update
This commit is contained in:
parent
f31322a2a5
commit
6a067fd39e
@ -110,7 +110,7 @@ async def get_order(order_id: str):
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/close")
|
||||
async def close_order(order_id: str, request: CloseOrderRequest):
|
||||
async def close_order(order_id: str, request: Optional[CloseOrderRequest] = None):
|
||||
"""
|
||||
手动平仓
|
||||
|
||||
@ -119,7 +119,17 @@ async def close_order(order_id: str, request: CloseOrderRequest):
|
||||
"""
|
||||
try:
|
||||
service = get_paper_trading_service()
|
||||
result = service.close_order_manual(order_id, request.exit_price)
|
||||
order = service.get_order_by_id(order_id)
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在或已平仓")
|
||||
|
||||
exit_price = request.exit_price if request and request.exit_price > 0 else 0
|
||||
if exit_price <= 0:
|
||||
exit_price = service._get_current_price(order.get('symbol', ''))
|
||||
if exit_price <= 0:
|
||||
exit_price = order.get('filled_price') or order.get('entry_price') or 0
|
||||
|
||||
result = service.close_order_manual(order_id, float(exit_price))
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="订单不存在或已平仓")
|
||||
@ -136,6 +146,28 @@ async def close_order(order_id: str, request: CloseOrderRequest):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/cancel")
|
||||
async def cancel_order(order_id: str):
|
||||
"""撤销挂单"""
|
||||
try:
|
||||
service = get_paper_trading_service()
|
||||
result = service.cancel_order(order_id)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("message", "撤单失败"))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": result.get("message", "撤单成功"),
|
||||
"result": result
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"撤单失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/orders/{order_id}")
|
||||
async def delete_order(
|
||||
order_id: str,
|
||||
@ -409,7 +441,6 @@ async def reset_paper_trading():
|
||||
logger.error(f"重置交易数据失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/recalculate-statistics")
|
||||
async def recalculate_statistics():
|
||||
"""
|
||||
|
||||
@ -16,7 +16,6 @@ from app.services.dingtalk_service import get_dingtalk_service
|
||||
from app.services.paper_trading_service import get_paper_trading_service
|
||||
from app.services.signal_database_service import get_signal_db_service
|
||||
from app.crypto_agent.market_signal_analyzer import MarketSignalAnalyzer
|
||||
from app.crypto_agent.trading_decision_maker import TradingDecisionMaker
|
||||
from app.utils.system_status import get_system_monitor, AgentStatus
|
||||
|
||||
|
||||
@ -82,6 +81,18 @@ class CryptoAgent:
|
||||
'long_term': 1.0,
|
||||
}
|
||||
|
||||
SIGNAL_MIN_STOP_LOSS_PCT = {
|
||||
'short_term': 0.6,
|
||||
'medium_term': 1.0,
|
||||
'long_term': 1.2,
|
||||
}
|
||||
|
||||
SIGNAL_MIN_TAKE_PROFIT_PCT = {
|
||||
'short_term': 1.0,
|
||||
'medium_term': 2.0,
|
||||
'long_term': 2.5,
|
||||
}
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""单例模式 - 确保只有一个实例"""
|
||||
if cls._instance is None:
|
||||
@ -102,21 +113,14 @@ class CryptoAgent:
|
||||
self.telegram = get_telegram_service()
|
||||
self.dingtalk = get_dingtalk_service() # 添加钉钉服务
|
||||
|
||||
# 新架构:市场信号分析器 + 交易决策器
|
||||
# 信号层:只负责市场分析
|
||||
self.market_analyzer = MarketSignalAnalyzer()
|
||||
self.decision_maker = None # 延迟初始化,需要 paper_trading 的杠杆配置
|
||||
|
||||
self.signal_db = get_signal_db_service() # 信号数据库服务
|
||||
|
||||
# 模拟交易服务(始终启用)
|
||||
self.paper_trading = get_paper_trading_service()
|
||||
|
||||
# 初始化决策器(需要杠杆配置)
|
||||
self.decision_maker = TradingDecisionMaker(
|
||||
leverage=self.paper_trading.leverage,
|
||||
max_total_leverage=self.paper_trading.max_total_leverage
|
||||
)
|
||||
|
||||
# Hyperliquid 实盘服务(可选)
|
||||
from app.services.hyperliquid_trading_service import get_hyperliquid_service
|
||||
self.hyperliquid = get_hyperliquid_service()
|
||||
@ -612,12 +616,12 @@ class CryptoAgent:
|
||||
|
||||
async def analyze_symbol(self, symbol: str):
|
||||
"""
|
||||
分析单个交易对(新架构:市场分析 + 交易决策分离)
|
||||
分析单个交易对(信号分析 + 平台执行规则)
|
||||
|
||||
新架构流程:
|
||||
当前流程:
|
||||
1. 市场信号分析器分析市场(不包含仓位信息)
|
||||
2. 交易决策器根据信号+仓位+账户状态做决策
|
||||
3. 执行交易决策
|
||||
2. 各平台按自身规则筛选并处理信号
|
||||
3. 执行交易动作
|
||||
|
||||
Args:
|
||||
symbol: 交易对,如 'BTCUSDT'
|
||||
@ -696,7 +700,7 @@ class CryptoAgent:
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 发送市场信号通知(独立于交易决策)
|
||||
# 发送市场信号通知
|
||||
# ============================================================
|
||||
await self._send_market_signal_notification(market_signal, current_price)
|
||||
|
||||
@ -725,10 +729,6 @@ class CryptoAgent:
|
||||
else:
|
||||
logger.info(" 无可执行信号")
|
||||
paper_decision = {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||||
# 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策)
|
||||
# await self._send_trading_decision_notification(
|
||||
# paper_decision, market_signal, current_price, prefix="[模拟盘]"
|
||||
# )
|
||||
else:
|
||||
paper_decision = {"action": "IGNORE", "reason": "未启用"}
|
||||
logger.info(f"⏸️ 模拟盘交易未启用")
|
||||
@ -753,10 +753,6 @@ class CryptoAgent:
|
||||
else:
|
||||
logger.info(" 无可执行信号")
|
||||
hl_decision = {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||||
# 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策)
|
||||
# await self._send_trading_decision_notification(
|
||||
# hl_decision, market_signal, current_price, prefix="[Hyperliquid]"
|
||||
# )
|
||||
else:
|
||||
hl_decision = {"action": "IGNORE", "reason": "未启用"}
|
||||
logger.info(f"⏸️ Hyperliquid 实盘交易未启用")
|
||||
@ -781,16 +777,12 @@ class CryptoAgent:
|
||||
else:
|
||||
logger.info(" 无可执行信号")
|
||||
bg_decision = {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||||
# 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策)
|
||||
# await self._send_trading_decision_notification(
|
||||
# bg_decision, market_signal, current_price, prefix="[Bitget]"
|
||||
# )
|
||||
else:
|
||||
bg_decision = {"action": "IGNORE", "reason": "未启用"}
|
||||
logger.info(f"⏸️ Bitget 实盘交易未启用")
|
||||
|
||||
# ============================================================
|
||||
# 第三阶段:执行交易决策(各平台独立)
|
||||
# 第三阶段:执行交易动作(各平台独立)
|
||||
# ============================================================
|
||||
await self._execute_decisions(paper_decision, hl_decision, bg_decision, market_signal, current_price)
|
||||
|
||||
@ -870,41 +862,6 @@ class CryptoAgent:
|
||||
logger.info(f" 信心度: {confidence}%")
|
||||
logger.info(f" 理由: {sig.get('reasoning', 'N/A')}")
|
||||
|
||||
def _log_trading_decision(self, decision: Dict[str, Any]):
|
||||
"""输出交易决策结果"""
|
||||
decision_type = decision.get('decision', 'HOLD')
|
||||
decision_map = {
|
||||
'OPEN': '🟢 开仓',
|
||||
'CLOSE': '🔴 平仓',
|
||||
'ADD': '➕ 加仓',
|
||||
'REDUCE': '➖ 减仓',
|
||||
'HOLD': '⏸️ 观望'
|
||||
}
|
||||
|
||||
logger.info(f" 决策: {decision_map.get(decision_type, decision_type)}")
|
||||
logger.info(f" 动作: {decision.get('action', 'N/A')}")
|
||||
logger.info(f" 仓位: {decision.get('position_size', 'N/A')}")
|
||||
|
||||
# quantity 是保证金,显示持仓价值 = 保证金 × 杠杆
|
||||
quantity = decision.get('quantity', 0)
|
||||
if isinstance(quantity, (int, float)) and quantity > 0:
|
||||
leverage = self.paper_trading.leverage # 使用实际的杠杆配置
|
||||
position_value = quantity * leverage
|
||||
logger.info(f" 持仓价值: ${position_value:,.2f} (保证金 ${quantity:.2f})")
|
||||
else:
|
||||
logger.info(f" 数量: ${decision.get('quantity', 'N/A')}")
|
||||
|
||||
if decision.get('stop_loss'):
|
||||
logger.info(f" 止损: ${decision.get('stop_loss')}")
|
||||
if decision.get('take_profit'):
|
||||
logger.info(f" 止盈: ${decision.get('take_profit')}")
|
||||
|
||||
logger.info(f" 理由: {decision.get('reasoning', 'N/A')}")
|
||||
|
||||
risk = decision.get('risk_analysis', '')
|
||||
if risk:
|
||||
logger.info(f" 风险: {risk}")
|
||||
|
||||
def _get_paper_trading_state(self) -> tuple:
|
||||
"""
|
||||
获取模拟盘交易状态(持仓和账户)
|
||||
@ -1173,6 +1130,9 @@ class CryptoAgent:
|
||||
if decision_type == 'HOLD':
|
||||
reasoning = decision.get('reasoning', decision.get('reason', '观望'))
|
||||
logger.info(f"\n📊 交易决策: {reasoning}")
|
||||
await self._notify_signal_not_executed(
|
||||
market_signal, decision, current_price, reason=f"[模拟盘] {reasoning}", prefix="[模拟盘]"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"\n📊 【执行交易】")
|
||||
@ -1478,144 +1438,6 @@ class CryptoAgent:
|
||||
import traceback
|
||||
logger.debug(traceback.format_exc())
|
||||
|
||||
async def _send_trading_decision_notification(self, decision: Dict[str, Any],
|
||||
market_signal: Dict[str, Any],
|
||||
current_price: float,
|
||||
prefix: str = ""):
|
||||
"""发送交易决策通知(第二阶段)"""
|
||||
try:
|
||||
decision_type = decision.get('decision', 'HOLD')
|
||||
symbol = market_signal.get('symbol')
|
||||
|
||||
# 账户类型标识
|
||||
account_type = f"{prefix} 📊 交易" if prefix else "📊 交易"
|
||||
|
||||
# 决策类型映射
|
||||
decision_map = {
|
||||
'OPEN': '开仓',
|
||||
'CLOSE': '平仓',
|
||||
'ADD': '加仓',
|
||||
'REDUCE': '减仓',
|
||||
'CANCEL_PENDING': '取消挂单',
|
||||
'HOLD': '观望'
|
||||
}
|
||||
|
||||
decision_text = decision_map.get(decision_type, decision_type)
|
||||
|
||||
# 根据决策类型设置颜色
|
||||
color_map = {
|
||||
'OPEN': 'green',
|
||||
'ADD': 'green',
|
||||
'CLOSE': 'orange',
|
||||
'REDUCE': 'orange',
|
||||
'CANCEL_PENDING': 'red',
|
||||
'HOLD': 'gray'
|
||||
}
|
||||
color = color_map.get(decision_type, 'blue')
|
||||
|
||||
# 构建标题 - 添加 [决策] 前缀区分
|
||||
title = f"[决策] {account_type} {symbol} 交易决策: {decision_text}"
|
||||
|
||||
# 获取最佳信号用于显示
|
||||
best_signal = self._get_signal_for_decision(market_signal, decision)
|
||||
signal_confidence = best_signal.get('confidence', 0) if best_signal else 0
|
||||
signal_action = best_signal.get('action', '') if best_signal else ''
|
||||
signal_timeframe = best_signal.get('timeframe', best_signal.get('type', 'unknown')) if best_signal else 'unknown'
|
||||
timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'}
|
||||
timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe)
|
||||
|
||||
# 方向图标
|
||||
if 'buy' in signal_action.lower() or 'long' in signal_action.lower():
|
||||
action_icon = '🟢'
|
||||
action_text = '做多'
|
||||
elif 'sell' in signal_action.lower() or 'short' in signal_action.lower():
|
||||
action_icon = '🔴'
|
||||
action_text = '做空'
|
||||
else:
|
||||
action_icon = '➖'
|
||||
action_text = '观望'
|
||||
|
||||
# 构建内容
|
||||
content_parts = [
|
||||
f"{action_icon} **市场信号**: {action_text} | {timeframe_text} | 信心度: {signal_confidence}%",
|
||||
f"",
|
||||
f"🎯 **交易决策**: {decision_text}",
|
||||
f"",
|
||||
]
|
||||
|
||||
# 添加决策详情
|
||||
if decision_type != 'HOLD':
|
||||
reasoning = decision.get('reasoning', '')
|
||||
risk_analysis = decision.get('risk_analysis', '')
|
||||
position_size = decision.get('position_size', 'N/A')
|
||||
|
||||
# 仓位图标
|
||||
position_map = {'heavy': '🔥 重仓', 'medium': '📊 中仓', 'light': '🌱 轻仓', 'micro': '🌱 微仓'}
|
||||
position_display = position_map.get(position_size, position_size)
|
||||
|
||||
# HTML转义,避免特殊字符破坏HTML格式
|
||||
import html
|
||||
escaped_reasoning = html.escape(reasoning) if reasoning else ''
|
||||
escaped_risk = html.escape(risk_analysis) if risk_analysis else ''
|
||||
|
||||
content_parts.extend([
|
||||
f"📊 **仓位**: {position_display}",
|
||||
f"💭 **决策理由**: {escaped_reasoning}",
|
||||
])
|
||||
|
||||
if escaped_risk:
|
||||
content_parts.append(f"⚠️ **风险**: {escaped_risk}")
|
||||
|
||||
# 添加价格信息(如果有)
|
||||
quantity = decision.get('quantity', 0)
|
||||
if isinstance(quantity, (int, float)) and quantity > 0:
|
||||
leverage = self.paper_trading.leverage # 使用实际的杠杆配置
|
||||
position_value = quantity * leverage
|
||||
content_parts.append(f"💰 **持仓价值**: ${position_value:,.2f} (保证金 ${quantity:.2f})")
|
||||
|
||||
stop_loss = decision.get('stop_loss')
|
||||
take_profit = decision.get('take_profit')
|
||||
if stop_loss:
|
||||
content_parts.append(f"🛑 **止损**: ${stop_loss}")
|
||||
if take_profit:
|
||||
content_parts.append(f"🎯 **止盈**: ${take_profit}")
|
||||
|
||||
# 取消挂单时显示要取消的订单
|
||||
if decision_type == 'CANCEL_PENDING':
|
||||
orders_to_cancel = decision.get('orders_to_cancel', [])
|
||||
if orders_to_cancel:
|
||||
content_parts.append(f"🚫 **取消订单**: {len(orders_to_cancel)} 个")
|
||||
for order_id in orders_to_cancel[:3]: # 最多显示3个
|
||||
content_parts.append(f" - {order_id}")
|
||||
if len(orders_to_cancel) > 3:
|
||||
content_parts.append(f" - ... 还有 {len(orders_to_cancel) - 3} 个")
|
||||
else:
|
||||
# HOLD 决策
|
||||
reasoning = decision.get('reasoning', '综合评估后选择观望')
|
||||
content_parts.append(f"💭 **理由**: {reasoning}")
|
||||
|
||||
content_parts.append("")
|
||||
content_parts.append(f"⏰ 当前价格: ${current_price:,.2f}")
|
||||
|
||||
content = "\n".join(content_parts)
|
||||
|
||||
# 发送通知 - [决策] 发送到 paper_trading webhook(trading)
|
||||
if self.settings.feishu_enabled:
|
||||
await self.feishu_paper.send_card(title, content, color)
|
||||
if self.settings.telegram_enabled:
|
||||
# Telegram 使用文本格式
|
||||
message = f"{title}\n\n{content}"
|
||||
await self.telegram.send_message(message)
|
||||
if self.settings.dingtalk_enabled:
|
||||
await self.dingtalk.send_action_card(title, content)
|
||||
|
||||
logger.info(f" 📤 已发送交易决策通知: {decision_text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"发送交易决策通知失败: {e}")
|
||||
import traceback
|
||||
logger.debug(traceback.format_exc())
|
||||
|
||||
async def _send_signal_notification(self, market_signal: Dict[str, Any],
|
||||
decision: Dict[str, Any], current_price: float,
|
||||
prefix: str = "", hl_order_status: str = None):
|
||||
@ -2579,6 +2401,16 @@ class CryptoAgent:
|
||||
reward = entry - tp
|
||||
|
||||
if risk > 0:
|
||||
stop_distance_pct = risk / entry * 100
|
||||
take_distance_pct = reward / entry * 100
|
||||
min_stop_pct = self.SIGNAL_MIN_STOP_LOSS_PCT.get(signal_type, 0.6)
|
||||
min_take_pct = self.SIGNAL_MIN_TAKE_PROFIT_PCT.get(signal_type, 1.0)
|
||||
|
||||
if stop_distance_pct < min_stop_pct:
|
||||
return False, f"{signal_type} 止损距离 {stop_distance_pct:.2f}% < {min_stop_pct:.1f}%,不执行"
|
||||
if take_distance_pct < min_take_pct:
|
||||
return False, f"{signal_type} 止盈距离 {take_distance_pct:.2f}% < {min_take_pct:.1f}%,不执行"
|
||||
|
||||
risk_reward_ratio = reward / risk
|
||||
if risk_reward_ratio < min_rr:
|
||||
return False, f"{signal_type} 盈亏比 {risk_reward_ratio:.2f} < {min_rr:.1f},不执行"
|
||||
@ -2724,6 +2556,9 @@ class CryptoAgent:
|
||||
if decision_type == 'HOLD':
|
||||
reasoning = decision.get('reasoning', decision.get('reason', '观望'))
|
||||
logger.info(f" Bitget 决策: {reasoning}")
|
||||
await self._notify_signal_not_executed(
|
||||
market_signal, decision, current_price, reason=f"[Bitget] {reasoning}", prefix="[Bitget]"
|
||||
)
|
||||
return
|
||||
|
||||
# 使用执行器
|
||||
@ -3063,6 +2898,9 @@ class CryptoAgent:
|
||||
if decision_type == 'HOLD':
|
||||
reasoning = decision.get('reasoning', decision.get('reason', '观望'))
|
||||
logger.info(f" Hyperliquid 决策: {reasoning}")
|
||||
await self._notify_signal_not_executed(
|
||||
market_signal, decision, current_price, reason=f"[Hyperliquid] {reasoning}", prefix="[Hyperliquid]"
|
||||
)
|
||||
return
|
||||
|
||||
# 使用执行器
|
||||
@ -3557,12 +3395,14 @@ class CryptoAgent:
|
||||
market_signal: Dict[str, Any],
|
||||
decision: Dict[str, Any],
|
||||
current_price: float,
|
||||
reason: str = ""
|
||||
reason: str = "",
|
||||
prefix: str = ""
|
||||
):
|
||||
"""发送有信号但未执行交易的通知"""
|
||||
try:
|
||||
symbol = market_signal.get('symbol')
|
||||
account_type = "📊"
|
||||
title_prefix = f"{prefix} " if prefix else ""
|
||||
|
||||
signal = self._get_signal_for_decision(market_signal, decision)
|
||||
if not signal:
|
||||
@ -3600,7 +3440,7 @@ class CryptoAgent:
|
||||
action_text = '观望'
|
||||
|
||||
# 构建标题
|
||||
title = f"{account_type} {symbol} 信号未执行"
|
||||
title = f"{title_prefix}{account_type} {symbol} 信号未执行"
|
||||
|
||||
# 构建内容
|
||||
content_parts = [
|
||||
@ -3631,26 +3471,75 @@ class CryptoAgent:
|
||||
logger.warning(f"发送信号未执行通知失败: {e}")
|
||||
|
||||
async def analyze_once(self, symbol: str) -> Dict[str, Any]:
|
||||
"""单次分析(用于测试或手动触发)"""
|
||||
"""单次分析并返回市场信号与平台执行预览"""
|
||||
data = self.exchange.get_multi_timeframe_data(symbol)
|
||||
|
||||
if not self._validate_data(data):
|
||||
return {'error': '数据不完整'}
|
||||
|
||||
# 使用新架构:市场分析 + 交易决策
|
||||
current_price = float(data['5m'].iloc[-1]['close'])
|
||||
market_signal = await self.market_analyzer.analyze(
|
||||
symbol, data,
|
||||
symbols=self.symbols
|
||||
)
|
||||
|
||||
positions, account = self._get_trading_state()
|
||||
decision = await self.decision_maker.make_decision(
|
||||
market_signal, positions, account
|
||||
)
|
||||
signals = market_signal.get('signals', [])
|
||||
threshold = self.settings.crypto_llm_threshold * 100
|
||||
valid_signals = [
|
||||
signal for signal in signals
|
||||
if signal.get('action') in {'buy', 'sell'} and signal.get('confidence', 0) >= threshold
|
||||
]
|
||||
|
||||
execution_preview: Dict[str, Any] = {}
|
||||
|
||||
if self.settings.paper_trading_enabled:
|
||||
paper_positions, paper_account, paper_pending = self._get_paper_trading_state()
|
||||
paper_signal = self._select_signal_for_platform(valid_signals, 'PaperTrading')
|
||||
execution_preview['PaperTrading'] = self._normalize_execution_decision(
|
||||
self.execute_signal_with_rules(
|
||||
self._build_execution_signal(symbol, paper_signal, current_price),
|
||||
'PaperTrading',
|
||||
paper_account,
|
||||
paper_positions,
|
||||
paper_pending,
|
||||
),
|
||||
paper_positions,
|
||||
paper_pending,
|
||||
) if paper_signal else {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||||
|
||||
if self.hyperliquid:
|
||||
hl_positions, hl_account, hl_pending = self._get_hyperliquid_trading_state()
|
||||
hl_signal = self._select_signal_for_platform(valid_signals, 'Hyperliquid')
|
||||
execution_preview['Hyperliquid'] = self._normalize_execution_decision(
|
||||
self.execute_signal_with_rules(
|
||||
self._build_execution_signal(symbol, hl_signal, current_price),
|
||||
'Hyperliquid',
|
||||
hl_account,
|
||||
hl_positions,
|
||||
hl_pending,
|
||||
),
|
||||
hl_positions,
|
||||
hl_pending,
|
||||
) if hl_signal else {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||||
|
||||
if self.bitget:
|
||||
bg_positions, bg_account, bg_pending = self._get_bitget_trading_state()
|
||||
bg_signal = self._select_signal_for_platform(valid_signals, 'Bitget')
|
||||
execution_preview['Bitget'] = self._normalize_execution_decision(
|
||||
self.execute_signal_with_rules(
|
||||
self._build_execution_signal(symbol, bg_signal, current_price),
|
||||
'Bitget',
|
||||
bg_account,
|
||||
bg_positions,
|
||||
bg_pending,
|
||||
),
|
||||
bg_positions,
|
||||
bg_pending,
|
||||
) if bg_signal else {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||||
|
||||
return {
|
||||
'market_signal': market_signal,
|
||||
'trading_decision': decision
|
||||
'execution_preview': execution_preview,
|
||||
}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -2211,7 +2211,17 @@ class PaperTradingService:
|
||||
def _get_current_price(self, symbol: str) -> float:
|
||||
"""获取交易对当前价格"""
|
||||
try:
|
||||
from app.services.price_monitor_service import get_price_monitor_service
|
||||
from app.services.bitget_service import bitget_service
|
||||
|
||||
cached_price = get_price_monitor_service().get_latest_price(symbol)
|
||||
if cached_price:
|
||||
return float(cached_price)
|
||||
|
||||
price = bitget_service.get_current_price(symbol)
|
||||
if price:
|
||||
return float(price)
|
||||
|
||||
ticker = bitget_service.get_ticker(symbol)
|
||||
if ticker and 'lastPrice' in ticker:
|
||||
return float(ticker['lastPrice'])
|
||||
|
||||
177
backend/tests/test_market_signal_analyzer_lane_rules.py
Normal file
177
backend/tests/test_market_signal_analyzer_lane_rules.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""
|
||||
MarketSignalAnalyzer 回归测试
|
||||
|
||||
覆盖重点:
|
||||
- 关键位聚合后保留优先级,同时数值数组继续按价格顺序返回
|
||||
- lane 级别的最小盈亏比和止盈止损距离门槛
|
||||
- Fib 摘要与可交易区输出
|
||||
"""
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def load_market_signal_analyzer_class():
|
||||
analyzer_path = Path(__file__).resolve().parents[1] / "app" / "crypto_agent" / "market_signal_analyzer.py"
|
||||
|
||||
if "app" not in sys.modules:
|
||||
app_pkg = types.ModuleType("app")
|
||||
app_pkg.__path__ = [str(analyzer_path.parents[2] / "app")]
|
||||
sys.modules["app"] = app_pkg
|
||||
|
||||
if "app.services" not in sys.modules:
|
||||
services_pkg = types.ModuleType("app.services")
|
||||
services_pkg.__path__ = [str(analyzer_path.parents[1] / "services")]
|
||||
sys.modules["app.services"] = services_pkg
|
||||
|
||||
if "app.crypto_agent" not in sys.modules:
|
||||
crypto_pkg = types.ModuleType("app.crypto_agent")
|
||||
crypto_pkg.__path__ = [str(analyzer_path.parent)]
|
||||
sys.modules["app.crypto_agent"] = crypto_pkg
|
||||
|
||||
if "app.utils" not in sys.modules:
|
||||
utils_pkg = types.ModuleType("app.utils")
|
||||
utils_pkg.__path__ = [str(analyzer_path.parents[1] / "utils")]
|
||||
sys.modules["app.utils"] = utils_pkg
|
||||
|
||||
logger_module = types.ModuleType("app.utils.logger")
|
||||
logger_module.logger = MagicMock()
|
||||
sys.modules["app.utils.logger"] = logger_module
|
||||
|
||||
llm_module = types.ModuleType("app.services.llm_service")
|
||||
llm_module.llm_service = MagicMock()
|
||||
sys.modules["app.services.llm_service"] = llm_module
|
||||
|
||||
news_module = types.ModuleType("app.services.news_service")
|
||||
news_module.get_news_service = MagicMock(return_value=MagicMock())
|
||||
sys.modules["app.services.news_service"] = news_module
|
||||
|
||||
bitget_module = types.ModuleType("app.services.bitget_service")
|
||||
bitget_module.bitget_service = MagicMock()
|
||||
sys.modules["app.services.bitget_service"] = bitget_module
|
||||
|
||||
module_name = "app.crypto_agent.market_signal_analyzer_test"
|
||||
spec = importlib.util.spec_from_file_location(module_name, analyzer_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module.MarketSignalAnalyzer
|
||||
|
||||
|
||||
def make_analyzer():
|
||||
return load_market_signal_analyzer_class()()
|
||||
|
||||
|
||||
def make_frame(closes, lows, highs, ema20, atr=10.0):
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"close": closes,
|
||||
"low": lows,
|
||||
"high": highs,
|
||||
"ema20": [ema20] * len(closes),
|
||||
"atr": [atr] * len(closes),
|
||||
"volume": [1000 + i * 10 for i in range(len(closes))],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_derive_key_levels_keeps_numeric_arrays_price_sorted_and_builds_trade_zones():
|
||||
analyzer = make_analyzer()
|
||||
data = {
|
||||
"30m": make_frame(
|
||||
closes=[100, 101, 102, 103, 104, 105, 106, 104, 103, 102, 101, 100, 99, 100, 101, 102, 103, 104, 105, 106],
|
||||
lows=[98, 99, 100, 101, 102, 103, 104, 102, 101, 100, 99, 98, 97, 98, 99, 100, 101, 102, 103, 104],
|
||||
highs=[101, 102, 103, 104, 105, 106, 107, 105, 104, 103, 102, 101, 100, 101, 102, 103, 104, 105, 106, 107],
|
||||
ema20=102,
|
||||
),
|
||||
"1h": make_frame(
|
||||
closes=[110 + i for i in range(20)],
|
||||
lows=[108 + i for i in range(20)],
|
||||
highs=[111 + i for i in range(20)],
|
||||
ema20=118,
|
||||
),
|
||||
"4h": make_frame(
|
||||
closes=[120 + i for i in range(12)],
|
||||
lows=[118 + i for i in range(12)],
|
||||
highs=[121 + i for i in range(12)],
|
||||
ema20=126,
|
||||
),
|
||||
}
|
||||
fib_context = {
|
||||
"support_details": [
|
||||
{"price": 124.2, "ratio": 0.618, "distance_pct": 0.4, "kind": "retracement"},
|
||||
{"price": 122.8, "ratio": 0.5, "distance_pct": 1.1, "kind": "retracement"},
|
||||
],
|
||||
"resistance_details": [
|
||||
{"price": 131.4, "ratio": 1.272, "distance_pct": 1.0, "kind": "extension"},
|
||||
{"price": 133.1, "ratio": 1.618, "distance_pct": 2.3, "kind": "extension"},
|
||||
],
|
||||
}
|
||||
|
||||
levels = analyzer._derive_key_levels(
|
||||
data=data,
|
||||
range_zone={"support_level": 123.6, "resistance_level": 130.2},
|
||||
fib_context=fib_context,
|
||||
current_price=128.0,
|
||||
)
|
||||
|
||||
assert levels["support"] == sorted(levels["support"], reverse=True)
|
||||
assert levels["resistance"] == sorted(levels["resistance"])
|
||||
assert levels["best_long_zone"]["center"] == levels["support_priority"][0]["price"]
|
||||
assert levels["best_short_zone"]["center"] == levels["resistance_priority"][0]["price"]
|
||||
assert levels["best_long_zone"]["low"] < levels["best_long_zone"]["high"]
|
||||
assert any("Fib0.618" in item["sources"] for item in levels["support_priority"])
|
||||
|
||||
|
||||
def test_lane_specific_risk_reward_and_distance_thresholds():
|
||||
analyzer = make_analyzer()
|
||||
signal = {
|
||||
"action": "sell",
|
||||
"entry_price": 100.0,
|
||||
"stop_loss": 101.0,
|
||||
"take_profit": 98.0,
|
||||
}
|
||||
|
||||
assert analyzer._meets_min_risk_reward(signal, "short_term") is True
|
||||
assert analyzer._meets_min_risk_reward(signal, "medium_term") is True
|
||||
assert analyzer._meets_min_price_distance(signal, "short_term") is True
|
||||
assert analyzer._meets_min_price_distance(signal, "medium_term") is True
|
||||
|
||||
tighter_signal = {
|
||||
"action": "sell",
|
||||
"entry_price": 100.0,
|
||||
"stop_loss": 101.0,
|
||||
"take_profit": 98.4,
|
||||
}
|
||||
|
||||
assert analyzer._meets_min_risk_reward(tighter_signal, "short_term") is True
|
||||
assert analyzer._meets_min_risk_reward(tighter_signal, "medium_term") is False
|
||||
assert analyzer._meets_min_price_distance(tighter_signal, "short_term") is True
|
||||
assert analyzer._meets_min_price_distance(tighter_signal, "medium_term") is False
|
||||
|
||||
|
||||
def test_fibonacci_context_marks_kind_and_trade_zone():
|
||||
analyzer = make_analyzer()
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"close": [100, 102, 105, 108, 112, 116, 120, 118, 116, 114, 112, 110, 108, 106, 104, 102, 101, 100, 99, 98],
|
||||
"high": [101, 103, 106, 109, 113, 117, 121, 119, 117, 115, 113, 111, 109, 107, 105, 103, 102, 101, 100, 99],
|
||||
"low": [99, 101, 104, 107, 111, 115, 119, 117, 115, 113, 111, 109, 107, 105, 103, 101, 100, 99, 98, 97],
|
||||
"ema20": [108] * 20,
|
||||
"atr": [2.5] * 20,
|
||||
"volume": [1000, 1100, 1300, 1500, 1700, 1900, 2100, 1800, 1600, 1500, 1400, 1300, 1200, 1100, 1050, 1000, 980, 960, 940, 920],
|
||||
}
|
||||
)
|
||||
|
||||
result = analyzer._calculate_fibonacci_levels(df, current_price=104.0, lookback=20)
|
||||
|
||||
assert result is not None
|
||||
assert result["trade_zone"]
|
||||
assert any(level["kind"] in {"retracement", "extension"} for level in result["support_details"] + result["resistance_details"])
|
||||
|
||||
formatted = analyzer._format_fib_levels(result["support_details"] or result["resistance_details"])
|
||||
assert "回撤Fib" in formatted or "扩展Fib" in formatted
|
||||
@ -282,9 +282,6 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" @click="refreshData">刷新</button>
|
||||
<button class="btn btn-danger" @click="resetAccount" v-if="currentTab === 'positions' && openPositions.length > 0">
|
||||
重置账户
|
||||
</button>
|
||||
<!-- 管理员菜单 -->
|
||||
<div class="admin-dropdown" v-if="adminMode">
|
||||
<button class="btn btn-secondary" @click="toggleAdminMenu">管理 ▾</button>
|
||||
@ -393,6 +390,8 @@
|
||||
<th>数量</th>
|
||||
<th>入场价</th>
|
||||
<th>当前价</th>
|
||||
<th>止损</th>
|
||||
<th>止盈</th>
|
||||
<th>杠杆</th>
|
||||
<th>保证金</th>
|
||||
<th>未实现盈亏</th>
|
||||
@ -408,16 +407,18 @@
|
||||
{{ order.side === 'long' ? '做多' : '做空' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ order.quantity ? order.quantity.toFixed(4) : '0.0000' }}</td>
|
||||
<td>{{ order.entry_price ? '$' + order.entry_price.toFixed(2) : '$0.00' }}</td>
|
||||
<td>{{ order.current_price ? '$' + order.current_price.toFixed(2) : '-' }}</td>
|
||||
<td>{{ formatNumber(order.quantity, 4) }}</td>
|
||||
<td>{{ formatCurrency(order.display_entry_price) }}</td>
|
||||
<td>{{ formatCurrency(order.current_price) }}</td>
|
||||
<td>{{ formatCurrency(order.stop_loss) }}</td>
|
||||
<td>{{ formatCurrency(order.take_profit) }}</td>
|
||||
<td>{{ order.leverage || 0 }}x</td>
|
||||
<td>{{ order.margin ? '$' + order.margin.toFixed(2) : '$0.00' }}</td>
|
||||
<td>{{ formatCurrency(order.margin) }}</td>
|
||||
<td :class="order.unrealized_pnl >= 0 ? 'text-success' : 'text-error'">
|
||||
{{ order.unrealized_pnl >= 0 ? '+' : '' }}${{ order.unrealized_pnl ? order.unrealized_pnl.toFixed(2) : '0.00' }}
|
||||
{{ formatSignedCurrency(order.unrealized_pnl) }}
|
||||
</td>
|
||||
<td :class="order.pnl_percent >= 0 ? 'text-success' : 'text-error'">
|
||||
{{ order.pnl_percent >= 0 ? '+' : '' }}{{ order.pnl_percent ? order.pnl_percent.toFixed(2) : '0.00' }}%
|
||||
{{ formatSignedPercent(order.pnl_percent) }}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-small" @click="closeOrder(order)">平仓</button>
|
||||
@ -659,7 +660,22 @@
|
||||
},
|
||||
computed: {
|
||||
openPositions() {
|
||||
return this.orders.filter(o => o.status === 'open');
|
||||
return this.orders
|
||||
.filter(order => order.status === 'open')
|
||||
.map(order => {
|
||||
const displayEntryPrice = this.getDisplayEntryPrice(order);
|
||||
const currentPrice = this.resolveOrderCurrentPrice(order);
|
||||
const pnlPercent = this.calculateOpenOrderPnlPercent(order, currentPrice, displayEntryPrice);
|
||||
const unrealizedPnl = this.calculateOpenOrderPnlAmount(order, pnlPercent);
|
||||
|
||||
return {
|
||||
...order,
|
||||
display_entry_price: displayEntryPrice,
|
||||
current_price: currentPrice || null,
|
||||
pnl_percent: pnlPercent,
|
||||
unrealized_pnl: unrealizedPnl
|
||||
};
|
||||
});
|
||||
},
|
||||
pendingOrders() {
|
||||
return this.orders.filter(o => o.status === 'pending');
|
||||
@ -741,14 +757,8 @@
|
||||
async fetchOrders() {
|
||||
try {
|
||||
const response = await axios.get('/api/trading/orders');
|
||||
console.log('API Response:', response.data);
|
||||
if (response.data.success) {
|
||||
this.orders = response.data.orders || [];
|
||||
console.log('Orders loaded:', this.orders.length);
|
||||
console.log('Orders data:', this.orders);
|
||||
console.log('Open positions:', this.orders.filter(o => o.status === 'open'));
|
||||
console.log('Pending orders:', this.orders.filter(o => o.status === 'pending'));
|
||||
console.log('Order history:', this.orders.filter(o => o.status === 'closed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单失败:', error);
|
||||
@ -759,7 +769,10 @@
|
||||
if (!confirm('确定要平仓吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/trading/orders/${order.order_id}/close`);
|
||||
const exitPrice = this.resolveOrderCurrentPrice(order) || this.getDisplayEntryPrice(order);
|
||||
const response = await axios.post(`/api/trading/orders/${order.order_id}/close`, {
|
||||
exit_price: exitPrice
|
||||
});
|
||||
if (response.data.success) {
|
||||
await this.refreshData();
|
||||
alert('平仓成功');
|
||||
@ -806,23 +819,6 @@
|
||||
}
|
||||
},
|
||||
|
||||
async resetAccount() {
|
||||
if (!confirm('确定要重置账户吗?这将清除所有持仓和订单!')) return;
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/trading/account/reset');
|
||||
if (response.data.success) {
|
||||
await this.refreshData();
|
||||
alert('账户重置成功');
|
||||
} else {
|
||||
alert('重置失败: ' + (response.data.message || '未知错误'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置账户失败:', error);
|
||||
alert('重置失败: ' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
},
|
||||
|
||||
async sendReport() {
|
||||
this.sendingReport = true;
|
||||
try {
|
||||
@ -851,6 +847,73 @@
|
||||
});
|
||||
},
|
||||
|
||||
formatCurrency(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) return '-';
|
||||
return `$${number.toFixed(2)}`;
|
||||
},
|
||||
|
||||
formatSignedCurrency(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return '$0.00';
|
||||
return `${number >= 0 ? '+' : '-'}$${Math.abs(number).toFixed(2)}`;
|
||||
},
|
||||
|
||||
formatSignedPercent(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return '0.00%';
|
||||
return `${number >= 0 ? '+' : '-'}${Math.abs(number).toFixed(2)}%`;
|
||||
},
|
||||
|
||||
formatNumber(value, digits = 2) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return (0).toFixed(digits);
|
||||
return number.toFixed(digits);
|
||||
},
|
||||
|
||||
getDisplayEntryPrice(order) {
|
||||
const price = Number(order.filled_price || order.entry_price || 0);
|
||||
return Number.isFinite(price) ? price : 0;
|
||||
},
|
||||
|
||||
resolveOrderCurrentPrice(order) {
|
||||
const latest = Number(this.latestPrices?.[order.symbol]);
|
||||
if (Number.isFinite(latest) && latest > 0) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
const current = Number(order.current_price);
|
||||
if (Number.isFinite(current) && current > 0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return 0;
|
||||
},
|
||||
|
||||
calculateOpenOrderPnlPercent(order, currentPrice, entryPrice) {
|
||||
if (!currentPrice || !entryPrice) {
|
||||
return Number(order.pnl_percent || 0);
|
||||
}
|
||||
|
||||
if (order.side === 'long') {
|
||||
return ((currentPrice - entryPrice) / entryPrice) * 100;
|
||||
}
|
||||
|
||||
if (order.side === 'short') {
|
||||
return ((entryPrice - currentPrice) / entryPrice) * 100;
|
||||
}
|
||||
|
||||
return Number(order.pnl_percent || 0);
|
||||
},
|
||||
|
||||
calculateOpenOrderPnlAmount(order, pnlPercent) {
|
||||
const positionValue = Number(order.quantity || 0);
|
||||
if (!Number.isFinite(positionValue) || positionValue <= 0) {
|
||||
return Number(order.unrealized_pnl || 0);
|
||||
}
|
||||
return positionValue * pnlPercent / 100;
|
||||
},
|
||||
|
||||
getCloseReason(reason) {
|
||||
const map = {
|
||||
'manual': '手动',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user