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,7 +1402,11 @@ class CryptoAgent:
|
|||||||
detail="完成分析,无交易信号",
|
detail="完成分析,无交易信号",
|
||||||
extra={"trade_signals": 0, "valid_signals": 0},
|
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
|
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'])
|
||||||
File diff suppressed because it is too large
Load Diff
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