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

View File

@ -91,6 +91,9 @@ def _normalize_platform_position(platform: str, position: Dict[str, Any]) -> Dic
"take_profit": position.get("take_profit"),
"stop_loss": position.get("stop_loss"),
"liquidation_price": position.get("liquidation_price"),
"setup_type": position.get("setup_type"),
"setup_basis": position.get("setup_basis"),
"entry_basis": position.get("entry_basis"),
"opened_at": position.get("opened_at") or position.get("created_at"),
}
@ -128,6 +131,9 @@ def _normalize_platform_order(platform: str, order: Dict[str, Any]) -> Dict[str,
"take_profit": order.get("take_profit"),
"signal_grade": order.get("signal_grade"),
"signal_type": order.get("signal_type"),
"setup_type": order.get("setup_type"),
"setup_basis": order.get("setup_basis"),
"entry_basis": order.get("entry_basis"),
"confidence": _safe_float(order.get("confidence")),
"created_at": order.get("created_at") or order.get("timestamp"),
}

View File

@ -126,6 +126,69 @@ class CryptoAgent:
},
}
SETUP_EXECUTION_PROFILES = {
'breakout_confirmation': {
'margin_multiplier': 0.75,
'max_margin_pct_cap': 0.12,
'same_direction_position_policy': 'no_add',
'same_direction_pending_policy': 'no_replace',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 3,
'allow_close_opposite_on_small_loss': False,
},
'breakout_pullback': {
'margin_multiplier': 0.95,
'max_margin_pct_cap': 0.18,
'same_direction_position_policy': 'hold',
'same_direction_pending_policy': 'replace_better',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 2,
},
'trend_continuation_pullback': {
'margin_multiplier': 1.0,
'max_margin_pct_cap': 0.18,
'same_direction_position_policy': 'scale_in',
'same_direction_pending_policy': 'replace_better',
'max_same_side_pending': 2,
'opposite_flip_confidence_delta': 2,
},
'deep_pullback_continuation': {
'margin_multiplier': 0.8,
'max_margin_pct_cap': 0.12,
'same_direction_position_policy': 'scale_in_only_if_deep_edge',
'same_direction_pending_policy': 'replace_better',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 4,
},
'range_reversal': {
'margin_multiplier': 0.7,
'max_margin_pct_cap': 0.10,
'same_direction_position_policy': 'no_add',
'same_direction_pending_policy': 'single_order_only',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 5,
'allow_close_opposite_on_small_loss': False,
},
'trend_reversal': {
'margin_multiplier': 0.55,
'max_margin_pct_cap': 0.08,
'same_direction_position_policy': 'no_add',
'same_direction_pending_policy': 'single_order_only',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 8,
'allow_close_opposite_on_small_loss': False,
},
'default': {
'margin_multiplier': 1.0,
'max_margin_pct_cap': 0.18,
'same_direction_position_policy': 'scale_in',
'same_direction_pending_policy': 'replace_better',
'max_same_side_pending': 2,
'opposite_flip_confidence_delta': 0,
'allow_close_opposite_on_small_loss': True,
},
}
TP_SL_RETRY_ALERT_THRESHOLD = 3
TP_SL_MAX_RETRY_BEFORE_ERROR = 6
TP_SL_ALERT_COOLDOWN_MINUTES = 15
@ -196,6 +259,7 @@ class CryptoAgent:
self.last_signals: Dict[str, Dict[str, Any]] = {}
self.last_execution_preview: Dict[str, Dict[str, Any]] = {}
self.signal_cooldown: Dict[str, datetime] = {}
self.symbol_trade_cooldown: Dict[str, Dict[str, Any]] = {}
# 账户初始余额持久化(用于计算回撤)
self._initial_balances: Dict[str, float] = {}
@ -288,6 +352,9 @@ class CryptoAgent:
"symbol": symbol or (decision or {}).get("symbol", ""),
"decision": (decision or {}).get("decision"),
"action": (decision or {}).get("action"),
"setup_type": (decision or {}).get("setup_type"),
"setup_basis": (decision or {}).get("setup_basis"),
"entry_basis": (decision or {}).get("entry_basis"),
"reason": reason or (decision or {}).get("reason") or (decision or {}).get("reasoning", ""),
}
if extra:
@ -1234,6 +1301,25 @@ class CryptoAgent:
price_change_24h = self._calculate_price_change(data['1h'])
logger.info(f"💰 当前价格: ${current_price:,.2f} ({price_change_24h})")
paper_symbol_cooldown = None
if self.settings.paper_trading_enabled:
paper_symbol_cooldown = self._refresh_symbol_trade_cooldown('PaperTrading', symbol)
if paper_symbol_cooldown.get('should_cool_down'):
cooldown_until = paper_symbol_cooldown.get('cooldown_until')
cooldown_text = cooldown_until.isoformat() if cooldown_until else ''
self._record_analysis_event(
"symbol_trade_cooldown",
symbol=symbol,
status="warning",
detail=paper_symbol_cooldown.get('reason', '交易对处于冷却中'),
extra={
"platform": "PaperTrading",
"losing_streak": paper_symbol_cooldown.get('losing_streak', 0),
"cooldown_until": cooldown_text,
},
)
logger.info(f"⏸️ 模拟盘交易对冷却: {paper_symbol_cooldown.get('reason')} | until={cooldown_text}")
# 1.5. 波动率检查(节省 LLM 调用)
should_analyze, volatility_reason, volatility = self._check_volatility(symbol, data)
if not should_analyze:
@ -1299,6 +1385,8 @@ class CryptoAgent:
'current_price': current_price
}
regime_profile = market_signal.get('regime_profile') or {}
# 过滤掉 wait 信号,只保留 buy/sell 信号
signals = market_signal.get('signals', [])
trade_signals = [s for s in signals if s.get('action') in ['buy', 'sell']]
@ -1314,7 +1402,11 @@ class CryptoAgent:
detail="完成分析,无交易信号",
extra={"trade_signals": 0, "valid_signals": 0},
)
logger.info(f"\n⏸️ 结论: 无交易信号(仅有观望建议),继续观望")
blocked_reasons = market_signal.get('blocked_reasons') or []
if blocked_reasons:
logger.info(f"\n⏸️ 结论: 当前市场状态不允许交易 | {''.join(blocked_reasons[:2])}")
else:
logger.info(f"\n⏸️ 结论: 无交易信号(仅有观望建议),继续观望")
return
# 检查是否有达到阈值的交易信号
@ -1356,7 +1448,13 @@ class CryptoAgent:
if self.settings.paper_trading_enabled:
logger.info(f"\n📊 【模拟盘】")
paper_positions, paper_account, paper_pending = self._get_paper_trading_state()
paper_signal = self._select_signal_for_platform(valid_signals, 'PaperTrading', market_state=market_signal.get('market_state', '中性'), trend_direction=market_signal.get('trend_direction', 'neutral'))
paper_signal = self._select_signal_for_platform(
valid_signals,
'PaperTrading',
market_state=market_signal.get('market_state', '中性'),
trend_direction=market_signal.get('trend_direction', 'neutral'),
regime_profile=regime_profile,
)
if paper_signal:
logger.info(
f" 采用信号: {paper_signal.get('timeframe', 'unknown')} | "
@ -1383,7 +1481,8 @@ class CryptoAgent:
valid_signals,
'Bitget',
market_state=market_signal.get('market_state', '中性'),
trend_direction=market_signal.get('trend_direction', 'neutral')
trend_direction=market_signal.get('trend_direction', 'neutral'),
regime_profile=regime_profile,
)
for account_id in self._iter_bitget_accounts():
logger.info(f"\n🔥 【Bitget:{account_id}")
@ -1698,6 +1797,13 @@ class CryptoAgent:
fallback['reasoning'] = fallback.get('reasoning', fallback.get('reason', f'未支持的决策类型: {decision_type}'))
return fallback
def _get_setup_execution_profile(self, signal: Dict[str, Any]) -> Dict[str, Any]:
setup_type = signal.get('setup_type') or 'default'
profile = dict(self.SETUP_EXECUTION_PROFILES['default'])
profile.update(self.SETUP_EXECUTION_PROFILES.get(setup_type, {}))
profile['setup_type'] = setup_type
return profile
async def _execute_decisions(self, paper_decision: Dict[str, Any],
bitget_decisions: Dict[str, Dict[str, Any]],
market_signal: Dict[str, Any], current_price: float):
@ -1974,11 +2080,22 @@ class CryptoAgent:
def _select_signal_for_platform(self, signals: List[Dict[str, Any]],
platform_name: str,
market_state: str = '中性',
trend_direction: str = 'neutral') -> Optional[Dict[str, Any]]:
trend_direction: str = 'neutral',
regime_profile: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""根据平台偏好和市场状态选择最适合执行的信号"""
if not signals:
return None
regime_profile = regime_profile or {}
allowed_lanes = set(regime_profile.get('allowed_lanes') or [])
if allowed_lanes:
signals = [
signal for signal in signals
if (signal.get('timeframe') or signal.get('type') or 'unknown') in allowed_lanes
]
if not signals:
return None
# 震荡市:趋势信号降权(信心 × 0.8),优先选择日内反转信号
adjusted_signals = []
for signal in signals:
@ -1995,6 +2112,15 @@ class CryptoAgent:
pass # 趋势市不降权日内信号
adjusted_signals.append(s)
preferred_lanes = regime_profile.get('preferred_lanes') or []
if preferred_lanes:
lane_priority = [
lane for lane in preferred_lanes
if lane in {sig.get('timeframe') or sig.get('type') or 'unknown' for sig in adjusted_signals}
]
else:
lane_priority = self.PLATFORM_SIGNAL_PRIORITY.get(platform_name, ['short_term', 'medium_term'])
# 逆势信号大幅降权(安全网,主过滤在 _merge_lane_results
if trend_direction in ('uptrend', 'downtrend'):
forbidden = 'sell' if trend_direction == 'uptrend' else 'buy'
@ -2005,7 +2131,6 @@ class CryptoAgent:
s['_trend_penalized'] = True
s['_original_confidence'] = original
lane_priority = self.PLATFORM_SIGNAL_PRIORITY.get(platform_name, ['short_term', 'medium_term'])
by_lane: Dict[str, List[Dict[str, Any]]] = {}
for signal in adjusted_signals:
lane = signal.get('timeframe') or signal.get('type') or 'unknown'
@ -2043,10 +2168,15 @@ class CryptoAgent:
'reasoning': signal.get('reasoning', ''),
'timeframe': signal_type,
'type': signal_type,
'setup_type': signal.get('setup_type', 'unknown'),
'setup_basis': signal.get('setup_basis', ''),
'entry_basis': signal.get('entry_basis', ''),
'volume_price_context': signal.get('volume_price_context', {}),
'position_size': position_size,
'current_price': current_price,
'market_state': market_signal.get('market_state', '中性') if market_signal else '中性',
'regime': range_metrics.get('regime', ''),
'regime_profile': market_signal.get('regime_profile', {}) if market_signal else {},
'range_metrics': range_metrics,
'market_location': market_location,
'funding_rate_data': funding_rate_data,
@ -2077,7 +2207,54 @@ class CryptoAgent:
def _get_signal_execution_rule(self, signal: Dict[str, Any]) -> Dict[str, float]:
signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term'
return self.SIGNAL_EXECUTION_RULES.get(signal_type, self.SIGNAL_EXECUTION_RULES['medium_term'])
rule = dict(self.SIGNAL_EXECUTION_RULES.get(signal_type, self.SIGNAL_EXECUTION_RULES['medium_term']))
profile = self._get_setup_execution_profile(signal)
if 'opposite_flip_confidence_delta' in profile:
rule['flip_confidence'] = min(
99,
rule.get('flip_confidence', 85) + int(profile.get('opposite_flip_confidence_delta', 0))
)
return rule
def _check_setup_execution_constraints(self, signal: Dict[str, Any]) -> tuple[bool, str]:
setup_type = signal.get('setup_type', 'unknown')
entry_type = signal.get('entry_type', 'market')
market_location = signal.get('market_location') or {}
volume_context = signal.get('volume_price_context') or {}
breakout_quality = volume_context.get('breakout_quality') or signal.get('breakout_quality')
pullback_quality = volume_context.get('pullback_quality') or signal.get('pullback_quality')
rejection_signal = volume_context.get('rejection_signal') or signal.get('rejection_signal')
exhaustion_risk = volume_context.get('exhaustion_risk') or signal.get('exhaustion_risk')
location_tag = market_location.get('location_tag', 'unknown')
if setup_type == 'breakout_confirmation':
if entry_type != 'market':
return False, "突破确认 setup 只能用 market 执行"
if breakout_quality not in {'acceptance_breakout_up', 'acceptance_breakout_down'}:
return False, "突破确认缺少接受型量价证据"
if setup_type in {'trend_continuation_pullback', 'deep_pullback_continuation', 'breakout_pullback'}:
if entry_type != 'limit':
return False, f"{setup_type} 应使用 limit 等待回踩/反抽"
if pullback_quality != 'healthy_pullback':
return False, f"{setup_type} 缺少健康缩量回调证据"
if location_tag in {'far_from_trade_zone', 'middle_of_range'}:
return False, f"{setup_type} 当前不在有效回踩交易区"
if setup_type == 'range_reversal':
if location_tag not in {'near_range_support', 'near_range_resistance'}:
return False, "区间反转 setup 不在区间边界"
if rejection_signal not in {'bullish_rejection', 'bearish_rejection'} and entry_type == 'market':
return False, "区间反转现价执行缺少明确拒绝信号"
if setup_type == 'trend_reversal':
if rejection_signal not in {'bullish_rejection', 'bearish_rejection'}:
return False, "趋势反转 setup 缺少拒绝/结构切换证据"
if exhaustion_risk in {'upside_climax', 'downside_climax'} and setup_type != 'trend_reversal':
return False, "当前量价处于高潮风险,非反转 setup 不执行"
return True, "setup 约束通过"
def _get_actionable_pending_orders(self, pending_orders: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return [order for order in pending_orders if not order.get('is_reduce_only')]
@ -2209,6 +2386,13 @@ class CryptoAgent:
if entry_type != 'limit':
return False, "仅 limit 信号考虑替换挂单"
profile = self._get_setup_execution_profile(signal)
pending_policy = profile.get('same_direction_pending_policy', 'replace_better')
if pending_policy == 'no_replace':
return False, "当前 setup 不主动替换挂单"
if pending_policy == 'single_order_only':
return False, "当前 setup 仅保留单个边界挂单"
signal_price = float(signal.get('entry_price', 0) or 0)
order_price = float(order.get('entry_price', 0) or 0)
current_price = float(signal.get('current_price', signal_price) or signal_price or 0)
@ -2309,6 +2493,9 @@ class CryptoAgent:
entry_type = best_signal.get('entry_type', 'market')
entry_type_text = '现价入场' if entry_type == 'market' else '挂单等待'
entry_type_icon = '' if entry_type == 'market' else ''
setup_type = best_signal.get('setup_type', 'unknown')
setup_basis = best_signal.get('setup_basis', '')
entry_basis = best_signal.get('entry_basis', '')
# 等级(基于信心度映射)- 与 market_signal_analyzer.py 保持一致
# A级(80-100): 量价配合 + 多指标共振 + 多周期确认
@ -2372,6 +2559,13 @@ class CryptoAgent:
f"{entry_type_icon} **入场**: {entry_type_text} | {position_icon} **仓位**: {position_text}",
f"",
]
if setup_type and setup_type != 'unknown':
content_parts.append(f"🧩 **Setup**: `{setup_type}`")
if setup_basis:
content_parts.append(f"📌 **Setup依据**: {setup_basis}")
if entry_basis:
content_parts.append(f"🎯 **入场依据**: {entry_basis}")
content_parts.append("")
# 入场价格显示
if entry_type == 'limit':
@ -2478,6 +2672,9 @@ class CryptoAgent:
signal_timeframe = best_signal.get('timeframe', best_signal.get('type', 'unknown')) if best_signal else 'unknown'
timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'}
timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe)
setup_type = best_signal.get('setup_type', 'unknown') if best_signal else 'unknown'
setup_basis = best_signal.get('setup_basis', '') if best_signal else ''
entry_basis = best_signal.get('entry_basis', '') if best_signal else ''
# 对限价单:用实际订单状态决定显示
# resting=真的在挂单中, filled=已立即成交, None=市价单或未知
@ -2560,10 +2757,20 @@ class CryptoAgent:
f"{entry_type_icon} **入场方式**: {entry_type_text}",
f"{position_display.replace(' ', ': **')} | 📈 信心度: **{confidence}%**",
f"",
]
if setup_type and setup_type != 'unknown':
content_parts.extend([
f"🧩 **Setup**: `{setup_type}`",
f"📌 **Setup依据**: {setup_basis}" if setup_basis else None,
f"🎯 **入场依据**: {entry_basis}" if entry_basis else None,
f"",
])
content_parts.extend([
f"💰 **名义仓位**: {position_value_display}",
f"🪙 **保证金 / 杠杆**: ${margin:,.2f} / {leverage}x" if isinstance(margin, (int, float)) and isinstance(leverage, (int, float)) else f"🪙 **保证金**: ${margin:,.2f}",
price_display,
]
])
content_parts = [part for part in content_parts if part is not None]
if isinstance(contracts, (int, float)) and contracts:
content_parts.append(f"📦 **合约张数**: {contracts}")
@ -3214,6 +3421,7 @@ class CryptoAgent:
max_leverage = account.get('max_total_leverage', 10)
order_leverage = account.get('order_leverage', 10)
min_effective_leverage = self.SIGNAL_MIN_EFFECTIVE_LEVERAGE.get(signal_type, 2.0)
setup_profile = self._get_setup_execution_profile(signal)
target_margin_pct, sizing_reason, _, _ = resolve_target_margin_pct(
position_size=position_size,
@ -3224,6 +3432,10 @@ class CryptoAgent:
default_positions=self.SIGNAL_POSITION_SIZE_DEFAULTS,
)
setup_margin_multiplier = float(setup_profile.get('margin_multiplier', 1.0) or 1.0)
if setup_margin_multiplier != 1.0:
target_margin_pct *= setup_margin_multiplier
# 市场状态仓位调整:震荡市降低仓位,避免来回被止损
regime = signal.get('regime', '')
REGIME_MARGIN_MULTIPLIERS = {
@ -3243,6 +3455,9 @@ class CryptoAgent:
logger.info(f" 📊 连败降温: 仓位系数={streak_multiplier}")
target_margin_pct *= streak_multiplier
setup_margin_pct_cap = setup_profile.get('max_margin_pct_cap')
effective_max_margin_pct = min(max_margin_pct, setup_margin_pct_cap) if isinstance(setup_margin_pct_cap, (int, float)) else max_margin_pct
margin, _, budget_reason = calculate_margin_and_position_value(
balance=balance,
available_margin=available,
@ -3250,7 +3465,7 @@ class CryptoAgent:
max_total_leverage=max_leverage,
order_leverage=order_leverage,
target_margin_pct=target_margin_pct,
max_margin_pct=max_margin_pct,
max_margin_pct=effective_max_margin_pct,
min_margin=min_margin,
min_effective_leverage=min_effective_leverage,
)
@ -3259,7 +3474,7 @@ class CryptoAgent:
return 0, budget_reason
return margin, (
f"{sizing_reason} | 平台: {platform_name} | "
f"{sizing_reason} | setup={setup_profile.get('setup_type')} x{setup_margin_multiplier:.2f} | 平台: {platform_name} | "
f"最小有效杠杆 {min_effective_leverage:.1f}x | "
f"限制后保证金 ${margin:.2f} ({budget_reason})"
)
@ -3278,6 +3493,10 @@ class CryptoAgent:
signal_price = signal.get('entry_price', 0)
entry_type = signal.get('entry_type', 'market')
rule = self._get_signal_execution_rule(signal)
setup_profile = self._get_setup_execution_profile(signal)
position_policy = setup_profile.get('same_direction_position_policy', 'scale_in')
pending_policy = setup_profile.get('same_direction_pending_policy', 'replace_better')
max_same_side_pending = int(setup_profile.get('max_same_side_pending', 2) or 2)
# 检查同向持仓
same_positions = [p for p in positions
@ -3299,8 +3518,18 @@ class CryptoAgent:
if position_protected:
return "HOLD", "同向持仓已进入保本/保护态,不再主动加仓"
if position_policy == 'no_add':
return "HOLD", f"{setup_profile.get('setup_type')} setup 避免同向叠加仓位"
if position_policy == 'hold':
return "HOLD", f"{setup_profile.get('setup_type')} setup 有同向持仓时优先持有,不追加"
# 规则1: 价格距离足够 + 持仓已有浮盈 + 新价格更优 → 加仓
if better_price and price_diff_pct >= rule['min_add_price_gap_pct'] and pnl_pct >= rule['min_add_profit_pct']:
if position_policy == 'scale_in_only_if_deep_edge':
location_tag = (signal.get('market_location') or {}).get('location_tag')
if location_tag not in {'near_long_zone', 'near_short_zone', 'near_range_support', 'near_range_resistance'}:
return "HOLD", "深回踩 continuation 仅在关键交易区边缘允许加仓"
return "ADD", f"加仓:价格差{price_diff_pct:.1f}%,盈利{pnl_pct:.1f}%"
# 规则2: 价格距离 < 2% → 忽略
@ -3332,6 +3561,9 @@ class CryptoAgent:
(signal_side == 'sell' and signal_price > order_price)
)
if pending_policy == 'single_order_only':
return "HOLD", f"{setup_profile.get('setup_type')} setup 已有同向挂单,保持单一挂单"
# 规则5: 价格距离 < 2% → 忽略
if price_diff_pct < 2:
return "IGNORE", f"同向挂单价格差{price_diff_pct:.1f}% < 2%,忽略"
@ -3342,10 +3574,10 @@ class CryptoAgent:
return "REPLACE_PENDING", f"同向挂单存在,{replace_reason}"
# 规则7: 价格距离 >= 2% 且挂单 < 3 → 可再挂一单
if len(same_orders) < 3:
if len(same_orders) < max_same_side_pending and pending_policy != 'no_replace' and signal_is_better:
return "OPEN", f"同向挂单价格差{price_diff_pct:.1f}% >= 2%,可开新单"
else:
return "IGNORE", "同向挂单已达3个,忽略"
return "IGNORE", f"同向挂单已达 setup 上限 {max_same_side_pending} 个,忽略"
# 无同向订单 → 正常开仓
return "OPEN", "无同向订单,正常开仓"
@ -3364,6 +3596,8 @@ class CryptoAgent:
opposite_side = 'sell' if signal_side == 'buy' else 'buy'
confidence = signal.get('confidence', 0)
rule = self._get_signal_execution_rule(signal)
setup_profile = self._get_setup_execution_profile(signal)
allow_close_opposite_on_small_loss = bool(setup_profile.get('allow_close_opposite_on_small_loss', True))
# 检查反向持仓
opposite_positions = [p for p in positions
@ -3387,6 +3621,8 @@ class CryptoAgent:
# 规则4: 小亏损 → 平仓
if -1 < pnl_pct < 0:
if not allow_close_opposite_on_small_loss:
return "WAIT", f"{setup_profile.get('setup_type')} setup 对反向小亏损仓位保持克制,等待进一步确认"
return "CLOSE_OPPOSITE", f"反向持仓小亏损{pnl_pct:.1f}%,平仓"
# 检查反向挂单
@ -3447,6 +3683,90 @@ class CryptoAgent:
return {'losing_streak': losing_streak, 'should_cool_down': False, 'margin_multiplier': 1.0, 'reason': ''}
def _get_symbol_cooldown_key(self, platform_name: str, symbol: str) -> str:
return f"{platform_name}:{self._normalize_symbol(symbol)}"
def _check_symbol_losing_streak(self, platform_name: str, symbol: str, max_lookback: int = 4) -> Dict[str, Any]:
"""
检查单个交易对近期连续亏损情况
当前仅对模拟盘启用实盘后续应由独立执行监管器基于成交回报维护
"""
normalized_symbol = self._normalize_symbol(symbol)
recent_orders = []
if platform_name == 'PaperTrading' and self.paper_trading:
recent_orders = self.paper_trading.get_order_history(symbol=normalized_symbol, limit=max_lookback)
if not recent_orders:
return {
'symbol': normalized_symbol,
'losing_streak': 0,
'cooldown_hours': 0,
'cooldown_until': None,
'should_cool_down': False,
'reason': '',
}
losing_streak = 0
last_loss_order = None
for order in recent_orders:
pnl = order.get('pnl_amount', 0) or 0
if pnl < 0:
losing_streak += 1
if last_loss_order is None:
last_loss_order = order
else:
break
cooldown_hours = 0
if losing_streak >= 3:
cooldown_hours = 6
elif losing_streak >= 2:
cooldown_hours = 2
cooldown_until = None
if cooldown_hours > 0 and last_loss_order:
closed_at = last_loss_order.get('closed_at') or last_loss_order.get('updated_at') or last_loss_order.get('created_at')
if closed_at:
try:
closed_at_dt = datetime.fromisoformat(str(closed_at).replace('Z', '+00:00'))
cooldown_until = closed_at_dt + timedelta(hours=cooldown_hours)
except ValueError:
cooldown_until = None
return {
'symbol': normalized_symbol,
'losing_streak': losing_streak,
'cooldown_hours': cooldown_hours,
'cooldown_until': cooldown_until,
'should_cool_down': bool(cooldown_until and datetime.now(cooldown_until.tzinfo) < cooldown_until),
'reason': (
f"{normalized_symbol} 最近连续亏损 {losing_streak} 次,暂停新开仓 {cooldown_hours} 小时"
if cooldown_until and cooldown_hours > 0 else ''
),
}
def _refresh_symbol_trade_cooldown(self, platform_name: str, symbol: str) -> Dict[str, Any]:
info = self._check_symbol_losing_streak(platform_name, symbol)
key = self._get_symbol_cooldown_key(platform_name, symbol)
if info.get('should_cool_down'):
self.symbol_trade_cooldown[key] = info
else:
self.symbol_trade_cooldown.pop(key, None)
return info
def _get_symbol_trade_cooldown(self, platform_name: str, symbol: str) -> Optional[Dict[str, Any]]:
key = self._get_symbol_cooldown_key(platform_name, symbol)
cached = self.symbol_trade_cooldown.get(key)
if cached and cached.get('cooldown_until'):
cooldown_until = cached['cooldown_until']
now = datetime.now(cooldown_until.tzinfo) if getattr(cooldown_until, 'tzinfo', None) else datetime.now()
if now < cooldown_until:
return cached
self.symbol_trade_cooldown.pop(key, None)
return self._refresh_symbol_trade_cooldown(platform_name, symbol)
def _check_risk_control(self, signal: Dict[str, Any],
platform_name: str,
account: Dict[str, Any],
@ -3459,6 +3779,22 @@ class CryptoAgent:
(passed, reason) - 是否通过和原因
"""
# 1. 杠杆限制检查
regime_profile = signal.get('regime_profile') or {}
tradability = regime_profile.get('tradability')
if tradability == 'avoid':
blocked_reasons = regime_profile.get('no_trade_reasons') or ['当前市场状态不允许交易']
return False, f"市场状态过滤: {''.join(blocked_reasons[:2])}"
symbol_cooldown = self._get_symbol_trade_cooldown(platform_name, signal.get('symbol', ''))
if symbol_cooldown and symbol_cooldown.get('should_cool_down'):
cooldown_until = symbol_cooldown.get('cooldown_until')
until_text = cooldown_until.isoformat() if cooldown_until else ''
return False, f"交易对冷却中: {symbol_cooldown.get('reason', '').strip()} {until_text}".strip()
setup_passed, setup_reason = self._check_setup_execution_constraints(signal)
if not setup_passed:
return False, f"setup 过滤: {setup_reason}"
current_leverage = account.get('current_total_leverage', 0)
max_leverage = account.get('max_total_leverage', 10)
remaining_leverage = max_leverage - current_leverage

View File

@ -75,6 +75,10 @@ class PaperTradingExecutor(BaseExecutor):
'signal_type': signal_type,
'type': raw_signal_type,
'reason': decision.get('reasoning', decision.get('reason', '')),
'setup_type': decision.get('setup_type'),
'setup_basis': decision.get('setup_basis'),
'entry_basis': decision.get('entry_basis'),
'volume_price_context': decision.get('volume_price_context'),
}
# 执行下单(统一调用方式)

View File

@ -0,0 +1,364 @@
from typing import Any, Dict, Optional
import numpy as np
import pandas as pd
from app.utils.logger import logger
class FeatureEngine:
"""将原始 K 线转换为供策略与 LLM 使用的高价值特征。"""
def get_session_open(self, df: Optional[pd.DataFrame]) -> Optional[float]:
"""获取当前交易日开盘价。"""
if df is None or df.empty:
return None
try:
if 'open_time' not in df.columns:
return float(df.iloc[-24]['open']) if len(df) >= 24 else float(df.iloc[0]['open'])
latest_time = pd.to_datetime(df['open_time'].iloc[-1])
session_start = latest_time.normalize()
today_bars = df[df['open_time'] >= session_start]
if not today_bars.empty:
return float(today_bars.iloc[0]['open'])
except Exception as e:
logger.debug(f"获取交易日开盘价失败: {e}")
return float(df.iloc[0]['open']) if not df.empty else None
def calculate_session_vwap(self, df: Optional[pd.DataFrame]) -> Optional[float]:
"""计算当前交易日 VWAP。"""
if df is None or df.empty or 'volume' not in df.columns:
return None
try:
session_df = df
if 'open_time' in df.columns:
latest_time = pd.to_datetime(df['open_time'].iloc[-1])
session_start = latest_time.normalize()
session_df = df[df['open_time'] >= session_start]
if session_df.empty:
return None
typical_price = (session_df['high'] + session_df['low'] + session_df['close']) / 3
volume = session_df['volume'].replace(0, np.nan)
total_volume = volume.sum()
if pd.isna(total_volume) or total_volume <= 0:
return None
return float((typical_price * session_df['volume']).sum() / total_volume)
except Exception as e:
logger.debug(f"计算 VWAP 失败: {e}")
return None
def calculate_opening_range(self, df: Optional[pd.DataFrame], bars: int = 6) -> Optional[Dict[str, float]]:
"""计算前 30 分钟开盘区间。"""
if df is None or df.empty or len(df) < bars:
return None
try:
session_df = df
if 'open_time' in df.columns:
latest_time = pd.to_datetime(df['open_time'].iloc[-1])
session_start = latest_time.normalize()
session_df = df[df['open_time'] >= session_start]
session_df = session_df.iloc[:bars]
if session_df.empty:
return None
return {
'high': float(session_df['high'].max()),
'low': float(session_df['low'].min())
}
except Exception as e:
logger.debug(f"计算开盘区间失败: {e}")
return None
def summarize_timeframe_features(self, df: Optional[pd.DataFrame], timeframe: str) -> Dict[str, Any]:
"""将单个周期的 K 线转换为高价值特征摘要。"""
feature = {
'timeframe': timeframe,
'available': False,
'close': None,
'ema_alignment': 'neutral',
'structure': 'unknown',
'momentum_3': None,
'momentum_12': None,
'rsi': None,
'atr_pct': None,
'volume_ratio': None,
'distance_to_ema20': None,
'distance_to_recent_high': None,
'distance_to_recent_low': None,
'is_accelerating': False,
'adx': None,
'trend_strength_adx': 'unknown',
'body_ratio': None,
'close_position_in_bar': None,
'upper_wick_ratio': None,
'lower_wick_ratio': None,
'range_expansion_ratio': None,
'pressure_bias': 'neutral',
'volume_price_state': 'neutral',
'breakout_quality': 'none',
'pullback_quality': 'neutral',
'rejection_signal': 'none',
'exhaustion_risk': 'low',
}
if df is None or df.empty or len(df) < 20:
return feature
latest = df.iloc[-1]
close = float(latest['close'])
ema5 = latest.get('ema5')
ema10 = latest.get('ema10')
ema20 = latest.get('ema20')
rsi = latest.get('rsi')
atr = latest.get('atr')
structure = self.infer_price_structure(df)
candle_context = self.analyze_candle_context(df)
volume_price = self.analyze_volume_price(df, structure=structure, candle_context=candle_context)
feature.update({
'available': True,
'close': close,
'rsi': float(rsi) if pd.notna(rsi) else None,
'atr_pct': float(atr / close * 100) if pd.notna(atr) and close > 0 else None,
'distance_to_ema20': self.distance_percent(close, ema20),
'structure': structure,
'momentum_3': self.window_return(df, 3),
'momentum_12': self.window_return(df, 12),
'volume_ratio': self.calculate_volume_ratio(df),
'is_accelerating': self.is_accelerating(df),
**candle_context,
**volume_price,
})
adx = latest.get('adx')
if pd.notna(adx):
feature['adx'] = float(adx)
if adx >= 40:
feature['trend_strength_adx'] = 'strong'
elif adx >= 25:
feature['trend_strength_adx'] = 'moderate'
elif adx >= 20:
feature['trend_strength_adx'] = 'weak'
else:
feature['trend_strength_adx'] = 'ranging'
if pd.notna(ema5) and pd.notna(ema10) and pd.notna(ema20):
if ema5 > ema10 > ema20:
feature['ema_alignment'] = 'bull'
elif ema5 < ema10 < ema20:
feature['ema_alignment'] = 'bear'
else:
feature['ema_alignment'] = 'mixed'
recent_window = df.iloc[-20:]
recent_high = float(recent_window['high'].max())
recent_low = float(recent_window['low'].min())
feature['distance_to_recent_high'] = self.distance_percent(close, recent_high)
feature['distance_to_recent_low'] = self.distance_percent(close, recent_low)
return feature
def analyze_candle_context(self, df: pd.DataFrame, window: int = 20) -> Dict[str, Any]:
"""提炼最新 K 线的实体、影线、收盘位置和波动扩张。"""
context = {
'body_ratio': None,
'close_position_in_bar': None,
'upper_wick_ratio': None,
'lower_wick_ratio': None,
'range_expansion_ratio': None,
}
if df is None or df.empty:
return context
latest = df.iloc[-1]
open_price = self._resolve_open_price(df)
close_price = float(latest['close'])
high_price = float(latest['high'])
low_price = float(latest['low'])
bar_range = max(high_price - low_price, 1e-9)
body_high = max(open_price, close_price)
body_low = min(open_price, close_price)
avg_range = None
if len(df) > 1:
recent_window = df.iloc[-window:]
avg_range = float((recent_window['high'] - recent_window['low']).mean())
context['body_ratio'] = body_ratio = abs(close_price - open_price) / bar_range
context['close_position_in_bar'] = (close_price - low_price) / bar_range
context['upper_wick_ratio'] = (high_price - body_high) / bar_range
context['lower_wick_ratio'] = (body_low - low_price) / bar_range
if avg_range and avg_range > 0:
context['range_expansion_ratio'] = bar_range / avg_range
return context
def analyze_volume_price(
self,
df: pd.DataFrame,
*,
structure: str,
candle_context: Optional[Dict[str, Any]] = None,
volume_window: int = 20,
) -> Dict[str, Any]:
"""将量价关系压缩成交易含义明确的状态字段。"""
result = {
'pressure_bias': 'neutral',
'volume_price_state': 'neutral',
'breakout_quality': 'none',
'pullback_quality': 'neutral',
'rejection_signal': 'none',
'exhaustion_risk': 'low',
}
if df is None or len(df) < 8:
return result
candle_context = candle_context or self.analyze_candle_context(df)
volume_ratio = self.calculate_volume_ratio(df, window=volume_window)
recent_move = self.window_return(df, 3) or 0.0
body_ratio = float(candle_context.get('body_ratio') or 0)
close_position = float(candle_context.get('close_position_in_bar') or 0.5)
upper_wick_ratio = float(candle_context.get('upper_wick_ratio') or 0)
lower_wick_ratio = float(candle_context.get('lower_wick_ratio') or 0)
range_expansion = float(candle_context.get('range_expansion_ratio') or 1.0)
close_price = float(df['close'].iloc[-1])
prior_window = df.iloc[-min(max(volume_window, 8) + 1, len(df)):-1]
prior_high = float(prior_window['high'].max()) if not prior_window.empty else close_price
prior_low = float(prior_window['low'].min()) if not prior_window.empty else close_price
breakout_up = close_price > prior_high
breakout_down = close_price < prior_low
if breakout_up:
if volume_ratio >= 1.2 and body_ratio >= 0.55 and close_position >= 0.72:
result['breakout_quality'] = 'acceptance_breakout_up'
else:
result['breakout_quality'] = 'weak_breakout_up'
elif breakout_down:
if volume_ratio >= 1.2 and body_ratio >= 0.55 and close_position <= 0.28:
result['breakout_quality'] = 'acceptance_breakout_down'
else:
result['breakout_quality'] = 'weak_breakout_down'
if upper_wick_ratio >= 0.4 and close_position <= 0.45 and volume_ratio >= 1.15:
result['rejection_signal'] = 'bearish_rejection'
elif lower_wick_ratio >= 0.4 and close_position >= 0.55 and volume_ratio >= 1.15:
result['rejection_signal'] = 'bullish_rejection'
if structure == 'HH/HL' and recent_move < 0:
result['pullback_quality'] = 'healthy_pullback' if volume_ratio <= 0.95 else 'heavy_sell_pullback'
elif structure == 'LH/LL' and recent_move > 0:
result['pullback_quality'] = 'healthy_pullback' if volume_ratio <= 0.95 else 'heavy_buy_pullback'
if abs(recent_move) >= 1.5 and volume_ratio >= 1.8 and body_ratio <= 0.35:
if recent_move > 0:
result['exhaustion_risk'] = 'upside_climax'
elif recent_move < 0:
result['exhaustion_risk'] = 'downside_climax'
elif volume_ratio >= 1.6 and range_expansion <= 0.8:
result['exhaustion_risk'] = 'high_volume_churn'
if result['breakout_quality'] == 'acceptance_breakout_up':
result['pressure_bias'] = 'bullish'
result['volume_price_state'] = 'bullish_acceptance'
elif result['breakout_quality'] == 'acceptance_breakout_down':
result['pressure_bias'] = 'bearish'
result['volume_price_state'] = 'bearish_acceptance'
elif result['rejection_signal'] == 'bullish_rejection':
result['pressure_bias'] = 'bullish'
result['volume_price_state'] = 'bullish_rejection'
elif result['rejection_signal'] == 'bearish_rejection':
result['pressure_bias'] = 'bearish'
result['volume_price_state'] = 'bearish_rejection'
elif structure == 'HH/HL' and recent_move > 0 and volume_ratio >= 1.05 and close_position >= 0.6:
result['pressure_bias'] = 'bullish'
result['volume_price_state'] = 'bullish_continuation'
elif structure == 'LH/LL' and recent_move < 0 and volume_ratio >= 1.05 and close_position <= 0.4:
result['pressure_bias'] = 'bearish'
result['volume_price_state'] = 'bearish_continuation'
elif result['pullback_quality'] == 'healthy_pullback':
result['volume_price_state'] = 'pullback_on_light_volume'
elif result['pullback_quality'] in {'heavy_sell_pullback', 'heavy_buy_pullback'}:
result['volume_price_state'] = 'counter_pressure_expanding'
return result
def infer_price_structure(self, df: pd.DataFrame, lookback: int = 20) -> str:
"""根据分段高低点判断 HH/HL / LH/LL / 区间。"""
if df is None or len(df) < lookback:
return "unknown"
window = df.iloc[-lookback:]
half = max(lookback // 2, 5)
first = window.iloc[:half]
second = window.iloc[-half:]
prev_high = float(first['high'].max())
prev_low = float(first['low'].min())
recent_high = float(second['high'].max())
recent_low = float(second['low'].min())
if recent_high > prev_high and recent_low > prev_low:
return "HH/HL"
if recent_high < prev_high and recent_low < prev_low:
return "LH/LL"
return "range/mixed"
def window_return(self, df: pd.DataFrame, bars: int) -> Optional[float]:
if df is None or len(df) <= bars:
return None
start_price = float(df['close'].iloc[-bars - 1])
end_price = float(df['close'].iloc[-1])
if start_price <= 0:
return None
return (end_price - start_price) / start_price * 100
def calculate_volume_ratio(self, df: pd.DataFrame, window: int = 20) -> float:
if df is None or len(df) <= 1:
return 1.0
lookback = min(window, len(df) - 1)
latest_volume = float(df['volume'].iloc[-1])
baseline = float(df['volume'].iloc[-(lookback + 1):-1].mean())
if baseline <= 0:
return 1.0
return latest_volume / baseline
def is_accelerating(self, df: pd.DataFrame, bars: int = 3, threshold: float = 0.3) -> bool:
if df is None or len(df) < bars + 1:
return False
closes = df['close'].iloc[-(bars + 1):].values
changes = [
(closes[i] - closes[i - 1]) / closes[i - 1] * 100
for i in range(1, len(closes))
if closes[i - 1] > 0
]
if len(changes) < bars:
return False
same_direction = all(change > 0 for change in changes) or all(change < 0 for change in changes)
large_enough = sum(1 for change in changes if abs(change) >= threshold) >= bars - 1
return same_direction and large_enough
def distance_percent(self, value: Optional[float], reference: Optional[float]) -> Optional[float]:
if value is None or reference is None or pd.isna(reference) or reference == 0:
return None
return (float(value) - float(reference)) / float(reference) * 100
def _resolve_open_price(self, df: pd.DataFrame) -> float:
latest = df.iloc[-1]
open_value = latest.get('open')
if pd.notna(open_value):
return float(open_value)
if len(df) >= 2:
return float(df['close'].iloc[-2])
return float(latest['close'])

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,169 @@
"""
市场状态分类引擎
职责
- 根据量化特征判断当前市场处于哪类 regime
- 输出当前允许的交易行为集合
- LLM 与执行层提供统一约束
"""
from typing import Any, Dict, List
class RegimeEngine:
"""市场状态分类引擎"""
def classify(
self,
*,
range_metrics: Dict[str, Any] | None,
market_location: Dict[str, Any] | None,
trend_direction: str = "neutral",
trend_strength: str = "weak",
derivatives_state: Dict[str, Any] | None = None,
reversal_detection: Dict[str, Any] | None = None,
trend_stage: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
range_metrics = range_metrics or {}
market_location = market_location or {}
derivatives_state = derivatives_state or {}
reversal_detection = reversal_detection or {}
trend_stage = trend_stage or {}
location_tag = str(market_location.get("location_tag") or "unknown")
relative_to_range = str(market_location.get("relative_to_range") or "unknown")
range_regime = str(range_metrics.get("regime") or "unknown")
crowding_regime = str(derivatives_state.get("crowding_regime") or "low")
crowding_bias = str(derivatives_state.get("crowding_bias") or "neutral")
bb_squeeze = bool(range_metrics.get("bb_squeeze"))
no_trade_reasons: List[str] = []
profile: Dict[str, Any] = {
"regime_key": "neutral",
"market_state_label": "中性市",
"tradability": "avoid",
"risk_mode": "defensive",
"allowed_lanes": [],
"preferred_lanes": [],
"allowed_setups": [],
"preferred_entry_types": [],
"crowding_bias": crowding_bias,
"crowding_regime": crowding_regime,
"no_trade_reasons": no_trade_reasons,
"summary": "",
}
if location_tag in {"middle_of_range", "far_from_trade_zone"}:
no_trade_reasons.append(f"位置不佳: {location_tag}")
if range_regime == "ranging":
profile.update(
{
"regime_key": "range",
"market_state_label": "震荡市",
"tradability": "selective",
"risk_mode": "defensive",
"allowed_lanes": ["short_term"],
"preferred_lanes": ["short_term", "medium_term"],
"allowed_setups": ["range_reversal"],
"preferred_entry_types": ["limit", "market"],
}
)
if relative_to_range not in {"near_range_support", "near_range_resistance"}:
no_trade_reasons.append("震荡市但不在区间边界")
elif range_regime == "transitional":
if bb_squeeze and location_tag not in {"far_from_trade_zone", "middle_of_range"}:
profile.update(
{
"regime_key": "breakout_compression",
"market_state_label": "压缩待突破",
"tradability": "selective",
"risk_mode": "defensive",
"allowed_lanes": ["short_term"],
"preferred_lanes": ["short_term", "medium_term"],
"allowed_setups": ["breakout_confirmation", "breakout_pullback"],
"preferred_entry_types": ["market", "limit"],
}
)
else:
profile.update(
{
"regime_key": "transition",
"market_state_label": "过渡市",
"tradability": "avoid",
"risk_mode": "defensive",
"allowed_lanes": [],
"preferred_lanes": [],
"allowed_setups": [],
"preferred_entry_types": [],
}
)
no_trade_reasons.append("趋势与震荡切换期,方向优势不足")
elif range_regime in {"weak_trend", "strong_trend"} and trend_direction in {"uptrend", "downtrend"}:
allow_reversal = bool(reversal_detection.get("is_reversing")) and trend_strength != "strong"
if crowding_regime == "high":
profile.update(
{
"regime_key": "crowded_trend",
"market_state_label": "拥挤趋势",
"tradability": "selective",
"risk_mode": "defensive",
"allowed_lanes": ["medium_term"],
"preferred_lanes": ["medium_term", "short_term"],
"allowed_setups": ["deep_pullback_continuation"],
"preferred_entry_types": ["limit"],
}
)
if location_tag not in {"near_long_zone", "near_short_zone"}:
no_trade_reasons.append("趋势拥挤,且不在深回踩/反抽交易区")
else:
profile.update(
{
"regime_key": "trend",
"market_state_label": "趋势市",
"tradability": "tradable",
"risk_mode": "normal" if range_regime == "strong_trend" else "reduced",
"allowed_lanes": ["medium_term", "short_term"],
"preferred_lanes": ["medium_term", "short_term"],
"allowed_setups": ["trend_continuation_pullback"],
"preferred_entry_types": ["limit", "market"],
}
)
if allow_reversal:
profile["allowed_setups"].append("trend_reversal")
if location_tag in {"far_from_trade_zone", "middle_of_range"}:
profile["tradability"] = "selective"
no_trade_reasons.append("趋势存在,但当前价格没有位置优势")
if str(trend_stage.get("stage") or "") == "late":
profile["tradability"] = "selective"
profile["risk_mode"] = "defensive"
no_trade_reasons.append("趋势晚期,避免追价")
else:
profile.update(
{
"regime_key": "neutral",
"market_state_label": "中性市",
"tradability": "avoid",
"risk_mode": "defensive",
"allowed_lanes": [],
"preferred_lanes": [],
"allowed_setups": [],
"preferred_entry_types": [],
}
)
no_trade_reasons.append("无清晰市场结构")
if no_trade_reasons and profile["tradability"] == "selective":
profile["summary"] = f"{profile['market_state_label']} | 谨慎交易 | {''.join(no_trade_reasons[:2])}"
elif no_trade_reasons and profile["tradability"] == "avoid":
profile["summary"] = f"{profile['market_state_label']} | 观望优先 | {''.join(no_trade_reasons[:2])}"
else:
profile["summary"] = (
f"{profile['market_state_label']} | "
f"允许: {', '.join(profile['allowed_setups']) if profile['allowed_setups'] else '空仓'}"
)
return profile

View File

@ -0,0 +1,119 @@
"""
市场状态到交易行为的硬约束策略
"""
from typing import Any, Dict, List, Tuple
class SetupPolicy:
"""交易行为约束策略"""
RANGE_ONLY_SETUPS = {"range_reversal"}
TREND_ONLY_SETUPS = {"trend_continuation_pullback", "deep_pullback_continuation", "trend_reversal"}
BREAKOUT_ONLY_SETUPS = {"breakout_confirmation", "breakout_pullback"}
def filter_signals(
self,
signals: List[Dict[str, Any]],
regime_profile: Dict[str, Any],
) -> Tuple[List[Dict[str, Any]], List[str]]:
allowed_lanes = set(regime_profile.get("allowed_lanes") or [])
allowed_setups = set(regime_profile.get("allowed_setups") or [])
tradability = regime_profile.get("tradability", "avoid")
reasons: List[str] = []
if tradability == "avoid" or not allowed_lanes or not allowed_setups:
reasons.extend(regime_profile.get("no_trade_reasons") or ["当前市场状态不允许交易"])
return [], reasons
kept: List[Dict[str, Any]] = []
for signal in signals or []:
lane = signal.get("timeframe") or signal.get("type") or "unknown"
setup_type = self._infer_setup_type(signal)
setup_basis = self._build_setup_basis(signal, setup_type)
entry_basis = self._build_entry_basis(signal, setup_type)
if lane not in allowed_lanes:
reasons.append(f"{lane} 不在允许交易周期内")
continue
if setup_type not in allowed_setups:
reasons.append(f"{setup_type} 不在允许 setup 内")
continue
kept.append({
**signal,
"setup_type": setup_type,
"setup_basis": setup_basis,
"entry_basis": entry_basis,
})
return kept, reasons
def _infer_setup_type(self, signal: Dict[str, Any]) -> str:
lane = signal.get("timeframe") or signal.get("type") or "medium_term"
action = signal.get("action")
entry_type = signal.get("entry_type", "market")
location_tag = ((signal.get("market_location") or {}).get("location_tag") or "unknown")
regime = signal.get("regime", "")
trend_stage = (signal.get("trend_stage") or {}).get("stage") or "unknown"
volume_context = signal.get("volume_price_context") or {}
breakout_quality = volume_context.get("breakout_quality") or signal.get("breakout_quality")
pullback_quality = volume_context.get("pullback_quality") or signal.get("pullback_quality")
rejection_signal = volume_context.get("rejection_signal") or signal.get("rejection_signal")
volume_price_state = volume_context.get("volume_price_state") or signal.get("volume_price_state")
if regime == "ranging" or location_tag in {"near_range_support", "near_range_resistance"}:
return "range_reversal"
if regime == "transitional" and (
breakout_quality in {"acceptance_breakout_up", "acceptance_breakout_down"} or
volume_price_state in {"bullish_acceptance", "bearish_acceptance"}
) and entry_type == "market":
return "breakout_confirmation"
if regime == "transitional" and entry_type == "limit":
return "breakout_pullback"
if lane == "medium_term" and entry_type == "limit" and pullback_quality == "healthy_pullback":
return "trend_continuation_pullback"
if lane == "short_term" and entry_type == "limit" and location_tag in {"near_long_zone", "near_short_zone"} and pullback_quality == "healthy_pullback":
return "deep_pullback_continuation"
if lane == "medium_term" and action in {"buy", "sell"} and (
rejection_signal in {"bullish_rejection", "bearish_rejection"} or trend_stage == "early"
):
return "trend_reversal"
return "unknown"
def _build_setup_basis(self, signal: Dict[str, Any], setup_type: str) -> str:
market_location = signal.get("market_location") or {}
volume_context = signal.get("volume_price_context") or {}
parts: List[str] = []
location_tag = market_location.get("location_tag")
if location_tag and location_tag != "unknown":
parts.append(f"location={location_tag}")
for key in ("volume_price_state", "breakout_quality", "pullback_quality", "rejection_signal"):
value = volume_context.get(key) or signal.get(key)
if value and value not in {"none", "neutral", "unknown"}:
parts.append(f"{key}={value}")
if setup_type != "unknown":
parts.insert(0, f"setup={setup_type}")
return " | ".join(parts[:4]) if parts else f"setup={setup_type}"
def _build_entry_basis(self, signal: Dict[str, Any], setup_type: str) -> str:
entry_type = signal.get("entry_type", "market")
market_location = signal.get("market_location") or {}
if setup_type == "breakout_confirmation":
return "breakout_acceptance_follow_through"
if setup_type in {"breakout_pullback", "trend_continuation_pullback", "deep_pullback_continuation"}:
return "pullback_into_trade_zone" if entry_type == "limit" else "pullback_confirmed"
if setup_type == "range_reversal":
location_tag = market_location.get("location_tag", "range_edge")
return f"reversal_from_{location_tag}"
if setup_type == "trend_reversal":
return "rejection_or_structure_shift"
return "generic_entry"

View File

@ -137,4 +137,7 @@ class PaperOrder(Base):
'opened_at': format_time(self.opened_at),
'closed_at': format_time(self.closed_at),
'entry_reasons': self.entry_reasons,
'setup_type': (self.indicators or {}).get('setup_type'),
'setup_basis': (self.indicators or {}).get('setup_basis'),
'entry_basis': (self.indicators or {}).get('entry_basis'),
}

View File

@ -357,7 +357,13 @@ class PaperTradingService:
status=status,
opened_at=opened_at,
entry_reasons=[signal.get('reason', '')] if signal.get('reason') else signal.get('reasons', []),
indicators=signal.get('indicators', {})
indicators={
**(signal.get('indicators', {}) or {}),
'setup_type': signal.get('setup_type'),
'setup_basis': signal.get('setup_basis'),
'entry_basis': signal.get('entry_basis'),
'volume_price_context': signal.get('volume_price_context'),
}
)
db.add(order)

View File

@ -186,7 +186,18 @@ def test_resume_platform_resets_initial_balance_and_clears_halt():
def test_execution_events_are_recorded_and_returned_in_reverse_time_order():
agent = make_agent()
agent._record_execution_event('Bitget', 'open_failed', symbol='ETHUSDT', reason='余额不足', status='error')
agent._record_execution_event(
'Bitget',
'open_failed',
symbol='ETHUSDT',
reason='余额不足',
status='error',
decision={
'setup_type': 'breakout_confirmation',
'setup_basis': 'setup=breakout_confirmation | breakout_quality=acceptance_breakout_up',
'entry_basis': 'breakout_acceptance_follow_through',
},
)
agent._record_execution_event('PaperTrading', 'hold', symbol='BTCUSDT', reason='已有盈利反向仓', status='hold')
events = agent.get_recent_execution_events(limit=10)
@ -196,6 +207,7 @@ def test_execution_events_are_recorded_and_returned_in_reverse_time_order():
assert events[0]['event_type'] == 'hold'
assert events[1]['platform'] == 'Bitget'
assert events[1]['reason'] == '余额不足'
assert events[1]['setup_type'] == 'breakout_confirmation'
def test_get_status_contains_last_execution_preview():

View File

@ -99,8 +99,13 @@ def make_agent():
agent.SIGNAL_POSITION_SIZE_DEFAULTS = {}
agent.SIGNAL_MARGIN_MULTIPLIERS = {}
agent.PLATFORM_RULES = {'Bitget': {'min_margin': {}, 'max_margin_pct': 0.25}}
agent.SIGNAL_EXECUTION_RULES = CryptoAgent.SIGNAL_EXECUTION_RULES
agent.SETUP_EXECUTION_PROFILES = CryptoAgent.SETUP_EXECUTION_PROFILES
agent.symbol_trade_cooldown = {}
agent._check_losing_streak = MagicMock(return_value={'should_cool_down': False})
agent._get_symbol_trade_cooldown = MagicMock(return_value=None)
agent._calculate_position_size = MagicMock(return_value=(100.0, 'ok'))
agent._normalize_symbol = lambda symbol: symbol
return agent
@ -218,8 +223,140 @@ def test_opposite_position_uses_current_price_to_protect_profitable_medium_term_
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, [])
assert decision['decision'] == 'HOLD'
assert decision['action'] == 'HOLD'
assert '反向持仓盈利' in decision['reason']
def test_regime_profile_avoid_blocks_execution_even_with_valid_signal():
agent = make_agent()
signal = {
'symbol': 'BTCUSDT',
'action': 'buy',
'entry_type': 'limit',
'entry_price': 100.0,
'stop_loss': 98.0,
'take_profit': 104.0,
'confidence': 82,
'timeframe': 'medium_term',
'type': 'medium_term',
'position_size': 'medium',
'regime_profile': {
'tradability': 'avoid',
'no_trade_reasons': ['位置不佳: middle_of_range'],
},
'funding_rate_data': {'funding_rate_percent': 0.01},
}
account = {
'current_total_leverage': 0,
'max_total_leverage': 10,
'available': 1000,
}
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], [])
assert decision['decision'] == 'HOLD'
assert decision['action'] == 'IGNORE'
assert '市场状态过滤' in decision['reason']
def test_symbol_cooldown_blocks_execution_even_with_valid_signal():
agent = make_agent()
agent._get_symbol_trade_cooldown = MagicMock(return_value={
'should_cool_down': True,
'reason': 'BTCUSDT 最近连续亏损 2 次,暂停新开仓 2 小时',
'cooldown_until': None,
})
signal = {
'symbol': 'BTCUSDT',
'action': 'buy',
'entry_type': 'limit',
'entry_price': 100.0,
'stop_loss': 98.0,
'take_profit': 104.0,
'confidence': 82,
'timeframe': 'medium_term',
'type': 'medium_term',
'position_size': 'medium',
'funding_rate_data': {'funding_rate_percent': 0.01},
}
account = {
'current_total_leverage': 0,
'max_total_leverage': 10,
'available': 1000,
}
decision = agent.execute_signal_with_rules(signal, 'PaperTrading', account, [], [])
assert decision['decision'] == 'HOLD'
assert decision['action'] == 'IGNORE'
assert '交易对冷却中' in decision['reason']
def test_breakout_confirmation_requires_market_and_acceptance_context():
agent = make_agent()
agent._get_symbol_trade_cooldown = MagicMock(return_value=None)
signal = {
'symbol': 'BTCUSDT',
'action': 'buy',
'entry_type': 'limit',
'entry_price': 100.0,
'stop_loss': 98.0,
'take_profit': 104.0,
'confidence': 84,
'timeframe': 'short_term',
'type': 'short_term',
'setup_type': 'breakout_confirmation',
'volume_price_context': {
'breakout_quality': 'weak_breakout_up',
},
'position_size': 'light',
'funding_rate_data': {'funding_rate_percent': 0.01},
}
account = {
'current_total_leverage': 0,
'max_total_leverage': 10,
'available': 1000,
}
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], [])
assert decision['decision'] == 'HOLD'
assert 'setup 过滤' in decision['reason']
def test_trend_continuation_pullback_requires_healthy_pullback_context():
agent = make_agent()
agent._get_symbol_trade_cooldown = MagicMock(return_value=None)
signal = {
'symbol': 'BTCUSDT',
'action': 'buy',
'entry_type': 'limit',
'entry_price': 100.0,
'stop_loss': 97.5,
'take_profit': 106.0,
'confidence': 82,
'timeframe': 'medium_term',
'type': 'medium_term',
'setup_type': 'trend_continuation_pullback',
'market_location': {'location_tag': 'near_long_zone'},
'volume_price_context': {
'pullback_quality': 'heavy_sell_pullback',
},
'position_size': 'medium',
'funding_rate_data': {'funding_rate_percent': 0.01},
}
account = {
'current_total_leverage': 0,
'max_total_leverage': 10,
'available': 1000,
}
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], [])
assert decision['decision'] == 'HOLD'
assert 'setup 过滤' in decision['reason']
def test_short_term_super_strong_signal_can_flip_when_opposite_profit_is_small():
@ -422,6 +559,109 @@ def test_between_trade_zones_short_term_signal_can_replace_pending_order_when_ne
assert decision['orders_to_cancel'] == ['old-1']
def test_range_reversal_will_not_add_when_same_direction_position_exists():
agent = make_agent()
signal = {
'symbol': 'BTCUSDT',
'action': 'buy',
'entry_type': 'limit',
'entry_price': 98.0,
'current_price': 101.0,
'stop_loss': 96.0,
'take_profit': 106.0,
'confidence': 78,
'timeframe': 'medium_term',
'type': 'medium_term',
'setup_type': 'range_reversal',
'market_location': {'location_tag': 'near_range_support'},
'volume_price_context': {'rejection_signal': 'bullish_rejection'},
'funding_rate_data': {'funding_rate_percent': 0.01},
}
positions = [{
'symbol': 'BTCUSDT',
'side': 'buy',
'entry_price': 100.0,
'stop_loss': 98.0,
'take_profit': 106.0,
'current_price': 101.0,
}]
account = {'current_total_leverage': 0, 'max_total_leverage': 10, 'available': 1000}
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, [])
assert decision['decision'] == 'HOLD'
assert '避免同向叠加仓位' in decision['reason']
def test_breakout_confirmation_will_not_replace_existing_same_side_pending_order():
agent = make_agent()
signal = {
'symbol': 'BTCUSDT',
'action': 'buy',
'entry_type': 'market',
'entry_price': 101.0,
'current_price': 101.0,
'stop_loss': 99.0,
'take_profit': 105.0,
'confidence': 90,
'timeframe': 'short_term',
'type': 'short_term',
'setup_type': 'breakout_confirmation',
'volume_price_context': {'breakout_quality': 'acceptance_breakout_up'},
'funding_rate_data': {'funding_rate_percent': 0.01},
}
pending_orders = [{
'order_id': 'old-1',
'symbol': 'BTCUSDT',
'side': 'buy',
'entry_price': 99.0,
'entry_type': 'limit',
'is_reduce_only': False,
'created_at': '2026-04-22T10:00:00',
}]
account = {'current_total_leverage': 0, 'max_total_leverage': 10, 'available': 1000}
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], pending_orders)
assert decision['decision'] == 'HOLD'
assert '同向挂单已达 setup 上限' in decision['reason']
def test_trend_reversal_waits_instead_of_closing_small_losing_opposite_position():
agent = make_agent()
signal = {
'symbol': 'BTCUSDT',
'action': 'buy',
'entry_type': 'market',
'entry_price': 100.0,
'current_price': 100.4,
'stop_loss': 98.0,
'take_profit': 106.0,
'confidence': 92,
'timeframe': 'medium_term',
'type': 'medium_term',
'setup_type': 'trend_reversal',
'market_location': {'location_tag': 'near_long_zone'},
'volume_price_context': {'rejection_signal': 'bullish_rejection'},
'funding_rate_data': {'funding_rate_percent': 0.01},
}
positions = [{
'symbol': 'BTCUSDT',
'side': 'sell',
'entry_price': 100.0,
'current_price': 100.4,
}]
account = {'current_total_leverage': 0, 'max_total_leverage': 10, 'available': 1000}
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, [])
assert decision['decision'] == 'HOLD'
assert '反向小亏损仓位保持克制' in decision['reason']
def test_runtime_position_state_derives_protection_and_remaining_target():
agent = make_agent()

View File

@ -214,6 +214,151 @@ def test_market_location_summary_marks_middle_of_range_and_far_from_trade_zone()
assert far_location["location_tag"] == "far_from_trade_zone"
def test_serialize_feature_block_includes_volume_price_fields():
analyzer = make_analyzer()
block = analyzer._serialize_feature_block(
{
"available": True,
"structure": "HH/HL",
"ema_alignment": "bull",
"momentum_3": 1.2,
"momentum_12": 3.4,
"rsi": 61.5,
"atr_pct": 1.1,
"volume_ratio": 1.35,
"body_ratio": 0.68,
"close_position_in_bar": 0.82,
"upper_wick_ratio": 0.08,
"lower_wick_ratio": 0.1,
"range_expansion_ratio": 1.45,
"pressure_bias": "bullish",
"volume_price_state": "bullish_acceptance",
"breakout_quality": "acceptance_breakout_up",
"pullback_quality": "healthy_pullback",
"rejection_signal": "none",
"exhaustion_risk": "low",
"distance_to_ema20": 2.5,
"distance_to_recent_high": -0.4,
"distance_to_recent_low": 4.2,
"is_accelerating": True,
"adx": 24.8,
"trend_strength_adx": "weak",
}
)
assert block["volume_price_state"] == "bullish_acceptance"
assert block["breakout_quality"] == "acceptance_breakout_up"
assert block["pressure_bias"] == "bullish"
assert block["body_ratio"] == 0.68
assert block["range_expansion_ratio"] == 1.45
def test_feature_engine_detects_bullish_acceptance_breakout():
analyzer = make_analyzer()
opens = [100 + i * 0.25 for i in range(19)] + [104.8]
closes = [100.2 + i * 0.25 for i in range(19)] + [108.6]
lows = [price - 0.35 for price in opens[:-1]] + [104.6]
highs = [price + 0.45 for price in closes[:-1]] + [108.8]
volumes = [100 + i * 4 for i in range(19)] + [260]
df = pd.DataFrame(
{
"open": opens,
"close": closes,
"low": lows,
"high": highs,
"ema5": [102.5] * 20,
"ema10": [101.8] * 20,
"ema20": [100.8] * 20,
"rsi": [55] * 20,
"atr": [1.0] * 20,
"adx": [28] * 20,
"volume": volumes,
}
)
feature = analyzer.feature_engine.summarize_timeframe_features(df, "15m")
assert feature["volume_price_state"] == "bullish_acceptance"
assert feature["breakout_quality"] == "acceptance_breakout_up"
assert feature["pressure_bias"] == "bullish"
def test_quantify_ranging_state_prefers_price_action_compression_and_flip_count():
analyzer = make_analyzer()
closes = [100.0, 100.4, 100.1, 100.5, 100.2, 100.6, 100.25, 100.55, 100.3, 100.5,
100.35, 100.45, 100.3, 100.4, 100.32, 100.42, 100.34, 100.41, 100.36, 100.4]
highs = [c + 0.35 for c in closes]
lows = [c - 0.35 for c in closes]
df = pd.DataFrame(
{
"open": [closes[0]] + closes[:-1],
"close": closes,
"high": highs,
"low": lows,
"ema5": [100.35] * 20,
"ema10": [100.33] * 20,
"ema20": [100.31] * 20,
"atr": [0.7] * 20,
"adx": [18] * 20,
"bb_upper": [100.9] * 20,
"bb_lower": [99.9] * 20,
"volume": [100 + (i % 3) * 5 for i in range(20)],
}
)
result = analyzer._quantify_ranging_state({"1h": df})
assert result["regime"] == "ranging"
assert result["swing_flip_count"] >= 6
assert result["range_efficiency"] < 0.4
def test_detect_trend_stage_uses_breakout_acceptance_as_early_signal():
analyzer = make_analyzer()
closes = [100 + i * 0.35 for i in range(23)] + [109.2]
opens = [99.8 + i * 0.35 for i in range(23)] + [105.5]
highs = [c + 0.3 for c in closes[:-1]] + [109.5]
lows = [o - 0.2 for o in opens[:-1]] + [105.2]
volumes = [110 + i * 3 for i in range(23)] + [320]
df_1h = pd.DataFrame(
{
"open": opens,
"close": closes,
"high": highs,
"low": lows,
"ema5": [105.8] * 24,
"ema10": [104.8] * 24,
"ema20": [103.6] * 24,
"rsi": [58] * 24,
"atr": [1.1] * 24,
"adx": [29] * 24,
"volume": volumes,
}
)
df_4h = pd.DataFrame(
{
"open": [95 + i * 1.2 for i in range(20)],
"close": [95.5 + i * 1.2 for i in range(20)],
"high": [96 + i * 1.2 for i in range(20)],
"low": [94.6 + i * 1.2 for i in range(20)],
"ema5": [110] * 20,
"ema10": [108.5] * 20,
"ema20": [106] * 20,
"rsi": [60] * 20,
"atr": [2.2] * 20,
"adx": [26] * 20,
"volume": [500 + i * 10 for i in range(20)],
}
)
result = analyzer._detect_trend_stage({"1h": df_1h, "4h": df_4h})
assert result["stage"] in {"early", "middle"}
assert any("接受" in signal or "先动" in signal for signal in result["signals"])
def test_build_analysis_prompt_includes_structured_market_and_derivatives_blocks():
analyzer = make_analyzer()
prompt = analyzer._build_analysis_prompt(

View File

@ -148,3 +148,20 @@ def test_paper_dynamic_position_uses_equity_pct_instead_of_margin_multiple():
assert margin == pytest.approx(2400.0)
assert position_value == pytest.approx(24000.0)
def test_setup_profile_can_cap_margin_budget_more_conservatively():
module = load_position_sizing_module()
margin, position_value, _ = module.calculate_margin_and_position_value(
balance=20000,
available_margin=19000,
current_total_leverage=0,
max_total_leverage=10,
order_leverage=10,
target_margin_pct=0.12 * 0.55,
max_margin_pct=0.08,
)
assert margin == pytest.approx(1320.0)
assert position_value == pytest.approx(13200.0)

View File

@ -0,0 +1,127 @@
import importlib.util
import sys
import types
from pathlib import Path
def load_class(module_rel_path: str, class_name: str):
base = Path(__file__).resolve().parents[1] / "app" / "crypto_agent"
target = base / module_rel_path
if "app" not in sys.modules:
app_pkg = types.ModuleType("app")
app_pkg.__path__ = [str(base.parents[1] / "app")]
sys.modules["app"] = app_pkg
if "app.crypto_agent" not in sys.modules:
crypto_pkg = types.ModuleType("app.crypto_agent")
crypto_pkg.__path__ = [str(base)]
sys.modules["app.crypto_agent"] = crypto_pkg
module_name = f"app.crypto_agent.{target.stem}_test"
spec = importlib.util.spec_from_file_location(module_name, target)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return getattr(module, class_name)
def test_regime_engine_blocks_middle_of_range_in_ranging_market():
RegimeEngine = load_class("regime_engine.py", "RegimeEngine")
engine = RegimeEngine()
profile = engine.classify(
range_metrics={"regime": "ranging", "bb_squeeze": False},
market_location={"location_tag": "middle_of_range", "relative_to_range": "middle_of_range"},
trend_direction="neutral",
trend_strength="weak",
derivatives_state={"crowding_regime": "low", "crowding_bias": "neutral"},
reversal_detection={},
trend_stage={},
)
assert profile["regime_key"] == "range"
assert profile["tradability"] == "selective"
assert "range_reversal" in profile["allowed_setups"]
assert any("区间边界" in reason or "位置不佳" in reason for reason in profile["no_trade_reasons"])
def test_setup_policy_allows_only_short_term_range_reversal_in_range_market():
SetupPolicy = load_class("setup_policy.py", "SetupPolicy")
policy = SetupPolicy()
profile = {
"tradability": "selective",
"allowed_lanes": ["short_term"],
"allowed_setups": ["range_reversal"],
}
signals = [
{
"timeframe": "medium_term",
"type": "medium_term",
"action": "sell",
"entry_type": "limit",
"regime": "ranging",
"market_location": {"location_tag": "near_range_resistance"},
},
{
"timeframe": "short_term",
"type": "short_term",
"action": "sell",
"entry_type": "limit",
"regime": "ranging",
"market_location": {"location_tag": "near_range_resistance"},
},
]
filtered, reasons = policy.filter_signals(signals, profile)
assert len(filtered) == 1
assert filtered[0]["timeframe"] == "short_term"
assert filtered[0]["setup_type"] == "range_reversal"
assert reasons
def test_setup_policy_uses_volume_price_context_for_breakout_and_pullback_setups():
SetupPolicy = load_class("setup_policy.py", "SetupPolicy")
policy = SetupPolicy()
profile = {
"tradability": "selective",
"allowed_lanes": ["short_term", "medium_term"],
"allowed_setups": ["breakout_confirmation", "trend_continuation_pullback"],
}
signals = [
{
"timeframe": "short_term",
"type": "short_term",
"action": "buy",
"entry_type": "market",
"regime": "transitional",
"market_location": {"location_tag": "near_long_zone"},
"volume_price_context": {
"breakout_quality": "acceptance_breakout_up",
"volume_price_state": "bullish_acceptance",
},
},
{
"timeframe": "medium_term",
"type": "medium_term",
"action": "buy",
"entry_type": "limit",
"regime": "weak_trend",
"market_location": {"location_tag": "near_long_zone"},
"volume_price_context": {
"pullback_quality": "healthy_pullback",
},
},
]
filtered, reasons = policy.filter_signals(signals, profile)
assert len(filtered) == 2
assert filtered[0]["setup_type"] == "breakout_confirmation"
assert filtered[1]["setup_type"] == "trend_continuation_pullback"
assert filtered[0]["entry_basis"] == "breakout_acceptance_follow_through"
assert "setup=breakout_confirmation" in filtered[0]["setup_basis"]
assert not reasons

View File

@ -0,0 +1,116 @@
import importlib.util
import sys
import types
from pathlib import Path
def load_system_module():
system_path = Path(__file__).resolve().parents[1] / "app" / "api" / "system.py"
if "app" not in sys.modules:
app_pkg = types.ModuleType("app")
app_pkg.__path__ = [str(system_path.parents[2] / "app")]
sys.modules["app"] = app_pkg
for pkg_name, pkg_path in [
("app.api", system_path.parent),
("app.utils", system_path.parents[1] / "utils"),
("app.crypto_agent", system_path.parents[1] / "crypto_agent"),
("app.services", system_path.parents[1] / "services"),
]:
if pkg_name not in sys.modules:
pkg = types.ModuleType(pkg_name)
pkg.__path__ = [str(pkg_path)]
sys.modules[pkg_name] = pkg
fastapi_module = types.ModuleType("fastapi")
class DummyRouter:
def get(self, *args, **kwargs):
def decorator(fn):
return fn
return decorator
class DummyHTTPException(Exception):
pass
fastapi_module.APIRouter = lambda: DummyRouter()
fastapi_module.HTTPException = DummyHTTPException
sys.modules["fastapi"] = fastapi_module
logger_module = types.ModuleType("app.utils.logger")
logger_module.logger = types.SimpleNamespace(error=lambda *a, **k: None, warning=lambda *a, **k: None)
sys.modules["app.utils.logger"] = logger_module
system_status_module = types.ModuleType("app.utils.system_status")
system_status_module.get_system_monitor = lambda: None
sys.modules["app.utils.system_status"] = system_status_module
crypto_agent_module = types.ModuleType("app.crypto_agent.crypto_agent")
crypto_agent_module.get_crypto_agent = lambda: None
sys.modules["app.crypto_agent.crypto_agent"] = crypto_agent_module
signal_db_module = types.ModuleType("app.services.signal_database_service")
signal_db_module.get_signal_db_service = lambda: None
sys.modules["app.services.signal_database_service"] = signal_db_module
paper_module = types.ModuleType("app.services.paper_trading_service")
paper_module.get_paper_trading_service = lambda: None
sys.modules["app.services.paper_trading_service"] = paper_module
bitget_module = types.ModuleType("app.services.bitget_live_trading_service")
bitget_module.get_all_bitget_live_services = lambda: {}
bitget_module.get_bitget_live_service = lambda: None
sys.modules["app.services.bitget_live_trading_service"] = bitget_module
module_name = "app.api.system_console_test"
spec = importlib.util.spec_from_file_location(module_name, system_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def test_normalize_platform_position_preserves_setup_fields():
module = load_system_module()
item = module._normalize_platform_position(
"paper",
{
"symbol": "BTCUSDT",
"side": "buy",
"entry_price": 100.0,
"mark_price": 101.0,
"size": 1.2,
"take_profit": 106.0,
"stop_loss": 98.0,
"setup_type": "trend_continuation_pullback",
"setup_basis": "setup=trend_continuation_pullback | pullback_quality=healthy_pullback",
"entry_basis": "pullback_into_trade_zone",
},
)
assert item["setup_type"] == "trend_continuation_pullback"
assert "healthy_pullback" in item["setup_basis"]
assert item["entry_basis"] == "pullback_into_trade_zone"
def test_normalize_platform_order_preserves_setup_fields():
module = load_system_module()
item = module._normalize_platform_order(
"bitget",
{
"symbol": "ETHUSDT",
"side": "sell",
"price": 2000.0,
"size": 3,
"status": "pending",
"entry_type": "limit",
"setup_type": "range_reversal",
"setup_basis": "setup=range_reversal | location=near_range_resistance",
"entry_basis": "reversal_from_near_range_resistance",
},
)
assert item["setup_type"] == "range_reversal"
assert "near_range_resistance" in item["setup_basis"]
assert item["entry_basis"] == "reversal_from_near_range_resistance"

View File

@ -2705,6 +2705,22 @@
<div class="stat-chip decision-chip ${paper.tone}"><span class="label">模拟盘</span><span class="value">${paper.label}</span></div>
<div class="stat-chip decision-chip ${bitget.tone}"><span class="label">Bitget</span><span class="value">${bitget.label}</span></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;">
模拟盘: ${paper.detail}
</div>
@ -2794,8 +2810,15 @@
${event.decision ? `<span class="event-inline-badge">${event.decision}</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.setup_type ? `<span class="event-inline-badge">${event.setup_type}</span>` : ''}
</div>
<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 ? `
<div class="blocked-platforms" style="margin-top: 10px;">
${event.blocked_platforms.map((item) => `
@ -2860,6 +2883,7 @@
<th>方向</th>
<th>入场 / 现价</th>
<th>仓位 / 杠杆</th>
<th>Setup</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 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>${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 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>
@ -2915,6 +2940,7 @@
<th>价格</th>
<th>数量 / 杠杆</th>
<th>信号</th>
<th>Setup</th>
<th>时间</th>
</tr>
</thead>
@ -2929,6 +2955,7 @@
<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">${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>
</tr>
`).join('')}