fix bug!
This commit is contained in:
parent
61ed81c9b6
commit
491a1d29f1
@ -11,6 +11,7 @@ from app.services.price_monitor_service import get_price_monitor_service
|
||||
from app.services.bitget_service import bitget_service
|
||||
from app.services.db_service import db_service
|
||||
from app.utils.logger import logger
|
||||
from app.crypto_agent.crypto_agent import get_crypto_agent
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/trading", tags=["交易"])
|
||||
@ -27,6 +28,11 @@ class DeleteOrdersRequest(BaseModel):
|
||||
recalculate: bool = True # 是否重新计算统计数据
|
||||
|
||||
|
||||
class ResumePlatformRequest(BaseModel):
|
||||
"""恢复平台执行请求"""
|
||||
platform: str
|
||||
|
||||
|
||||
class OrderResponse(BaseModel):
|
||||
"""订单响应"""
|
||||
success: bool
|
||||
@ -219,6 +225,39 @@ async def delete_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/platform-halts")
|
||||
async def get_platform_halts():
|
||||
"""获取平台熔断/停机状态"""
|
||||
try:
|
||||
agent = get_crypto_agent()
|
||||
return {
|
||||
"success": True,
|
||||
"platform_halts": agent.get_platform_halt_status(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取平台熔断状态失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/platform-halts/resume")
|
||||
async def resume_platform(request: ResumePlatformRequest):
|
||||
"""手动恢复指定平台执行"""
|
||||
try:
|
||||
agent = get_crypto_agent()
|
||||
result = agent.resume_platform(request.platform)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{request.platform} 已恢复执行",
|
||||
"platform": request.platform,
|
||||
"status": result,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"恢复平台执行失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/orders/batch-delete")
|
||||
async def batch_delete_orders(request: DeleteOrdersRequest):
|
||||
"""
|
||||
|
||||
@ -1,14 +1,33 @@
|
||||
"""
|
||||
系统状态 API
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any
|
||||
from app.utils.logger import logger
|
||||
from app.utils.system_status import get_system_monitor
|
||||
from app.crypto_agent.crypto_agent import get_crypto_agent
|
||||
from app.services.signal_database_service import get_signal_db_service
|
||||
from app.services.paper_trading_service import get_paper_trading_service
|
||||
from app.services.bitget_live_trading_service import get_bitget_live_service
|
||||
from app.services.hyperliquid_trading_service import get_hyperliquid_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_signal_timestamp(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None) if value.tzinfo else value
|
||||
text = str(value).replace("Z", "+00:00")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
return parsed.replace(tzinfo=None) if parsed.tzinfo else parsed
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/status", response_model=Dict[str, Any])
|
||||
async def get_system_status():
|
||||
"""
|
||||
@ -91,3 +110,155 @@ async def get_agent_status(agent_id: str):
|
||||
except Exception as e:
|
||||
logger.error(f"获取 Agent 状态失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取 Agent 状态失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/console", response_model=Dict[str, Any])
|
||||
async def get_console_snapshot():
|
||||
"""
|
||||
获取总控台快照
|
||||
|
||||
聚合系统运行态、信号统计、模拟盘与实盘平台状态,供总控台页面使用。
|
||||
"""
|
||||
try:
|
||||
monitor = get_system_monitor()
|
||||
summary = monitor.get_summary()
|
||||
now = datetime.now()
|
||||
|
||||
signal_db = get_signal_db_service()
|
||||
signal_stats = signal_db.get_signal_stats(days=7)
|
||||
latest_signals = signal_db.get_latest_signals(limit=12, days=3)
|
||||
|
||||
crypto_agent = get_crypto_agent()
|
||||
crypto_status = crypto_agent.get_status()
|
||||
|
||||
paper_service = get_paper_trading_service()
|
||||
paper_account = paper_service.get_account_status()
|
||||
paper_orders = paper_service.get_active_orders()
|
||||
paper_positions = [o for o in paper_orders if o.get('status') == 'open']
|
||||
paper_pending = [o for o in paper_orders if o.get('status') == 'pending']
|
||||
paper_stats = paper_service.calculate_statistics()
|
||||
|
||||
bitget_service = get_bitget_live_service()
|
||||
bitget_summary = {"enabled": False}
|
||||
if bitget_service is not None:
|
||||
bg_account = bitget_service.get_account_state()
|
||||
bg_positions = bitget_service.get_open_positions()
|
||||
bg_orders = bitget_service.get_open_orders()
|
||||
bg_total_position_value = sum(abs(p["size"]) * p["entry_price"] for p in bg_positions)
|
||||
bg_drawdown = 0.0
|
||||
if bitget_service.initial_balance and bitget_service.initial_balance > 0:
|
||||
bg_drawdown = (bitget_service.initial_balance - bg_account["account_value"]) / bitget_service.initial_balance * 100
|
||||
|
||||
bitget_summary = {
|
||||
"enabled": True,
|
||||
"account": {
|
||||
"account_value": bg_account.get("account_value", 0),
|
||||
"available_balance": bg_account.get("available_balance", 0),
|
||||
"total_margin_used": bg_account.get("total_margin_used", 0),
|
||||
"initial_balance": bitget_service.initial_balance,
|
||||
},
|
||||
"positions": {
|
||||
"count": len(bg_positions),
|
||||
"total_value": bg_total_position_value,
|
||||
"items": bg_positions[:8],
|
||||
},
|
||||
"orders": {
|
||||
"count": len(bg_orders),
|
||||
"entry_orders": len([o for o in bg_orders if not o.get("is_reduce_only")]),
|
||||
"tp_sl_orders": len([o for o in bg_orders if o.get("is_reduce_only")]),
|
||||
"items": bg_orders[:8],
|
||||
},
|
||||
"risk": {
|
||||
"current_leverage": bg_total_position_value / bg_account["account_value"] if bg_account.get("account_value", 0) > 0 else 0,
|
||||
"max_leverage": bitget_service.max_total_leverage,
|
||||
"drawdown_percent": bg_drawdown,
|
||||
"circuit_breaker_threshold": bitget_service.circuit_breaker_drawdown * 100,
|
||||
},
|
||||
}
|
||||
|
||||
hyperliquid_service = get_hyperliquid_service()
|
||||
hyperliquid_summary = {"enabled": False}
|
||||
if hyperliquid_service is not None:
|
||||
hl_account = hyperliquid_service.get_account_state()
|
||||
hl_positions = hyperliquid_service.get_open_positions()
|
||||
hl_orders = hyperliquid_service.get_open_orders()
|
||||
hl_total_position_value = sum(abs(p["size"]) * p["entry_price"] for p in hl_positions)
|
||||
hl_drawdown = 0.0
|
||||
if hyperliquid_service.initial_balance and hyperliquid_service.initial_balance > 0:
|
||||
hl_drawdown = (hyperliquid_service.initial_balance - hl_account["account_value"]) / hyperliquid_service.initial_balance * 100
|
||||
|
||||
hyperliquid_summary = {
|
||||
"enabled": True,
|
||||
"account": {
|
||||
"account_value": hl_account.get("account_value", 0),
|
||||
"available_balance": hl_account.get("available_balance", 0),
|
||||
"total_margin_used": hl_account.get("total_margin_used", 0),
|
||||
"initial_balance": hyperliquid_service.initial_balance,
|
||||
},
|
||||
"positions": {
|
||||
"count": len(hl_positions),
|
||||
"total_value": hl_total_position_value,
|
||||
"items": hl_positions[:8],
|
||||
},
|
||||
"orders": {
|
||||
"count": len(hl_orders),
|
||||
"entry_orders": len([o for o in hl_orders if not o.get("is_reduce_only")]),
|
||||
"tp_sl_orders": len([o for o in hl_orders if o.get("is_reduce_only")]),
|
||||
"items": hl_orders[:8],
|
||||
},
|
||||
"risk": {
|
||||
"current_leverage": hl_total_position_value / hl_account["account_value"] if hl_account.get("account_value", 0) > 0 else 0,
|
||||
"max_leverage": hyperliquid_service.max_total_leverage,
|
||||
"drawdown_percent": hl_drawdown,
|
||||
"circuit_breaker_threshold": hyperliquid_service.circuit_breaker_drawdown * 100,
|
||||
},
|
||||
}
|
||||
|
||||
recent_cutoff = now - timedelta(minutes=30)
|
||||
recent_signal_count = sum(
|
||||
1
|
||||
for signal in latest_signals
|
||||
if (_parse_signal_timestamp(signal.get("created_at")) or datetime.min) >= recent_cutoff
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"generated_at": now.isoformat(),
|
||||
"system": summary,
|
||||
"crypto_agent": crypto_status,
|
||||
"execution_events": crypto_agent.get_recent_execution_events(limit=40),
|
||||
"signals": {
|
||||
"stats_7d": signal_stats,
|
||||
"latest": latest_signals,
|
||||
"recent_30m_count": recent_signal_count,
|
||||
},
|
||||
"platforms": {
|
||||
"paper": {
|
||||
"enabled": True,
|
||||
"account": paper_account,
|
||||
"positions": {
|
||||
"count": len(paper_positions),
|
||||
"items": paper_positions[:8],
|
||||
},
|
||||
"orders": {
|
||||
"count": len(paper_orders),
|
||||
"pending_count": len(paper_pending),
|
||||
"items": paper_pending[:8],
|
||||
},
|
||||
"statistics": {
|
||||
"win_rate": paper_stats.get("win_rate", 0),
|
||||
"total_trades": paper_stats.get("total_trades", 0),
|
||||
"total_pnl": paper_stats.get("total_pnl", 0),
|
||||
"max_drawdown": paper_stats.get("max_drawdown", 0),
|
||||
"by_grade": paper_stats.get("by_grade", {}),
|
||||
},
|
||||
},
|
||||
"bitget": bitget_summary,
|
||||
"hyperliquid": hyperliquid_summary,
|
||||
},
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取总控台快照失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取总控台快照失败: {str(e)}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -22,6 +22,7 @@ class HyperliquidExecutor(BaseExecutor):
|
||||
try:
|
||||
symbol = decision.get('symbol', '').replace('USDT', '')
|
||||
action = decision.get('signal_action', decision.get('action')) # buy/sell
|
||||
margin = decision.get('margin', decision.get('quantity', 0))
|
||||
entry_price = decision.get('entry_price', current_price)
|
||||
stop_loss = decision.get('stop_loss')
|
||||
take_profit = decision.get('take_profit')
|
||||
@ -33,15 +34,15 @@ class HyperliquidExecutor(BaseExecutor):
|
||||
# 获取账户状态
|
||||
account_state = self.hyperliquid.get_account_state()
|
||||
available = account_state.get('available_balance', 0)
|
||||
account_value = account_state.get('account_value', 0)
|
||||
|
||||
# 仓位价值 = 1x 账户价值,所需保证金 = 账户价值 / 杠杆
|
||||
leverage = min(decision.get('leverage', 10), 10)
|
||||
target_position_value = account_value # 1倍账户价值
|
||||
margin_needed = target_position_value / leverage if leverage > 0 else 0
|
||||
adjusted_margin = self.calculate_effective_margin(available, margin)
|
||||
|
||||
# 预留手续费并限制在可用余额内
|
||||
adjusted_margin = self.calculate_effective_margin(available, margin_needed)
|
||||
if adjusted_margin <= 0:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'保证金无效: {adjusted_margin}'
|
||||
}
|
||||
|
||||
# 计算仓位大小
|
||||
position_size = self._calculate_position_size(symbol, adjusted_margin, entry_price, leverage)
|
||||
@ -84,25 +85,40 @@ class HyperliquidExecutor(BaseExecutor):
|
||||
|
||||
logger.info(f" ✅ 开仓成功: {symbol} {position_size} @ ${order_type}")
|
||||
|
||||
# 成交后设置止盈止损(Hyperliquid 不支持下单时设置 TP/SL)
|
||||
# 必须在飞书通知之前设置,避免通知异常导致止盈止损跳过
|
||||
# 设置止盈止损
|
||||
if stop_loss or take_profit:
|
||||
try:
|
||||
tp_sl_result = self.hyperliquid.set_tp_sl(
|
||||
symbol=symbol,
|
||||
is_long=is_buy,
|
||||
size=position_size,
|
||||
tp_price=take_profit,
|
||||
sl_price=stop_loss
|
||||
)
|
||||
if not tp_sl_result.get('success'):
|
||||
logger.warning(f" ⚠️ 止盈止损设置失败: {tp_sl_result.get('error', tp_sl_result.get('message'))}")
|
||||
result['tp_sl_warning'] = tp_sl_result.get('error', tp_sl_result.get('message'))
|
||||
else:
|
||||
logger.info(f" ✅ 止盈止损已设置: TP={take_profit}, SL={stop_loss}")
|
||||
except Exception as tp_sl_err:
|
||||
logger.error(f" ⚠️ 止盈止损设置异常: {tp_sl_err}")
|
||||
result['tp_sl_warning'] = str(tp_sl_err)
|
||||
if order_status == 'filled':
|
||||
# 市价单已成交,直接设置 TP/SL
|
||||
try:
|
||||
tp_sl_result = self.hyperliquid.set_tp_sl(
|
||||
symbol=symbol,
|
||||
is_long=is_buy,
|
||||
size=position_size,
|
||||
tp_price=take_profit,
|
||||
sl_price=stop_loss
|
||||
)
|
||||
tp_set = tp_sl_result.get('tp_set', False)
|
||||
sl_set = tp_sl_result.get('sl_set', False)
|
||||
|
||||
if tp_set and sl_set:
|
||||
logger.info(f" ✅ 止盈止损已设置: TP={take_profit}, SL={stop_loss}")
|
||||
elif tp_set or sl_set:
|
||||
# 部分成功:记录缺失侧
|
||||
set_text = "TP" if tp_set else "SL"
|
||||
fail_text = "TP" if not tp_set else "SL"
|
||||
logger.warning(f" ⚠️ 止盈止损部分成功: {set_text}已设, {fail_text}失败")
|
||||
result['tp_sl_warning'] = f"{fail_text}设置失败: {tp_sl_result.get('errors', [])}"
|
||||
else:
|
||||
errors = tp_sl_result.get('errors', [])
|
||||
logger.warning(f" ⚠️ 止盈止损设置失败: {errors}")
|
||||
result['tp_sl_warning'] = f"TP/SL设置失败: {'; '.join(errors)}"
|
||||
except Exception as tp_sl_err:
|
||||
logger.error(f" ⚠️ 止盈止损设置异常: {tp_sl_err}")
|
||||
result['tp_sl_warning'] = str(tp_sl_err)
|
||||
else:
|
||||
# 限价单未成交,暂时跳过(等成交后再设)
|
||||
logger.info(f" 📌 限价单待成交,TP/SL 将在成交后设置: TP={take_profit}, SL={stop_loss}")
|
||||
result['tp_sl_warning'] = "限价单未成交,TP/SL 待成交后设置"
|
||||
|
||||
# 发送飞书通知(在止盈止损之后,通知失败不影响交易结果)
|
||||
await self.send_execution_notification(
|
||||
|
||||
@ -27,45 +27,49 @@ from app.services.bitget_service import bitget_service
|
||||
class MarketSignalAnalyzer:
|
||||
"""市场信号分析器 - 只关注市场,输出客观信号"""
|
||||
|
||||
INTRADAY_ANALYSIS_TEMPERATURE = 0.15
|
||||
TREND_ANALYSIS_TEMPERATURE = 0.10
|
||||
INTRADAY_ANALYSIS_TEMPERATURE = 0.12
|
||||
TREND_ANALYSIS_TEMPERATURE = 0.08
|
||||
ANALYSIS_MAX_TOKENS = 1200
|
||||
LANE_MIN_CONFIDENCE = {
|
||||
'short_term': 70,
|
||||
'medium_term': 70,
|
||||
}
|
||||
LANE_MIN_RISK_REWARD = {
|
||||
'short_term': 1.5,
|
||||
'medium_term': 1.8,
|
||||
'short_term': 1.6,
|
||||
'medium_term': 2.0,
|
||||
}
|
||||
LANE_MIN_STOP_LOSS_PCT = {
|
||||
'short_term': 0.6,
|
||||
'medium_term': 1.0,
|
||||
'short_term': 0.7,
|
||||
'medium_term': 1.5,
|
||||
}
|
||||
LANE_MIN_TAKE_PROFIT_PCT = {
|
||||
'short_term': 1.0,
|
||||
'medium_term': 2.0,
|
||||
'short_term': 1.2,
|
||||
'medium_term': 3.0,
|
||||
}
|
||||
FIB_MIN_PIVOT_SEPARATION_BARS = 4
|
||||
FIB_PIVOT_VOLUME_LOOKBACK = 20
|
||||
|
||||
INTRADAY_ANALYSIS_PROMPT = """你是一位专业的加密货币日内交易员,只负责生成 short_term 信号。
|
||||
|
||||
你的任务是基于 5m / 15m、当日开盘、VWAP、开盘区间、关键位、Fib 回撤位和衍生品拥挤度,判断未来 30 分钟到 4 小时内是否存在可执行 setup。
|
||||
你的任务是基于 5m / 15m、当日开盘、VWAP、开盘区间、关键位、Fib 回撤位和衍生品拥挤度,判断未来 30 分钟到 4 小时内是否存在可执行的合约日内 setup。
|
||||
|
||||
执行原则:
|
||||
1. 先判断日内 regime:trending / ranging / neutral。
|
||||
2. 趋势日内只做顺势回调或突破后的回踩确认,不追涨杀跌。
|
||||
3. 震荡日内只做区间边界附近的反转,不在区间中部开仓。
|
||||
4. 技术指标只做辅助,优先看结构、关键位、波动率、量能、VWAP 偏离和距离。
|
||||
4. 技术指标只做辅助,优先看结构、关键位、波动率、量能、VWAP 偏离和位置优势。
|
||||
5. 优先使用“优先支撑 / 优先阻力”和“可交易多头区 / 可交易空头区”,普通支撑阻力只作补充。
|
||||
6. 没有清晰止损、止盈和盈亏比就不交易。
|
||||
7. 本次分析独立进行,不参考任何上一轮信号。
|
||||
8. 硬性禁止:
|
||||
- 如果多周期特征已确认上升趋势(HH+HL 结构,或突破震荡区间向上),禁止输出 sell 信号。
|
||||
- 如果多周期特征已确认下降趋势(LL+LH 结构,或跌破震荡区间向下),禁止输出 buy 信号。
|
||||
- 逆势信号只允许在 trend_direction=neutral 且有明确区间边界反转结构时输出。
|
||||
|
||||
信号要求:
|
||||
1. 只允许输出 0 或 1 个 short_term 信号。
|
||||
2. 盈亏比至少 1:1.5。
|
||||
3. 如果价格处于加速延伸,优先返回空信号。
|
||||
2. 盈亏比至少 1:1.6。
|
||||
3. 如果价格处于加速延伸、远离优先交易区、或衍生品同向拥挤,优先返回空信号。
|
||||
4. 如果价格位于区间中部、离关键位太远、止损过宽或方向证据冲突,必须返回空信号。
|
||||
5. 做多时,entry 应尽量靠近优先支撑或多头共振区;做空时,entry 应尽量靠近优先阻力或空头共振区。
|
||||
6. 只有在 setup 足够清晰时才允许输出信号;宁可空仓,不要勉强给单。
|
||||
@ -78,8 +82,10 @@ class MarketSignalAnalyzer:
|
||||
- C: 70-71,只有轻仓试错级别
|
||||
- 70 以下不要输出交易信号
|
||||
9. 止损止盈距离下限:
|
||||
- short_term 止损距离至少 0.6%
|
||||
- short_term 止盈距离至少 1.0%
|
||||
- short_term 止损距离至少 0.7%
|
||||
- short_term 止盈距离至少 1.2%
|
||||
10. reasoning 必须覆盖四点中的至少三点:结构、位置、量价/波动、衍生品拥挤度。
|
||||
11. 如果数据明确显示 `market_location=middle_of_range` 或 `far_from_trade_zone`,必须返回空信号。
|
||||
|
||||
输出 JSON,禁止输出解释性正文:
|
||||
```json
|
||||
@ -102,7 +108,7 @@ class MarketSignalAnalyzer:
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "结构+关键位+量能+波动率"
|
||||
"reasoning": "结构+位置+量价/波动+拥挤度"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -118,7 +124,7 @@ class MarketSignalAnalyzer:
|
||||
|
||||
TREND_ANALYSIS_PROMPT = """你是一位专业的加密货币趋势交易员,只负责生成 medium_term 信号。
|
||||
|
||||
你的任务是基于 1h / 4h / 1d、关键位、Fib 回撤/扩展位、趋势阶段、反转检测、衍生品拥挤度和新闻催化,判断未来 4 小时到 1 周内是否存在趋势 setup。
|
||||
你的任务是基于 1h / 4h / 1d、关键位、Fib 回撤/扩展位、趋势阶段、反转检测、衍生品拥挤度和新闻催化,判断未来 4 小时到 1 周内是否存在可执行的合约趋势 setup。
|
||||
|
||||
执行原则:
|
||||
1. 4h/1d 决定大方向,1h 决定节奏与入场位置。
|
||||
@ -126,14 +132,15 @@ class MarketSignalAnalyzer:
|
||||
- 趋势延续:4h/1d 趋势明确,1h 回踩关键位后确认继续
|
||||
- 趋势反转:4h/1d 结构和 1h 动能同时改善,且反转证据充分
|
||||
3. 禁止仅凭 15m 噪音逆 4h 开仓。
|
||||
4. 趋势晚期、资金费率过热或价格过度偏离关键均线时,要显著降低开仓积极性。
|
||||
4. 趋势晚期、资金费率过热、价格过度偏离关键均线、或衍生品顺向拥挤时,要显著降低开仓积极性。
|
||||
5. 没有清晰位置优势就不交易。
|
||||
6. 本次分析独立进行,不参考任何上一轮信号。
|
||||
7. 优先使用“优先支撑 / 优先阻力”和“可交易多头区 / 可交易空头区”,普通关键位只作补充。
|
||||
8. 趋势单的核心不是猜方向,而是等待大级别方向明确后,在有位置优势的回踩/反抽处开仓。
|
||||
|
||||
信号要求:
|
||||
1. 只允许输出 0 或 1 个 medium_term 信号。
|
||||
2. 盈亏比至少 1:1.8。
|
||||
2. 盈亏比至少 1:2.0。
|
||||
3. 如果 4h/1d 与 1h 明显冲突,优先返回空信号。
|
||||
4. 反转信号必须比延续信号更严格。
|
||||
5. 如果趋势处于晚期且没有回踩确认,或反转证据不足,必须返回空信号。
|
||||
@ -145,8 +152,10 @@ class MarketSignalAnalyzer:
|
||||
- C: 70-71,仅限早期确认不足的轻仓趋势尝试
|
||||
- 70 以下不要输出交易信号
|
||||
9. 止损止盈距离下限:
|
||||
- medium_term 止损距离至少 1.0%
|
||||
- medium_term 止盈距离至少 2.0%
|
||||
- medium_term 止损距离至少 1.5%
|
||||
- medium_term 止盈距离至少 3.0%
|
||||
10. reasoning 必须明确:大级别方向、1h 入场节奏、位置优势、拥挤度风险。
|
||||
11. 如果价格已经远离优先交易区,或趋势方向虽对但没有回踩/反抽确认,必须返回空信号。
|
||||
|
||||
输出 JSON,禁止输出解释性正文:
|
||||
```json
|
||||
@ -169,7 +178,7 @@ class MarketSignalAnalyzer:
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "4h方向+1h节奏+关键位+量价"
|
||||
"reasoning": "4h/1d方向+1h节奏+位置优势+拥挤度"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -216,14 +225,16 @@ class MarketSignalAnalyzer:
|
||||
lane="intraday",
|
||||
market_context=market_context,
|
||||
news_context=news_context,
|
||||
futures_context=futures_context
|
||||
futures_context=futures_context,
|
||||
futures_market_data=futures_market_data,
|
||||
)
|
||||
trend_prompt = self._build_analysis_prompt(
|
||||
symbol=symbol,
|
||||
lane="trend",
|
||||
market_context=market_context,
|
||||
news_context=news_context,
|
||||
futures_context=futures_context
|
||||
futures_context=futures_context,
|
||||
futures_market_data=futures_market_data,
|
||||
)
|
||||
|
||||
intraday_messages = [
|
||||
@ -298,6 +309,7 @@ class MarketSignalAnalyzer:
|
||||
trend_stage = self._detect_trend_stage(data)
|
||||
fib_context = self._build_fibonacci_context(data, current_price)
|
||||
key_levels = self._derive_key_levels(data, range_zone, fib_context, current_price)
|
||||
market_location = self._build_market_location_summary(current_price, range_zone, key_levels)
|
||||
|
||||
snapshot_parts = [
|
||||
f"## 市场快照",
|
||||
@ -316,6 +328,7 @@ class MarketSignalAnalyzer:
|
||||
snapshot_parts.append(
|
||||
f"- 开盘区间(前30分钟): 高 {opening_range['high']:.2f} / 低 {opening_range['low']:.2f}"
|
||||
)
|
||||
snapshot_parts.append(f"- 市场位置: {market_location['summary']}")
|
||||
|
||||
intraday_parts = [
|
||||
"## 日内特征",
|
||||
@ -396,6 +409,53 @@ class MarketSignalAnalyzer:
|
||||
f"{'BB收口' if range_metrics['bb_squeeze'] else 'BB正常'}"
|
||||
)
|
||||
|
||||
intraday_structured = self._build_market_context_block(
|
||||
lane='intraday',
|
||||
symbol=symbol,
|
||||
current_price=current_price,
|
||||
day_open=day_open,
|
||||
session_vwap=session_vwap,
|
||||
opening_range=opening_range,
|
||||
intraday_alignment=intraday_alignment,
|
||||
trend_alignment=trend_alignment,
|
||||
feature_map={
|
||||
'5m': feature_5m,
|
||||
'15m': feature_15m,
|
||||
'1h': feature_1h,
|
||||
'4h': feature_4h,
|
||||
},
|
||||
range_zone=range_zone,
|
||||
range_metrics=range_metrics,
|
||||
reversal_detection=reversal_detection,
|
||||
trend_stage=trend_stage,
|
||||
fib_context=fib_context,
|
||||
key_levels=key_levels,
|
||||
market_location=market_location,
|
||||
)
|
||||
trend_structured = self._build_market_context_block(
|
||||
lane='trend',
|
||||
symbol=symbol,
|
||||
current_price=current_price,
|
||||
day_open=day_open,
|
||||
session_vwap=session_vwap,
|
||||
opening_range=opening_range,
|
||||
intraday_alignment=intraday_alignment,
|
||||
trend_alignment=trend_alignment,
|
||||
feature_map={
|
||||
'15m': feature_15m,
|
||||
'1h': feature_1h,
|
||||
'4h': feature_4h,
|
||||
'1d': feature_1d,
|
||||
},
|
||||
range_zone=range_zone,
|
||||
range_metrics=range_metrics,
|
||||
reversal_detection=reversal_detection,
|
||||
trend_stage=trend_stage,
|
||||
fib_context=fib_context,
|
||||
key_levels=key_levels,
|
||||
market_location=market_location,
|
||||
)
|
||||
|
||||
return {
|
||||
'snapshot': "\n".join(snapshot_parts),
|
||||
'intraday': "\n".join(intraday_parts),
|
||||
@ -403,6 +463,9 @@ class MarketSignalAnalyzer:
|
||||
'levels': "\n".join(levels_parts),
|
||||
'range_warning': range_warning,
|
||||
'range_metrics': range_metrics,
|
||||
'market_location': market_location,
|
||||
'intraday_structured': intraday_structured,
|
||||
'trend_structured': trend_structured,
|
||||
}
|
||||
|
||||
def _get_session_open(self, df: Optional[pd.DataFrame]) -> Optional[float]:
|
||||
@ -1118,6 +1181,199 @@ class MarketSignalAnalyzer:
|
||||
return "N/A"
|
||||
return ", ".join(f"{level:.2f}" for level in levels[:3])
|
||||
|
||||
def _build_market_location_summary(self,
|
||||
current_price: float,
|
||||
range_zone: Dict[str, Any],
|
||||
key_levels: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""量化当前价格相对区间和优先交易区的位置"""
|
||||
summary = {
|
||||
'location_tag': 'unknown',
|
||||
'relative_to_range': 'unknown',
|
||||
'distance_to_best_long_zone_pct': None,
|
||||
'distance_to_best_short_zone_pct': None,
|
||||
'summary': '未知',
|
||||
}
|
||||
|
||||
best_long_zone = key_levels.get('best_long_zone')
|
||||
best_short_zone = key_levels.get('best_short_zone')
|
||||
|
||||
if best_long_zone and current_price > 0:
|
||||
summary['distance_to_best_long_zone_pct'] = round(
|
||||
abs(current_price - float(best_long_zone['center'])) / current_price * 100, 2
|
||||
)
|
||||
if best_short_zone and current_price > 0:
|
||||
summary['distance_to_best_short_zone_pct'] = round(
|
||||
abs(current_price - float(best_short_zone['center'])) / current_price * 100, 2
|
||||
)
|
||||
|
||||
if range_zone.get('is_ranging') and range_zone.get('support_level') and range_zone.get('resistance_level'):
|
||||
low = float(range_zone['support_level'])
|
||||
high = float(range_zone['resistance_level'])
|
||||
width = high - low
|
||||
if width > 0:
|
||||
position = (current_price - low) / width
|
||||
if position <= 0.25:
|
||||
summary['relative_to_range'] = 'near_range_support'
|
||||
elif position >= 0.75:
|
||||
summary['relative_to_range'] = 'near_range_resistance'
|
||||
else:
|
||||
summary['relative_to_range'] = 'middle_of_range'
|
||||
|
||||
long_dist = summary['distance_to_best_long_zone_pct']
|
||||
short_dist = summary['distance_to_best_short_zone_pct']
|
||||
|
||||
candidates = [(long_dist, 'near_long_zone'), (short_dist, 'near_short_zone')]
|
||||
valid_candidates = [(dist, tag) for dist, tag in candidates if dist is not None]
|
||||
if valid_candidates:
|
||||
nearest_dist, nearest_tag = min(valid_candidates, key=lambda item: item[0])
|
||||
if nearest_dist <= 0.6:
|
||||
summary['location_tag'] = nearest_tag
|
||||
elif nearest_dist >= 2.0:
|
||||
summary['location_tag'] = 'far_from_trade_zone'
|
||||
else:
|
||||
summary['location_tag'] = 'between_trade_zones'
|
||||
|
||||
if summary['relative_to_range'] == 'middle_of_range':
|
||||
summary['location_tag'] = 'middle_of_range'
|
||||
|
||||
summary['summary'] = (
|
||||
f"location={summary['location_tag']} | range={summary['relative_to_range']} | "
|
||||
f"dist_long={summary['distance_to_best_long_zone_pct']}% | "
|
||||
f"dist_short={summary['distance_to_best_short_zone_pct']}%"
|
||||
)
|
||||
return summary
|
||||
|
||||
def _serialize_feature_block(self, feature: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""把单周期特征压成稳定字段,供 prompt 直接消费"""
|
||||
if not feature.get('available'):
|
||||
return {'available': False}
|
||||
|
||||
def rounded(value: Optional[float], digits: int = 2) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
return round(float(value), digits)
|
||||
|
||||
return {
|
||||
'available': True,
|
||||
'structure': feature.get('structure'),
|
||||
'ema_alignment': feature.get('ema_alignment'),
|
||||
'momentum_3_pct': rounded(feature.get('momentum_3')),
|
||||
'momentum_12_pct': rounded(feature.get('momentum_12')),
|
||||
'rsi': rounded(feature.get('rsi'), 1),
|
||||
'atr_pct': rounded(feature.get('atr_pct')),
|
||||
'volume_ratio': rounded(feature.get('volume_ratio')),
|
||||
'distance_to_ema20_pct': rounded(feature.get('distance_to_ema20')),
|
||||
'distance_to_recent_high_pct': rounded(feature.get('distance_to_recent_high')),
|
||||
'distance_to_recent_low_pct': rounded(feature.get('distance_to_recent_low')),
|
||||
'is_accelerating': bool(feature.get('is_accelerating')),
|
||||
'adx': rounded(feature.get('adx'), 1),
|
||||
'trend_strength_adx': feature.get('trend_strength_adx'),
|
||||
}
|
||||
|
||||
def _build_market_context_block(self,
|
||||
lane: str,
|
||||
symbol: str,
|
||||
current_price: float,
|
||||
day_open: Optional[float],
|
||||
session_vwap: Optional[float],
|
||||
opening_range: Optional[Dict[str, float]],
|
||||
intraday_alignment: str,
|
||||
trend_alignment: str,
|
||||
feature_map: Dict[str, Dict[str, Any]],
|
||||
range_zone: Dict[str, Any],
|
||||
range_metrics: Dict[str, Any],
|
||||
reversal_detection: Dict[str, Any],
|
||||
trend_stage: Dict[str, Any],
|
||||
fib_context: Dict[str, Any],
|
||||
key_levels: Dict[str, Any],
|
||||
market_location: Dict[str, Any]) -> str:
|
||||
"""构建给 LLM 的结构化行情上下文"""
|
||||
block = {
|
||||
'symbol': symbol,
|
||||
'lane': lane,
|
||||
'current_price': round(current_price, 4),
|
||||
'day_open': round(float(day_open), 4) if day_open else None,
|
||||
'session_vwap': round(float(session_vwap), 4) if session_vwap else None,
|
||||
'opening_range': (
|
||||
{
|
||||
'high': round(float(opening_range['high']), 4),
|
||||
'low': round(float(opening_range['low']), 4),
|
||||
} if opening_range else None
|
||||
),
|
||||
'alignment': {
|
||||
'intraday': intraday_alignment,
|
||||
'trend': trend_alignment,
|
||||
},
|
||||
'market_location': market_location,
|
||||
'range_state': {
|
||||
'is_ranging': bool(range_zone.get('is_ranging')),
|
||||
'support_level': round(float(range_zone.get('support_level')), 4) if range_zone.get('support_level') else None,
|
||||
'resistance_level': round(float(range_zone.get('resistance_level')), 4) if range_zone.get('resistance_level') else None,
|
||||
'range_width_pct': round(float(range_zone.get('range_width_pct', 0) or 0), 2),
|
||||
'confidence': int(range_zone.get('confidence', 0) or 0),
|
||||
'regime': range_metrics.get('regime'),
|
||||
'regime_score': int(range_metrics.get('regime_score', 0) or 0),
|
||||
'efficiency': round(float(range_metrics.get('range_efficiency', 0) or 0), 2),
|
||||
'adx': round(float(range_metrics.get('adx', 0) or 0), 1),
|
||||
},
|
||||
'trend_stage': {
|
||||
'stage': trend_stage.get('stage', 'unknown'),
|
||||
'confidence': int(trend_stage.get('confidence', 0) or 0),
|
||||
},
|
||||
'reversal_detection': {
|
||||
'is_reversing': bool(reversal_detection.get('is_reversing')),
|
||||
'type': reversal_detection.get('reversal_type'),
|
||||
'confidence': int(reversal_detection.get('confidence', 0) or 0),
|
||||
},
|
||||
'timeframes': {
|
||||
timeframe: self._serialize_feature_block(feature)
|
||||
for timeframe, feature in feature_map.items()
|
||||
},
|
||||
'levels': {
|
||||
'support': [round(float(level), 4) for level in key_levels.get('support', [])[:3]],
|
||||
'resistance': [round(float(level), 4) for level in key_levels.get('resistance', [])[:3]],
|
||||
'priority_support': [
|
||||
{
|
||||
'price': round(float(level['price']), 4),
|
||||
'score': round(float(level['score']), 2),
|
||||
'distance_pct': round(float(level.get('distance_pct', 0) or 0), 2),
|
||||
'sources': level.get('sources', [])[:3],
|
||||
}
|
||||
for level in key_levels.get('support_priority', [])[:2]
|
||||
],
|
||||
'priority_resistance': [
|
||||
{
|
||||
'price': round(float(level['price']), 4),
|
||||
'score': round(float(level['score']), 2),
|
||||
'distance_pct': round(float(level.get('distance_pct', 0) or 0), 2),
|
||||
'sources': level.get('sources', [])[:3],
|
||||
}
|
||||
for level in key_levels.get('resistance_priority', [])[:2]
|
||||
],
|
||||
'best_long_zone': self._serialize_trade_zone(key_levels.get('best_long_zone')),
|
||||
'best_short_zone': self._serialize_trade_zone(key_levels.get('best_short_zone')),
|
||||
},
|
||||
'fib_context': {
|
||||
'intraday': fib_context.get('intraday') if lane == 'intraday' else None,
|
||||
'trend': fib_context.get('trend') if lane == 'trend' else None,
|
||||
},
|
||||
}
|
||||
|
||||
return "```json\n" + json.dumps(block, ensure_ascii=False, indent=2) + "\n```"
|
||||
|
||||
def _serialize_trade_zone(self, zone: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if not zone:
|
||||
return None
|
||||
return {
|
||||
'action': zone.get('action'),
|
||||
'center': round(float(zone['center']), 4),
|
||||
'low': round(float(zone['low']), 4),
|
||||
'high': round(float(zone['high']), 4),
|
||||
'distance_pct': round(float(zone.get('distance_pct', 0) or 0), 2),
|
||||
'score': round(float(zone.get('score', 0) or 0), 2),
|
||||
'sources': zone.get('sources', [])[:3],
|
||||
}
|
||||
|
||||
def _infer_price_structure(self, df: pd.DataFrame, lookback: int = 20) -> str:
|
||||
"""根据分段高低点判断 HH/HL / LH/LL / 区间"""
|
||||
if df is None or len(df) < lookback:
|
||||
@ -1227,6 +1483,7 @@ class MarketSignalAnalyzer:
|
||||
funding = market_data.get('funding_rate', {})
|
||||
oi = market_data.get('open_interest', {})
|
||||
premium = market_data.get('premium_rate')
|
||||
derivatives_state = self._summarize_derivatives_state(market_data)
|
||||
|
||||
lines = [
|
||||
f"## 衍生品特征",
|
||||
@ -1247,30 +1504,137 @@ class MarketSignalAnalyzer:
|
||||
if premium is not None:
|
||||
lines.append(f"- 溢价率: {premium:+.2f}%")
|
||||
|
||||
if derivatives_state.get('summary'):
|
||||
lines.append(f"- 拥挤度结论: {derivatives_state['summary']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _summarize_derivatives_state(self, market_data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""把资金费率/持仓/溢价压缩成更适合 LLM 判断的拥挤度特征"""
|
||||
summary = {
|
||||
'crowding_bias': 'neutral',
|
||||
'crowding_score': 0,
|
||||
'oi_regime': 'stable',
|
||||
'premium_regime': 'neutral',
|
||||
'summary': '中性',
|
||||
}
|
||||
|
||||
if not market_data:
|
||||
return summary
|
||||
|
||||
funding = market_data.get('funding_rate') or {}
|
||||
oi_change_pct = float(market_data.get('oi_change_percent_24h', 0) or 0)
|
||||
premium_rate = float(market_data.get('premium_rate', 0) or 0)
|
||||
funding_pct = float(funding.get('funding_rate_percent', 0) or 0)
|
||||
|
||||
score = 0
|
||||
bias = 'neutral'
|
||||
|
||||
if funding_pct >= 0.03:
|
||||
score += 20
|
||||
bias = 'long_crowded'
|
||||
elif funding_pct <= -0.03:
|
||||
score += 20
|
||||
bias = 'short_crowded'
|
||||
|
||||
if abs(oi_change_pct) >= 8:
|
||||
score += 20
|
||||
summary['oi_regime'] = 'expanding_fast'
|
||||
elif abs(oi_change_pct) >= 3:
|
||||
score += 10
|
||||
summary['oi_regime'] = 'expanding'
|
||||
elif abs(oi_change_pct) <= 1:
|
||||
summary['oi_regime'] = 'flat'
|
||||
|
||||
if premium_rate >= 0.25:
|
||||
score += 10
|
||||
summary['premium_regime'] = 'rich'
|
||||
if bias == 'neutral':
|
||||
bias = 'long_crowded'
|
||||
elif premium_rate <= -0.25:
|
||||
score += 10
|
||||
summary['premium_regime'] = 'discount'
|
||||
if bias == 'neutral':
|
||||
bias = 'short_crowded'
|
||||
|
||||
if score >= 40:
|
||||
regime = 'high'
|
||||
elif score >= 20:
|
||||
regime = 'medium'
|
||||
else:
|
||||
regime = 'low'
|
||||
|
||||
summary['crowding_bias'] = bias
|
||||
summary['crowding_score'] = score
|
||||
summary['crowding_regime'] = regime
|
||||
summary['summary'] = (
|
||||
f"{bias} | score={score} | oi={summary['oi_regime']} | premium={summary['premium_regime']}"
|
||||
)
|
||||
return summary
|
||||
|
||||
def _build_futures_context_block(self, market_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""为 LLM 构建稳定的衍生品结构化输入"""
|
||||
if not market_data:
|
||||
return None
|
||||
|
||||
funding = market_data.get('funding_rate') or {}
|
||||
oi = market_data.get('open_interest') or {}
|
||||
state = self._summarize_derivatives_state(market_data)
|
||||
|
||||
return {
|
||||
'funding_rate_percent': round(float(funding.get('funding_rate_percent', 0) or 0), 4),
|
||||
'funding_sentiment': funding.get('sentiment_level') or funding.get('sentiment') or 'neutral',
|
||||
'open_interest': round(float(oi.get('open_interest', 0) or 0), 2),
|
||||
'oi_change_percent_24h': round(float(market_data.get('oi_change_percent_24h', 0) or 0), 2),
|
||||
'premium_rate_percent': round(float(market_data.get('premium_rate', 0) or 0), 4),
|
||||
'mark_vs_index_basis_percent': round(
|
||||
float(market_data.get('premium_rate', 0) or 0),
|
||||
4
|
||||
),
|
||||
'crowding_bias': state.get('crowding_bias', 'neutral'),
|
||||
'crowding_regime': state.get('crowding_regime', 'low'),
|
||||
'crowding_score': state.get('crowding_score', 0),
|
||||
'oi_regime': state.get('oi_regime', 'stable'),
|
||||
'premium_regime': state.get('premium_regime', 'neutral'),
|
||||
'price_change_24h_pct': round(float(market_data.get('price_change_24h_pct', 0) or 0), 2),
|
||||
'range_position_24h': round(float(market_data.get('range_position_24h', 0.5) or 0.5), 2),
|
||||
'bid_ask_spread_pct': round(float(market_data.get('bid_ask_spread_pct', 0) or 0), 4),
|
||||
'quote_volume_24h': round(float(market_data.get('quote_volume_24h', 0) or 0), 2),
|
||||
}
|
||||
|
||||
def _build_analysis_prompt(self, symbol: str, lane: str,
|
||||
market_context: Dict[str, str],
|
||||
news_context: str,
|
||||
futures_context: str = "") -> str:
|
||||
futures_context: str = "",
|
||||
futures_market_data: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""构建分析提示词"""
|
||||
lane_text = "日内交易分析" if lane == "intraday" else "趋势交易分析"
|
||||
lane_scope = (
|
||||
[
|
||||
"只根据下面提供的日内结构化特征做判断,不要脑补未提供的数据。",
|
||||
"重点阅读 5m/15m、当日开盘、VWAP、开盘区间、区间状态、关键位、Fib 回撤位和衍生品过热程度。",
|
||||
"优先参考“优先支撑/优先阻力”和“可交易多头区/可交易空头区”,不要在远离关键位的位置给 entry。",
|
||||
"先看 JSON 结构块,再用后面的说明性摘要做交叉验证。",
|
||||
"重点判断是否存在位置优势,而不是只判断方向。",
|
||||
"优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone。",
|
||||
]
|
||||
if lane == "intraday"
|
||||
else [
|
||||
"只根据下面提供的趋势结构化特征做判断,不要脑补未提供的数据。",
|
||||
"重点阅读 1h/4h/1d、一致性、趋势阶段、反转检测、关键位、Fib 回撤/扩展位、新闻催化和衍生品拥挤度。",
|
||||
"优先参考“优先支撑/优先阻力”和“可交易多头区/可交易空头区”,趋势单必须体现位置优势,不接受远离关键位追价。",
|
||||
"先看 JSON 结构块,再用后面的说明性摘要做交叉验证。",
|
||||
"趋势单必须同时回答四个问题:大方向是否清晰、1h 节奏是否支持、位置是否优、拥挤是否可接受。",
|
||||
"优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone,不接受远离关键位追价。",
|
||||
]
|
||||
)
|
||||
|
||||
structured_market_context = (
|
||||
market_context.get('intraday_structured', '')
|
||||
if lane == "intraday"
|
||||
else market_context.get('trend_structured', '')
|
||||
)
|
||||
futures_block = self._build_futures_context_block(futures_market_data)
|
||||
|
||||
selected_sections = [
|
||||
market_context.get('snapshot', ''),
|
||||
structured_market_context,
|
||||
market_context.get('intraday', '') if lane == "intraday" else market_context.get('trend', ''),
|
||||
market_context.get('levels', ''),
|
||||
]
|
||||
@ -1280,6 +1644,15 @@ class MarketSignalAnalyzer:
|
||||
*lane_scope,
|
||||
]
|
||||
|
||||
if futures_block:
|
||||
prompt_parts.extend([
|
||||
"",
|
||||
"## 衍生品结构化特征",
|
||||
"```json",
|
||||
json.dumps(futures_block, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
])
|
||||
|
||||
for section in selected_sections:
|
||||
if section:
|
||||
prompt_parts.append("")
|
||||
@ -1297,6 +1670,12 @@ class MarketSignalAnalyzer:
|
||||
prompt_parts.append("")
|
||||
prompt_parts.append(market_context['range_warning'])
|
||||
|
||||
prompt_parts.append("")
|
||||
prompt_parts.append("判断时必须优先看这些约束:")
|
||||
prompt_parts.append("1. 没有位置优势,不交易。")
|
||||
prompt_parts.append("2. 方向正确但拥挤过热,也可以不交易。")
|
||||
prompt_parts.append("3. 远离优先交易区、处于区间中部、或已经加速延伸,优先空仓。")
|
||||
prompt_parts.append("4. 输出的是可执行 setup,不是主观行情评论。")
|
||||
prompt_parts.append("")
|
||||
prompt_parts.append("输出要求:只返回 system prompt 定义的 JSON 对象。没有高质量 setup 就返回 signals: []。")
|
||||
|
||||
@ -1312,8 +1691,22 @@ class MarketSignalAnalyzer:
|
||||
'trend': trend_result.get('raw_response', '')
|
||||
}
|
||||
|
||||
# 1. 先确定趋势方向(trend 车道优先,fallback 到 intraday)
|
||||
trend_direction = trend_result.get('trend_direction')
|
||||
if trend_direction in (None, 'neutral'):
|
||||
trend_direction = intraday_result.get('trend_direction', 'neutral')
|
||||
trend_direction = trend_direction or 'neutral'
|
||||
result['trend_direction'] = trend_direction
|
||||
|
||||
# 2. 标准化信号
|
||||
intraday_signals = self._normalize_lane_signals(intraday_result.get('signals', []), 'short_term')
|
||||
trend_signals = self._normalize_lane_signals(trend_result.get('signals', []), 'medium_term')
|
||||
|
||||
# 3. 过滤逆势信号(上升趋势丢弃 sell,下降趋势丢弃 buy)
|
||||
intraday_signals = self._filter_counter_trend_signals(intraday_signals, trend_direction)
|
||||
trend_signals = self._filter_counter_trend_signals(trend_signals, trend_direction)
|
||||
|
||||
# 4. 合并取 top 2
|
||||
merged_signals = sorted(
|
||||
intraday_signals + trend_signals,
|
||||
key=lambda signal: signal.get('confidence', 0),
|
||||
@ -1334,11 +1727,6 @@ class MarketSignalAnalyzer:
|
||||
),
|
||||
}
|
||||
|
||||
trend_direction = trend_result.get('trend_direction')
|
||||
if trend_direction in (None, 'neutral'):
|
||||
trend_direction = intraday_result.get('trend_direction', 'neutral')
|
||||
result['trend_direction'] = trend_direction or 'neutral'
|
||||
|
||||
trend_strength = trend_result.get('trend_strength')
|
||||
if trend_strength in (None, 'weak') and result['trend_direction'] == 'neutral':
|
||||
trend_strength = intraday_result.get('trend_strength', 'weak')
|
||||
@ -1397,6 +1785,32 @@ class MarketSignalAnalyzer:
|
||||
normalized.append(signal)
|
||||
return normalized[:1]
|
||||
|
||||
def _filter_counter_trend_signals(self, signals: List[Dict[str, Any]],
|
||||
trend_direction: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
过滤掉与确认趋势方向矛盾的信号。
|
||||
|
||||
- uptrend → 丢弃 sell 信号
|
||||
- downtrend → 丢弃 buy 信号
|
||||
- neutral → 不过滤
|
||||
"""
|
||||
if trend_direction not in ('uptrend', 'downtrend'):
|
||||
return signals
|
||||
|
||||
forbidden = 'sell' if trend_direction == 'uptrend' else 'buy'
|
||||
kept = []
|
||||
for s in signals:
|
||||
if s.get('action') == forbidden:
|
||||
lane = s.get('timeframe') or s.get('type', 'unknown')
|
||||
logger.info(
|
||||
f" [TrendFilter] 丢弃逆势 {forbidden} 信号 "
|
||||
f"({lane}, confidence={s.get('confidence')}) "
|
||||
f"因为 trend_direction={trend_direction}"
|
||||
)
|
||||
else:
|
||||
kept.append(s)
|
||||
return kept
|
||||
|
||||
def _infer_signal_grade(self, confidence: float, lane_type: str) -> str:
|
||||
"""根据 lane 规则统一 grade,避免模型随意给等级"""
|
||||
if lane_type == 'medium_term':
|
||||
|
||||
@ -738,6 +738,14 @@ async def status_page():
|
||||
return FileResponse(page_path)
|
||||
return {"message": "页面不存在"}
|
||||
|
||||
@app.get("/console")
|
||||
async def console_page():
|
||||
"""系统总控台页面"""
|
||||
page_path = os.path.join(frontend_path, "console.html")
|
||||
if os.path.exists(page_path):
|
||||
return FileResponse(page_path)
|
||||
return {"message": "页面不存在"}
|
||||
|
||||
@app.get("/hyperliquid")
|
||||
async def hyperliquid_page():
|
||||
"""Hyperliquid 交易监控页面"""
|
||||
|
||||
@ -661,24 +661,75 @@ class BitgetService:
|
||||
premium_rate = 0
|
||||
index_price = float(ticker.get('indexPrice', 0))
|
||||
mark_price = float(ticker.get('markPrice', 0))
|
||||
last_price = float(ticker.get('lastPrice', 0) or 0)
|
||||
bid_price = float(ticker.get('bidPrice', 0) or 0)
|
||||
ask_price = float(ticker.get('askPrice', 0) or 0)
|
||||
high_24h = float(ticker.get('high24h', 0) or 0)
|
||||
low_24h = float(ticker.get('low24h', 0) or 0)
|
||||
base_volume_24h = float(ticker.get('baseVolume', 0) or 0)
|
||||
quote_volume_24h = float(ticker.get('quoteVolume', 0) or 0)
|
||||
price_change_24h_pct = float(
|
||||
ticker.get('price24hPcnt', ticker.get('changeUtc24h', 0)) or 0
|
||||
) * 100
|
||||
oi_change_percent_24h = self._extract_ticker_oi_change_percent(ticker)
|
||||
bid_ask_spread_pct = 0.0
|
||||
range_position_24h = 0.5
|
||||
|
||||
if index_price > 0:
|
||||
premium_rate = ((mark_price - index_price) / index_price * 100)
|
||||
if bid_price > 0 and ask_price > 0:
|
||||
mid_price = (bid_price + ask_price) / 2
|
||||
if mid_price > 0:
|
||||
bid_ask_spread_pct = (ask_price - bid_price) / mid_price * 100
|
||||
if high_24h > low_24h and last_price > 0:
|
||||
range_position_24h = (last_price - low_24h) / (high_24h - low_24h)
|
||||
|
||||
return {
|
||||
'funding_rate': funding_rate,
|
||||
'open_interest': open_interest,
|
||||
'oi_change_percent_24h': oi_change_percent_24h,
|
||||
'premium_rate': premium_rate,
|
||||
'market_sentiment': funding_rate.get('sentiment', ''),
|
||||
'sentiment_level': funding_rate.get('sentiment_level', ''),
|
||||
'mark_price': mark_price,
|
||||
'index_price': index_price
|
||||
'index_price': index_price,
|
||||
'last_price': last_price,
|
||||
'bid_price': bid_price,
|
||||
'ask_price': ask_price,
|
||||
'bid_ask_spread_pct': bid_ask_spread_pct,
|
||||
'high_24h': high_24h,
|
||||
'low_24h': low_24h,
|
||||
'range_position_24h': range_position_24h,
|
||||
'price_change_24h_pct': price_change_24h_pct,
|
||||
'base_volume_24h': base_volume_24h,
|
||||
'quote_volume_24h': quote_volume_24h,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取 {symbol} 合约市场数据失败: {e}")
|
||||
return None
|
||||
|
||||
def _extract_ticker_oi_change_percent(self, ticker: Dict[str, Any]) -> float:
|
||||
"""从 ticker 中兼容提取 OI 24h 变化百分比"""
|
||||
candidates = [
|
||||
ticker.get('openInterestChg'),
|
||||
ticker.get('openInterestChange'),
|
||||
ticker.get('openInterestChangePercent'),
|
||||
ticker.get('oiChange'),
|
||||
ticker.get('oiChangePercent'),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate in (None, ''):
|
||||
continue
|
||||
try:
|
||||
value = float(candidate)
|
||||
if abs(value) <= 2:
|
||||
return value * 100
|
||||
return value
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
def format_futures_data_for_llm(self, symbol: str,
|
||||
market_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
|
||||
@ -424,23 +424,24 @@ class BitgetTradingAPI:
|
||||
try:
|
||||
ccxt_symbol = self._standardize_symbol(symbol)
|
||||
|
||||
# 获取当前持仓
|
||||
positions = self.get_position(symbol)
|
||||
if not positions:
|
||||
logger.warning(f"没有找到 {symbol} 的持仓")
|
||||
result["errors"].append("没有找到持仓")
|
||||
return result
|
||||
|
||||
# 查找有持仓的仓位
|
||||
# 获取当前持仓(重试最多 3 次,间隔 0.5s,等待仓位数据同步)
|
||||
position = None
|
||||
for pos in positions:
|
||||
if float(pos.get('contracts', 0)) != 0:
|
||||
position = pos
|
||||
for attempt in range(3):
|
||||
positions = self.get_position(symbol)
|
||||
for pos in positions:
|
||||
if float(pos.get('contracts', 0)) != 0:
|
||||
position = pos
|
||||
break
|
||||
if position:
|
||||
break
|
||||
if attempt < 2:
|
||||
import time
|
||||
logger.info(f"持仓数据未同步,等待重试 ({attempt + 1}/3)...")
|
||||
time.sleep(0.5)
|
||||
|
||||
if not position:
|
||||
logger.warning(f"{symbol} 持仓数量为 0")
|
||||
result["errors"].append("持仓数量为 0")
|
||||
logger.warning(f"没有找到 {symbol} 的持仓")
|
||||
result["errors"].append("没有找到持仓")
|
||||
return result
|
||||
|
||||
contracts = float(position.get('contracts', 0))
|
||||
|
||||
@ -313,7 +313,8 @@ class HyperliquidTradingService:
|
||||
# Hyperliquid API 不直接返回 reduce_only 标记
|
||||
# 但我们可以根据其他信息判断
|
||||
# 暂时将所有订单都标记为非 reduce_only
|
||||
is_reduce_only = order.get("reduce_only", False)
|
||||
# Hyperliquid API 返回 reduceOnly(驼峰),不是 reduce_only
|
||||
is_reduce_only = order.get("reduceOnly", order.get("reduce_only", False))
|
||||
|
||||
orders.append({
|
||||
"order_id": order.get("oid"),
|
||||
@ -396,54 +397,86 @@ class HyperliquidTradingService:
|
||||
sl_price: 止损价格(可选)
|
||||
|
||||
Returns:
|
||||
执行结果
|
||||
{"success": bool, "tp_set": bool, "sl_set": bool, "errors": [...]}
|
||||
success=True 仅当所有请求的都设置成功
|
||||
"""
|
||||
try:
|
||||
results = []
|
||||
close_is_buy = not is_long # 平多头=卖出,平空头=买入
|
||||
result = {"success": False, "tp_set": False, "sl_set": False, "errors": []}
|
||||
close_is_buy = not is_long # 平多头=卖出,平空头=买入
|
||||
|
||||
# 设置止盈(限价单)
|
||||
if tp_price:
|
||||
# 四舍五入价格到合适精度(避免 float_to_wire rounding 错误)
|
||||
# 设置止盈(限价单)— 独立 try-except,失败不影响止损
|
||||
if tp_price:
|
||||
try:
|
||||
tp_price = round(float(tp_price), 5)
|
||||
|
||||
tp_result = self.exchange.order(
|
||||
symbol, close_is_buy, size, tp_price,
|
||||
{"limit": {"tif": "Gtc"}},
|
||||
reduce_only=True
|
||||
)
|
||||
results.append({"type": "take_profit", "result": tp_result})
|
||||
logger.info(f"✅ 设置止盈: {symbol} @ ${tp_price}")
|
||||
# 验证响应
|
||||
if tp_result.get("status") == "ok":
|
||||
statuses = tp_result.get("response", {}).get("data", {}).get("statuses", [])
|
||||
error_statuses = [s for s in statuses if "error" in s]
|
||||
if error_statuses:
|
||||
err_msg = error_statuses[0]["error"]
|
||||
logger.warning(f"设置止盈失败: {symbol} {err_msg}")
|
||||
result["errors"].append(f"止盈设置失败: {err_msg}")
|
||||
else:
|
||||
result["tp_set"] = True
|
||||
logger.info(f"✅ 设置止盈: {symbol} @ ${tp_price}")
|
||||
else:
|
||||
err_msg = tp_result.get("response", str(tp_result))
|
||||
logger.warning(f"设置止盈失败: {symbol} {err_msg}")
|
||||
result["errors"].append(f"止盈设置失败: {err_msg}")
|
||||
except Exception as e:
|
||||
logger.warning(f"设置止盈失败: {symbol} {e}")
|
||||
result["errors"].append(f"止盈设置失败: {e}")
|
||||
|
||||
# 设置止损(触发单)
|
||||
if sl_price:
|
||||
# 触发价格需要稍微偏离(避免滑点问题)
|
||||
exec_px = sl_price * 0.999 if close_is_buy else sl_price * 1.001
|
||||
|
||||
# 四舍五入价格到合适精度(避免 float_to_wire rounding 错误)
|
||||
# Hyperliquid 要求价格最多 5 位小数
|
||||
# 设置止损(触发单)— 独立 try-except,失败不影响止盈
|
||||
if sl_price:
|
||||
try:
|
||||
# 买单止损:exec_px 略高于 trigger(接受更高的买入价)
|
||||
# 卖单止损:exec_px 略低于 trigger(接受更低的卖出价)
|
||||
exec_px = sl_price * 1.001 if close_is_buy else sl_price * 0.999
|
||||
sl_price = round(float(sl_price), 5)
|
||||
exec_px = round(float(exec_px), 5)
|
||||
|
||||
sl_result = self.exchange.order(
|
||||
symbol, close_is_buy, size, exec_px,
|
||||
{"trigger": {"triggerPx": sl_price, "isMarket": True, "tpsl": "sl"}},
|
||||
reduce_only=True
|
||||
)
|
||||
results.append({"type": "stop_loss", "result": sl_result})
|
||||
logger.info(f"✅ 设置止损: {symbol} @ ${sl_price}(触发)")
|
||||
# 验证响应
|
||||
if sl_result.get("status") == "ok":
|
||||
statuses = sl_result.get("response", {}).get("data", {}).get("statuses", [])
|
||||
error_statuses = [s for s in statuses if "error" in s]
|
||||
if error_statuses:
|
||||
err_msg = error_statuses[0]["error"]
|
||||
logger.warning(f"设置止损失败: {symbol} {err_msg}")
|
||||
result["errors"].append(f"止损设置失败: {err_msg}")
|
||||
else:
|
||||
result["sl_set"] = True
|
||||
logger.info(f"✅ 设置止损: {symbol} @ ${sl_price}(触发)")
|
||||
else:
|
||||
err_msg = sl_result.get("response", str(sl_result))
|
||||
logger.warning(f"设置止损失败: {symbol} {err_msg}")
|
||||
result["errors"].append(f"止损设置失败: {err_msg}")
|
||||
except Exception as e:
|
||||
logger.warning(f"设置止损失败: {symbol} {e}")
|
||||
result["errors"].append(f"止损设置失败: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"results": results
|
||||
}
|
||||
# 判断整体成功
|
||||
requested_tp = tp_price is not None
|
||||
requested_sl = sl_price is not None
|
||||
all_ok = (not requested_tp or result["tp_set"]) and (not requested_sl or result["sl_set"])
|
||||
result["success"] = all_ok
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"设置止盈止损失败: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
if all_ok:
|
||||
logger.info(f"✅ 止盈止损设置完成: {symbol} TP={tp_price} SL={sl_price}")
|
||||
elif result["tp_set"] or result["sl_set"]:
|
||||
logger.warning(f"⚠️ 止盈止损部分成功: {symbol} tp_set={result['tp_set']} sl_set={result['sl_set']}")
|
||||
else:
|
||||
logger.error(f"❌ 止盈止损设置失败: {symbol} errors={result['errors']}")
|
||||
|
||||
return result
|
||||
|
||||
def cancel_tp_sl_orders(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
193
backend/tests/test_crypto_agent_platform_halts.py
Normal file
193
backend/tests/test_crypto_agent_platform_halts.py
Normal file
@ -0,0 +1,193 @@
|
||||
"""
|
||||
CryptoAgent 平台熔断回归测试
|
||||
|
||||
覆盖重点:
|
||||
- 账户级止损只暂停单个平台,不再要求全局停机
|
||||
- 手动恢复平台会重置该平台初始权益基线
|
||||
"""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
|
||||
def load_crypto_agent_class():
|
||||
agent_path = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'crypto_agent.py'
|
||||
|
||||
if 'app' not in sys.modules:
|
||||
app_pkg = types.ModuleType('app')
|
||||
app_pkg.__path__ = [str(agent_path.parents[2] / 'app')]
|
||||
sys.modules['app'] = app_pkg
|
||||
|
||||
for pkg_name, pkg_path in [
|
||||
('app.crypto_agent', agent_path.parent),
|
||||
('app.services', agent_path.parents[1] / 'services'),
|
||||
('app.utils', agent_path.parents[1] / 'utils'),
|
||||
]:
|
||||
if pkg_name not in sys.modules:
|
||||
pkg = types.ModuleType(pkg_name)
|
||||
pkg.__path__ = [str(pkg_path)]
|
||||
sys.modules[pkg_name] = pkg
|
||||
|
||||
logger_module = types.ModuleType('app.utils.logger')
|
||||
logger_module.logger = MagicMock()
|
||||
sys.modules['app.utils.logger'] = logger_module
|
||||
|
||||
config_module = types.ModuleType('app.config')
|
||||
config_module.get_settings = MagicMock()
|
||||
sys.modules['app.config'] = config_module
|
||||
|
||||
bitget_service_module = types.ModuleType('app.services.bitget_service')
|
||||
bitget_service_module.bitget_service = MagicMock()
|
||||
sys.modules['app.services.bitget_service'] = bitget_service_module
|
||||
|
||||
feishu_module = types.ModuleType('app.services.feishu_service')
|
||||
feishu_module.get_feishu_service = MagicMock()
|
||||
feishu_module.get_feishu_paper_trading_service = MagicMock()
|
||||
sys.modules['app.services.feishu_service'] = feishu_module
|
||||
|
||||
telegram_module = types.ModuleType('app.services.telegram_service')
|
||||
telegram_module.get_telegram_service = MagicMock()
|
||||
sys.modules['app.services.telegram_service'] = telegram_module
|
||||
|
||||
dingtalk_module = types.ModuleType('app.services.dingtalk_service')
|
||||
dingtalk_module.get_dingtalk_service = MagicMock()
|
||||
sys.modules['app.services.dingtalk_service'] = dingtalk_module
|
||||
|
||||
paper_module = types.ModuleType('app.services.paper_trading_service')
|
||||
paper_module.get_paper_trading_service = MagicMock()
|
||||
sys.modules['app.services.paper_trading_service'] = paper_module
|
||||
|
||||
signal_db_module = types.ModuleType('app.services.signal_database_service')
|
||||
signal_db_module.get_signal_db_service = MagicMock()
|
||||
sys.modules['app.services.signal_database_service'] = signal_db_module
|
||||
|
||||
position_sizing_module = types.ModuleType('app.services.position_sizing')
|
||||
position_sizing_module.DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME = {}
|
||||
position_sizing_module.DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS = {}
|
||||
position_sizing_module.calculate_margin_and_position_value = MagicMock()
|
||||
position_sizing_module.resolve_target_margin_pct = MagicMock()
|
||||
sys.modules['app.services.position_sizing'] = position_sizing_module
|
||||
|
||||
market_analyzer_module = types.ModuleType('app.crypto_agent.market_signal_analyzer')
|
||||
market_analyzer_module.MarketSignalAnalyzer = MagicMock()
|
||||
sys.modules['app.crypto_agent.market_signal_analyzer'] = market_analyzer_module
|
||||
|
||||
system_status_module = types.ModuleType('app.utils.system_status')
|
||||
system_status_module.get_system_monitor = MagicMock()
|
||||
system_status_module.AgentStatus = types.SimpleNamespace(RUNNING='running', STOPPED='stopped')
|
||||
sys.modules['app.utils.system_status'] = system_status_module
|
||||
|
||||
module_name = 'app.crypto_agent.crypto_agent_test'
|
||||
spec = importlib.util.spec_from_file_location(module_name, agent_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module.CryptoAgent
|
||||
|
||||
|
||||
def make_agent():
|
||||
CryptoAgent = load_crypto_agent_class()
|
||||
agent = CryptoAgent.__new__(CryptoAgent)
|
||||
agent.settings = types.SimpleNamespace(account_max_drawdown=0.25, account_drawdown_alert=0.15)
|
||||
agent.paper_trading = None
|
||||
agent.hyperliquid = None
|
||||
agent.bitget = None
|
||||
agent.symbols = ['BTCUSDT']
|
||||
agent.executors = {}
|
||||
agent._platform_halts = {}
|
||||
from collections import deque
|
||||
agent._execution_events = deque(maxlen=120)
|
||||
agent._initial_balances = {}
|
||||
agent._save_platform_halts = MagicMock()
|
||||
agent._save_initial_balances = MagicMock()
|
||||
agent._send_alert_notification = AsyncMock()
|
||||
agent._emergency_close_all_positions = AsyncMock()
|
||||
return agent
|
||||
|
||||
|
||||
def test_account_stop_loss_halts_only_triggered_platform():
|
||||
agent = make_agent()
|
||||
bitget = MagicMock()
|
||||
bitget.get_account_state.return_value = {
|
||||
'account_value': 700.0,
|
||||
'current_balance': 700.0,
|
||||
}
|
||||
agent.bitget = bitget
|
||||
agent._get_risk_platforms = MagicMock(return_value=[('Bitget', bitget)])
|
||||
agent._get_initial_balance = MagicMock(return_value=1000.0)
|
||||
|
||||
should_stop, reason = asyncio.run(agent._check_account_level_stop_loss())
|
||||
|
||||
assert should_stop is True
|
||||
assert 'Bitget' in reason
|
||||
assert agent._platform_halts['Bitget']['halted'] is True
|
||||
agent._emergency_close_all_positions.assert_awaited_once()
|
||||
|
||||
|
||||
def test_resume_platform_resets_initial_balance_and_clears_halt():
|
||||
agent = make_agent()
|
||||
bitget = MagicMock()
|
||||
bitget.get_account_state.return_value = {
|
||||
'account_value': 888.0,
|
||||
'current_balance': 888.0,
|
||||
}
|
||||
agent.bitget = bitget
|
||||
agent._platform_halts = {
|
||||
'Bitget': {
|
||||
'halted': True,
|
||||
'reason': 'drawdown',
|
||||
'drawdown_pct': 25.1,
|
||||
}
|
||||
}
|
||||
|
||||
result = agent.resume_platform('Bitget')
|
||||
|
||||
assert result['halted'] is False
|
||||
assert agent._initial_balances['Bitget'] == 888.0
|
||||
assert result['initial_balance'] == 888.0
|
||||
assert result['current_balance'] == 888.0
|
||||
|
||||
|
||||
def test_execution_events_are_recorded_and_returned_in_reverse_time_order():
|
||||
agent = make_agent()
|
||||
|
||||
agent._record_execution_event('Bitget', 'open_failed', symbol='ETHUSDT', reason='余额不足', status='error')
|
||||
agent._record_execution_event('Hyperliquid', 'hold', symbol='BTCUSDT', reason='已有盈利反向仓', status='hold')
|
||||
|
||||
events = agent.get_recent_execution_events(limit=10)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0]['platform'] == 'Hyperliquid'
|
||||
assert events[0]['event_type'] == 'hold'
|
||||
assert events[1]['platform'] == 'Bitget'
|
||||
assert events[1]['reason'] == '余额不足'
|
||||
|
||||
|
||||
def test_get_status_contains_last_execution_preview():
|
||||
agent = make_agent()
|
||||
agent.running = True
|
||||
agent.symbols = ['BTCUSDT']
|
||||
agent.last_signals = {
|
||||
'BTCUSDT': {'type': 'medium_term', 'action': 'sell', 'confidence': 78, 'grade': 'B'}
|
||||
}
|
||||
agent.last_execution_preview = {
|
||||
'BTCUSDT': {
|
||||
'timestamp': '2026-04-22T12:00:00',
|
||||
'current_price': 65000.0,
|
||||
'paper': {'decision': 'OPEN', 'reason': '正常开仓'},
|
||||
'hyperliquid': {'decision': 'HOLD', 'reason': '无适配信号'},
|
||||
'bitget': {'decision': 'CANCEL_PENDING', 'reason': '替换旧挂单'},
|
||||
}
|
||||
}
|
||||
|
||||
status = agent.get_status()
|
||||
|
||||
assert status['last_execution_preview']['BTCUSDT']['paper']['decision'] == 'OPEN'
|
||||
assert status['last_execution_preview']['BTCUSDT']['bitget']['reason'] == '替换旧挂单'
|
||||
439
backend/tests/test_crypto_agent_signal_execution_coordination.py
Normal file
439
backend/tests/test_crypto_agent_signal_execution_coordination.py
Normal file
@ -0,0 +1,439 @@
|
||||
"""
|
||||
CryptoAgent 信号到执行层协同回归测试
|
||||
|
||||
覆盖重点:
|
||||
- reduce-only 的止盈止损挂单不应参与新开仓决策
|
||||
- 同向 limit 信号在已有旧挂单时,优先替换更优挂单
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
|
||||
def load_crypto_agent_class():
|
||||
agent_path = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'crypto_agent.py'
|
||||
|
||||
if 'app' not in sys.modules:
|
||||
app_pkg = types.ModuleType('app')
|
||||
app_pkg.__path__ = [str(agent_path.parents[2] / 'app')]
|
||||
sys.modules['app'] = app_pkg
|
||||
|
||||
for pkg_name, pkg_path in [
|
||||
('app.crypto_agent', agent_path.parent),
|
||||
('app.services', agent_path.parents[1] / 'services'),
|
||||
('app.utils', agent_path.parents[1] / 'utils'),
|
||||
]:
|
||||
if pkg_name not in sys.modules:
|
||||
pkg = types.ModuleType(pkg_name)
|
||||
pkg.__path__ = [str(pkg_path)]
|
||||
sys.modules[pkg_name] = pkg
|
||||
|
||||
logger_module = types.ModuleType('app.utils.logger')
|
||||
logger_module.logger = MagicMock()
|
||||
sys.modules['app.utils.logger'] = logger_module
|
||||
|
||||
config_module = types.ModuleType('app.config')
|
||||
config_module.get_settings = MagicMock()
|
||||
sys.modules['app.config'] = config_module
|
||||
|
||||
bitget_service_module = types.ModuleType('app.services.bitget_service')
|
||||
bitget_service_module.bitget_service = MagicMock()
|
||||
sys.modules['app.services.bitget_service'] = bitget_service_module
|
||||
|
||||
feishu_module = types.ModuleType('app.services.feishu_service')
|
||||
feishu_module.get_feishu_service = MagicMock()
|
||||
feishu_module.get_feishu_paper_trading_service = MagicMock()
|
||||
sys.modules['app.services.feishu_service'] = feishu_module
|
||||
|
||||
telegram_module = types.ModuleType('app.services.telegram_service')
|
||||
telegram_module.get_telegram_service = MagicMock()
|
||||
sys.modules['app.services.telegram_service'] = telegram_module
|
||||
|
||||
dingtalk_module = types.ModuleType('app.services.dingtalk_service')
|
||||
dingtalk_module.get_dingtalk_service = MagicMock()
|
||||
sys.modules['app.services.dingtalk_service'] = dingtalk_module
|
||||
|
||||
paper_module = types.ModuleType('app.services.paper_trading_service')
|
||||
paper_module.get_paper_trading_service = MagicMock()
|
||||
sys.modules['app.services.paper_trading_service'] = paper_module
|
||||
|
||||
signal_db_module = types.ModuleType('app.services.signal_database_service')
|
||||
signal_db_module.get_signal_db_service = MagicMock()
|
||||
sys.modules['app.services.signal_database_service'] = signal_db_module
|
||||
|
||||
position_sizing_module = types.ModuleType('app.services.position_sizing')
|
||||
position_sizing_module.DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME = {}
|
||||
position_sizing_module.DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS = {}
|
||||
position_sizing_module.calculate_margin_and_position_value = MagicMock()
|
||||
position_sizing_module.resolve_target_margin_pct = MagicMock()
|
||||
sys.modules['app.services.position_sizing'] = position_sizing_module
|
||||
|
||||
market_analyzer_module = types.ModuleType('app.crypto_agent.market_signal_analyzer')
|
||||
market_analyzer_module.MarketSignalAnalyzer = MagicMock()
|
||||
sys.modules['app.crypto_agent.market_signal_analyzer'] = market_analyzer_module
|
||||
|
||||
system_status_module = types.ModuleType('app.utils.system_status')
|
||||
system_status_module.get_system_monitor = MagicMock()
|
||||
system_status_module.AgentStatus = types.SimpleNamespace(RUNNING='running', STOPPED='stopped')
|
||||
sys.modules['app.utils.system_status'] = system_status_module
|
||||
|
||||
module_name = 'app.crypto_agent.crypto_agent_signal_exec_test'
|
||||
spec = importlib.util.spec_from_file_location(module_name, agent_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module.CryptoAgent
|
||||
|
||||
|
||||
def make_agent():
|
||||
CryptoAgent = load_crypto_agent_class()
|
||||
agent = CryptoAgent.__new__(CryptoAgent)
|
||||
agent.SIGNAL_POSITION_SIZE_DEFAULTS = {}
|
||||
agent.SIGNAL_MARGIN_MULTIPLIERS = {}
|
||||
agent.PLATFORM_RULES = {'Bitget': {'min_margin': {}, 'max_margin_pct': 0.25}}
|
||||
agent._check_losing_streak = MagicMock(return_value={'should_cool_down': False})
|
||||
agent._calculate_position_size = MagicMock(return_value=(100.0, 'ok'))
|
||||
return agent
|
||||
|
||||
|
||||
def test_reduce_only_pending_orders_do_not_block_new_open_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': 78,
|
||||
'timeframe': 'medium_term',
|
||||
'position_size': 'medium',
|
||||
'funding_rate_data': {'funding_rate_percent': 0.01},
|
||||
}
|
||||
account = {
|
||||
'current_total_leverage': 0,
|
||||
'max_total_leverage': 10,
|
||||
'available': 1000,
|
||||
}
|
||||
positions = []
|
||||
pending_orders = [
|
||||
{
|
||||
'order_id': 'tp-1',
|
||||
'symbol': 'BTCUSDT',
|
||||
'side': 'sell',
|
||||
'entry_price': 104.0,
|
||||
'is_reduce_only': True,
|
||||
}
|
||||
]
|
||||
|
||||
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, pending_orders)
|
||||
|
||||
assert decision['decision'] == 'OPEN'
|
||||
assert decision['margin'] == 100.0
|
||||
|
||||
|
||||
def test_same_direction_better_limit_order_replaces_old_pending_order():
|
||||
agent = make_agent()
|
||||
|
||||
signal = {
|
||||
'symbol': 'BTCUSDT',
|
||||
'action': 'buy',
|
||||
'entry_type': 'limit',
|
||||
'entry_price': 95.0,
|
||||
'current_price': 100.0,
|
||||
'stop_loss': 92.0,
|
||||
'take_profit': 104.0,
|
||||
'confidence': 80,
|
||||
'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,
|
||||
}
|
||||
positions = []
|
||||
pending_orders = [
|
||||
{
|
||||
'order_id': 'old-1',
|
||||
'symbol': 'BTCUSDT',
|
||||
'side': 'buy',
|
||||
'entry_price': 98.0,
|
||||
'entry_type': 'limit',
|
||||
'is_reduce_only': False,
|
||||
'created_at': '2026-04-22T10:00:00',
|
||||
}
|
||||
]
|
||||
|
||||
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, pending_orders)
|
||||
|
||||
assert decision['decision'] == 'CANCEL_PENDING'
|
||||
assert decision['orders_to_cancel'] == ['old-1']
|
||||
assert decision['next_decision']['decision'] == 'OPEN'
|
||||
assert decision['next_decision']['signal_action'] == 'buy'
|
||||
|
||||
|
||||
def test_opposite_position_uses_current_price_to_protect_profitable_medium_term_position():
|
||||
agent = make_agent()
|
||||
|
||||
signal = {
|
||||
'symbol': 'BTCUSDT',
|
||||
'action': 'buy',
|
||||
'entry_type': 'limit',
|
||||
'entry_price': 100.0,
|
||||
'current_price': 94.0,
|
||||
'stop_loss': 97.0,
|
||||
'take_profit': 106.0,
|
||||
'confidence': 88,
|
||||
'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,
|
||||
}
|
||||
positions = [
|
||||
{
|
||||
'symbol': 'BTCUSDT',
|
||||
'side': 'sell',
|
||||
'entry_price': 100.0,
|
||||
'take_profit': 92.0,
|
||||
}
|
||||
]
|
||||
|
||||
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, [])
|
||||
|
||||
assert decision['decision'] == 'HOLD'
|
||||
assert decision['action'] == 'HOLD'
|
||||
assert '反向持仓盈利' in decision['reason']
|
||||
|
||||
|
||||
def test_short_term_super_strong_signal_can_flip_when_opposite_profit_is_small():
|
||||
agent = make_agent()
|
||||
|
||||
signal = {
|
||||
'symbol': 'BTCUSDT',
|
||||
'action': 'buy',
|
||||
'entry_type': 'market',
|
||||
'entry_price': 100.0,
|
||||
'current_price': 99.5,
|
||||
'stop_loss': 99.0,
|
||||
'take_profit': 101.8,
|
||||
'confidence': 95,
|
||||
'timeframe': 'short_term',
|
||||
'type': 'short_term',
|
||||
'position_size': 'light',
|
||||
'funding_rate_data': {'funding_rate_percent': 0.01},
|
||||
}
|
||||
account = {
|
||||
'current_total_leverage': 0,
|
||||
'max_total_leverage': 10,
|
||||
'available': 1000,
|
||||
}
|
||||
positions = [
|
||||
{
|
||||
'symbol': 'BTCUSDT',
|
||||
'side': 'sell',
|
||||
'entry_price': 100.0,
|
||||
}
|
||||
]
|
||||
|
||||
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, [])
|
||||
|
||||
assert decision['decision'] == 'FLIP'
|
||||
|
||||
|
||||
def test_protected_same_direction_position_will_not_add_even_if_signal_price_is_better():
|
||||
agent = make_agent()
|
||||
|
||||
signal = {
|
||||
'symbol': 'BTCUSDT',
|
||||
'action': 'buy',
|
||||
'entry_type': 'limit',
|
||||
'entry_price': 97.0,
|
||||
'current_price': 101.0,
|
||||
'stop_loss': 95.0,
|
||||
'take_profit': 108.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,
|
||||
}
|
||||
positions = [
|
||||
{
|
||||
'symbol': 'BTCUSDT',
|
||||
'side': 'buy',
|
||||
'entry_price': 100.0,
|
||||
'stop_loss': 100.1,
|
||||
'take_profit': 108.0,
|
||||
}
|
||||
]
|
||||
|
||||
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, [])
|
||||
|
||||
assert decision['decision'] == 'HOLD'
|
||||
assert '保本/保护态' in decision['reason']
|
||||
|
||||
|
||||
def test_middle_of_range_signal_does_not_replace_existing_pending_order():
|
||||
agent = make_agent()
|
||||
|
||||
signal = {
|
||||
'symbol': 'BTCUSDT',
|
||||
'action': 'buy',
|
||||
'entry_type': 'limit',
|
||||
'entry_price': 95.0,
|
||||
'current_price': 100.0,
|
||||
'stop_loss': 92.0,
|
||||
'take_profit': 104.0,
|
||||
'confidence': 80,
|
||||
'timeframe': 'medium_term',
|
||||
'type': 'medium_term',
|
||||
'position_size': 'medium',
|
||||
'market_location': {'location_tag': 'middle_of_range'},
|
||||
'funding_rate_data': {'funding_rate_percent': 0.01},
|
||||
}
|
||||
account = {
|
||||
'current_total_leverage': 0,
|
||||
'max_total_leverage': 10,
|
||||
'available': 1000,
|
||||
}
|
||||
pending_orders = [
|
||||
{
|
||||
'order_id': 'old-1',
|
||||
'symbol': 'BTCUSDT',
|
||||
'side': 'buy',
|
||||
'entry_price': 98.0,
|
||||
'entry_type': 'limit',
|
||||
'is_reduce_only': False,
|
||||
'created_at': '2026-04-22T10:00:00',
|
||||
}
|
||||
]
|
||||
|
||||
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], pending_orders)
|
||||
|
||||
assert decision['decision'] == 'OPEN'
|
||||
|
||||
|
||||
def test_between_trade_zones_medium_term_signal_does_not_replace_pending_order():
|
||||
agent = make_agent()
|
||||
|
||||
signal = {
|
||||
'symbol': 'BTCUSDT',
|
||||
'action': 'buy',
|
||||
'entry_type': 'limit',
|
||||
'entry_price': 95.0,
|
||||
'current_price': 100.0,
|
||||
'stop_loss': 92.0,
|
||||
'take_profit': 104.0,
|
||||
'confidence': 80,
|
||||
'timeframe': 'medium_term',
|
||||
'type': 'medium_term',
|
||||
'position_size': 'medium',
|
||||
'market_location': {
|
||||
'location_tag': 'between_trade_zones',
|
||||
'distance_to_best_long_zone_pct': 0.7,
|
||||
},
|
||||
'funding_rate_data': {'funding_rate_percent': 0.01},
|
||||
}
|
||||
account = {
|
||||
'current_total_leverage': 0,
|
||||
'max_total_leverage': 10,
|
||||
'available': 1000,
|
||||
}
|
||||
pending_orders = [
|
||||
{
|
||||
'order_id': 'old-1',
|
||||
'symbol': 'BTCUSDT',
|
||||
'side': 'buy',
|
||||
'entry_price': 98.0,
|
||||
'entry_type': 'limit',
|
||||
'is_reduce_only': False,
|
||||
'created_at': '2026-04-22T10:00:00',
|
||||
}
|
||||
]
|
||||
|
||||
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], pending_orders)
|
||||
|
||||
assert decision['decision'] == 'OPEN'
|
||||
|
||||
|
||||
def test_between_trade_zones_short_term_signal_can_replace_pending_order_when_near_zone():
|
||||
agent = make_agent()
|
||||
|
||||
signal = {
|
||||
'symbol': 'BTCUSDT',
|
||||
'action': 'buy',
|
||||
'entry_type': 'limit',
|
||||
'entry_price': 97.5,
|
||||
'current_price': 100.0,
|
||||
'stop_loss': 96.5,
|
||||
'take_profit': 100.5,
|
||||
'confidence': 93,
|
||||
'timeframe': 'short_term',
|
||||
'type': 'short_term',
|
||||
'position_size': 'light',
|
||||
'market_location': {
|
||||
'location_tag': 'between_trade_zones',
|
||||
'distance_to_best_long_zone_pct': 0.6,
|
||||
},
|
||||
'funding_rate_data': {'funding_rate_percent': 0.01},
|
||||
}
|
||||
account = {
|
||||
'current_total_leverage': 0,
|
||||
'max_total_leverage': 10,
|
||||
'available': 1000,
|
||||
}
|
||||
pending_orders = [
|
||||
{
|
||||
'order_id': 'old-1',
|
||||
'symbol': 'BTCUSDT',
|
||||
'side': 'buy',
|
||||
'entry_price': 99.5,
|
||||
'entry_type': 'limit',
|
||||
'is_reduce_only': False,
|
||||
'created_at': '2026-04-22T10:00:00',
|
||||
}
|
||||
]
|
||||
|
||||
decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], pending_orders)
|
||||
|
||||
assert decision['decision'] == 'CANCEL_PENDING'
|
||||
assert decision['orders_to_cancel'] == ['old-1']
|
||||
|
||||
|
||||
def test_runtime_position_state_derives_protection_and_remaining_target():
|
||||
agent = make_agent()
|
||||
|
||||
position = agent._build_runtime_position_state({
|
||||
'symbol': 'BTCUSDT',
|
||||
'side': 'buy',
|
||||
'entry_price': 100.0,
|
||||
'mark_price': 105.0,
|
||||
'stop_loss': 100.2,
|
||||
'take_profit': 112.0,
|
||||
'opened_at': '2026-04-22T08:00:00',
|
||||
})
|
||||
|
||||
assert position['unrealized_pnl_pct'] == 5.0
|
||||
assert round(position['remaining_tp_pct'], 4) == round((112.0 - 105.0) / 105.0 * 100, 4)
|
||||
assert position['is_protected'] is True
|
||||
assert position['holding_hours'] >= 0
|
||||
@ -77,6 +77,41 @@ def load_bitget_executor_class():
|
||||
return sys.modules['app.crypto_agent.executor.bitget_executor'].BitgetExecutor
|
||||
|
||||
|
||||
def load_hyperliquid_executor_class():
|
||||
"""按文件加载执行器,避免触发 app.crypto_agent.__init__ 的重依赖"""
|
||||
executor_dir = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'executor'
|
||||
|
||||
if 'app.crypto_agent' not in sys.modules:
|
||||
crypto_pkg = types.ModuleType('app.crypto_agent')
|
||||
crypto_pkg.__path__ = [str(executor_dir.parent)]
|
||||
sys.modules['app.crypto_agent'] = crypto_pkg
|
||||
|
||||
if 'app.crypto_agent.executor' not in sys.modules:
|
||||
executor_pkg = types.ModuleType('app.crypto_agent.executor')
|
||||
executor_pkg.__path__ = [str(executor_dir)]
|
||||
sys.modules['app.crypto_agent.executor'] = executor_pkg
|
||||
|
||||
if 'app.crypto_agent.executor.base_executor' not in sys.modules:
|
||||
base_spec = importlib.util.spec_from_file_location(
|
||||
'app.crypto_agent.executor.base_executor',
|
||||
executor_dir / 'base_executor.py',
|
||||
)
|
||||
base_module = importlib.util.module_from_spec(base_spec)
|
||||
sys.modules[base_spec.name] = base_module
|
||||
base_spec.loader.exec_module(base_module)
|
||||
|
||||
if 'app.crypto_agent.executor.hyperliquid_executor' not in sys.modules:
|
||||
executor_spec = importlib.util.spec_from_file_location(
|
||||
'app.crypto_agent.executor.hyperliquid_executor',
|
||||
executor_dir / 'hyperliquid_executor.py',
|
||||
)
|
||||
executor_module = importlib.util.module_from_spec(executor_spec)
|
||||
sys.modules[executor_spec.name] = executor_module
|
||||
executor_spec.loader.exec_module(executor_module)
|
||||
|
||||
return sys.modules['app.crypto_agent.executor.hyperliquid_executor'].HyperliquidExecutor
|
||||
|
||||
|
||||
def test_bitget_market_close_position_only_closes_requested_symbol():
|
||||
service, mock_api = make_bitget_service()
|
||||
mock_api.get_position.return_value = [
|
||||
@ -193,3 +228,48 @@ def test_bitget_executor_open_uses_actual_leverage_for_contracts():
|
||||
assert result['success'] is True
|
||||
executor.bitget.update_leverage.assert_called_once_with('ETH', 10)
|
||||
executor.bitget.place_market_order.assert_called_once_with('ETH', is_buy=True, size=1)
|
||||
|
||||
|
||||
def test_hyperliquid_executor_open_uses_decision_margin_not_account_value():
|
||||
HyperliquidExecutor = load_hyperliquid_executor_class()
|
||||
|
||||
executor = HyperliquidExecutor.__new__(HyperliquidExecutor)
|
||||
executor.hyperliquid = MagicMock()
|
||||
executor.send_execution_notification = AsyncMock()
|
||||
executor.decide_order_type = MagicMock(return_value=('market', 'test'))
|
||||
executor.calculate_effective_margin = MagicMock(return_value=120.0)
|
||||
executor.hyperliquid.get_account_state.return_value = {
|
||||
'available_balance': 5000.0,
|
||||
'account_value': 50000.0,
|
||||
}
|
||||
executor.hyperliquid.get_sz_decimals.return_value = 3
|
||||
executor.hyperliquid.place_market_order.return_value = {
|
||||
'success': True,
|
||||
'order_id': 'oid-hl-1',
|
||||
'order_status': 'filled',
|
||||
}
|
||||
executor.hyperliquid.set_tp_sl.return_value = {'tp_set': True, 'sl_set': True}
|
||||
|
||||
result = asyncio.run(
|
||||
executor.execute_open(
|
||||
{
|
||||
'symbol': 'ETHUSDT',
|
||||
'action': 'buy',
|
||||
'margin': 120.0,
|
||||
'entry_price': 2000.0,
|
||||
'stop_loss': 1980.0,
|
||||
'take_profit': 2060.0,
|
||||
'leverage': 10,
|
||||
},
|
||||
2000.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert result['success'] is True
|
||||
executor.hyperliquid.update_leverage.assert_called_once_with('ETH', 10)
|
||||
executor.hyperliquid.place_market_order.assert_called_once_with(
|
||||
symbol='ETH',
|
||||
is_buy=True,
|
||||
size=0.6,
|
||||
reduce_only=False,
|
||||
)
|
||||
|
||||
369
backend/tests/test_hyperliquid_live_integration.py
Normal file
369
backend/tests/test_hyperliquid_live_integration.py
Normal file
@ -0,0 +1,369 @@
|
||||
"""
|
||||
Hyperliquid 真实 API 集成测试
|
||||
|
||||
⚠️ 警告:此测试会使用真实 API 调用和真实订单!
|
||||
- 使用最小下单量(ETH,szDecimals=3)
|
||||
- 市价单会立即成交,产生实际盈亏
|
||||
- 测试后自动清理所有订单和持仓
|
||||
|
||||
覆盖接口:
|
||||
- 账户状态查询
|
||||
- 杠杆设置
|
||||
- 持仓查询
|
||||
- 市价开仓
|
||||
- 止盈止损设置(TP limit 单 + SL trigger 单)
|
||||
- 止盈止损验证(读取挂单确认 TP 和 SL 都存在)
|
||||
- 市价平仓
|
||||
|
||||
运行方式:
|
||||
cd backend
|
||||
python3 tests/test_hyperliquid_live_integration.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
|
||||
|
||||
|
||||
# ==================== 测试配置 ====================
|
||||
|
||||
TEST_SYMBOL = 'ETH' # ETH 精度好(szDecimals=3),手续费低
|
||||
TEST_SIZE = 0.01 # 最小下单量
|
||||
TEST_LEVERAGE = 5 # 测试杠杆倍数
|
||||
|
||||
|
||||
class TestResult:
|
||||
"""测试结果收集器"""
|
||||
|
||||
def __init__(self):
|
||||
self.results = []
|
||||
|
||||
def record(self, name: str, passed: bool, detail: str = ""):
|
||||
self.results.append((name, passed, detail))
|
||||
status = "✅ PASS" if passed else "❌ FAIL"
|
||||
print(f" {status}: {name}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
|
||||
def summary(self):
|
||||
print(f"\n{'='*60}")
|
||||
print("测试结果汇总")
|
||||
print(f"{'='*60}")
|
||||
passed = sum(1 for _, p, _ in self.results if p)
|
||||
total = len(self.results)
|
||||
for name, p, detail in self.results:
|
||||
status = "✅" if p else "❌"
|
||||
line = f" {status} {name}"
|
||||
if not p and detail:
|
||||
line += f" — {detail}"
|
||||
print(line)
|
||||
print(f"\n 总计: {passed}/{total} 通过")
|
||||
print(f"{'='*60}")
|
||||
return passed == total
|
||||
|
||||
|
||||
# ==================== 测试函数 ====================
|
||||
|
||||
def test_account_state(service, r: TestResult):
|
||||
"""查询账户状态"""
|
||||
try:
|
||||
state = service.get_account_state()
|
||||
av = state['account_value']
|
||||
ab = state['available_balance']
|
||||
r.record(
|
||||
"查询账户状态",
|
||||
av > 0,
|
||||
f"权益=${av:,.2f}, 可用=${ab:,.2f}"
|
||||
)
|
||||
except Exception as e:
|
||||
r.record("查询账户状态", False, str(e))
|
||||
|
||||
|
||||
def test_update_leverage(service, r: TestResult):
|
||||
"""设置杠杆"""
|
||||
try:
|
||||
result = service.update_leverage(TEST_SYMBOL, TEST_LEVERAGE)
|
||||
r.record("设置杠杆", True, f"{TEST_SYMBOL} → {TEST_LEVERAGE}x")
|
||||
except Exception as e:
|
||||
r.record("设置杠杆", False, str(e))
|
||||
|
||||
|
||||
def test_get_positions(service, r: TestResult):
|
||||
"""查询持仓"""
|
||||
try:
|
||||
positions = service.get_open_positions()
|
||||
count = len(positions)
|
||||
r.record("查询持仓", True, f"当前活跃持仓: {count} 个")
|
||||
except Exception as e:
|
||||
r.record("查询持仓", False, str(e))
|
||||
|
||||
|
||||
def test_market_order_with_tp_sl(service, r: TestResult):
|
||||
"""市价开仓 → 设置止盈止损 → 验证 → 平仓"""
|
||||
opened = False
|
||||
try:
|
||||
# 0. 先清理已有持仓和挂单
|
||||
try:
|
||||
service.cancel_all_orders(TEST_SYMBOL)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
pos = service.get_position_for_symbol(TEST_SYMBOL)
|
||||
if pos:
|
||||
service.market_close_position(TEST_SYMBOL)
|
||||
time.sleep(1)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 1. 获取当前价格
|
||||
all_mids = service.info.all_mids()
|
||||
current_price = float(all_mids.get(TEST_SYMBOL, 0))
|
||||
if current_price <= 0:
|
||||
r.record("获取当前价格", False, f"无法获取 {TEST_SYMBOL} 价格")
|
||||
return
|
||||
|
||||
print(f"\n 当前 {TEST_SYMBOL}: ${current_price:,.2f}")
|
||||
|
||||
# 2. 计算最小下单量
|
||||
sz_decimals = service.get_sz_decimals(TEST_SYMBOL)
|
||||
import math
|
||||
size = max(math.floor(TEST_SIZE * (10 ** sz_decimals)) / (10 ** sz_decimals), 1 / (10 ** sz_decimals))
|
||||
print(f" 下单量: {size} ({sz_decimals} 位精度)")
|
||||
|
||||
# 3. 设置杠杆
|
||||
service.update_leverage(TEST_SYMBOL, TEST_LEVERAGE)
|
||||
|
||||
# 4. 市价开多
|
||||
result = service.place_market_order(
|
||||
symbol=TEST_SYMBOL,
|
||||
is_buy=True,
|
||||
size=size,
|
||||
reduce_only=False
|
||||
)
|
||||
if not result.get('success'):
|
||||
r.record("市价开仓", False, result.get('error', '未知错误'))
|
||||
return
|
||||
|
||||
r.record("市价开仓", True, f"{TEST_SYMBOL} buy {size}")
|
||||
opened = True
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# 5. 验证持仓
|
||||
position = service.get_position_for_symbol(TEST_SYMBOL)
|
||||
r.record("验证持仓存在", position is not None,
|
||||
f"size={position['size']}, entry=${position['entry_price']:,.2f}" if position else "未找到持仓")
|
||||
|
||||
# 6. 设置止盈止损
|
||||
tp_price = round(current_price * 1.02, 2) # +2%
|
||||
sl_price = round(current_price * 0.98, 2) # -2%
|
||||
print(f" 设置 TP=${tp_price:,.2f}, SL=${sl_price:,.2f}")
|
||||
|
||||
tp_sl_result = service.set_tp_sl(
|
||||
symbol=TEST_SYMBOL,
|
||||
is_long=True,
|
||||
size=size,
|
||||
tp_price=tp_price,
|
||||
sl_price=sl_price
|
||||
)
|
||||
|
||||
tp_set = tp_sl_result.get('tp_set', False)
|
||||
sl_set = tp_sl_result.get('sl_set', False)
|
||||
errors = tp_sl_result.get('errors', [])
|
||||
|
||||
detail = f"TP=${tp_price:,.2f}({'✅' if tp_set else '❌'}), SL=${sl_price:,.2f}({'✅' if sl_set else '❌'})"
|
||||
if errors:
|
||||
detail += f" errors={errors}"
|
||||
r.record("设置止盈止损", tp_set and sl_set, detail)
|
||||
|
||||
# 7. 验证止盈止损挂单
|
||||
time.sleep(1)
|
||||
tp_sl_prices = service.get_tp_sl_prices(TEST_SYMBOL)
|
||||
has_tp = tp_sl_prices.get('take_profit') is not None
|
||||
has_sl = tp_sl_prices.get('stop_loss') is not None
|
||||
r.record("验证 TP/SL 挂单", has_tp and has_sl,
|
||||
f"TP={tp_sl_prices.get('take_profit')}({'✅' if has_tp else '❌'}), "
|
||||
f"SL={tp_sl_prices.get('stop_loss')}({'✅' if has_sl else '❌'})")
|
||||
|
||||
# 8. 取消止盈止损
|
||||
try:
|
||||
service.cancel_tp_sl_orders(TEST_SYMBOL)
|
||||
except:
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
# 9. 市价平仓
|
||||
close_result = service.market_close_position(TEST_SYMBOL)
|
||||
r.record("市价平仓", close_result.get('success', False),
|
||||
close_result.get('error', f"成功"))
|
||||
if close_result.get('success'):
|
||||
opened = False
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# 10. 验证已平仓
|
||||
position_after = service.get_position_for_symbol(TEST_SYMBOL)
|
||||
r.record("验证已平仓", position_after is None)
|
||||
|
||||
except Exception as e:
|
||||
r.record("市价单流程异常", False, f"{e}\n{traceback.format_exc()}")
|
||||
finally:
|
||||
if opened:
|
||||
try:
|
||||
service.cancel_all_orders(TEST_SYMBOL)
|
||||
time.sleep(0.5)
|
||||
service.market_close_position(TEST_SYMBOL)
|
||||
print(" 🧹 已自动清理残留持仓")
|
||||
except Exception as cleanup_err:
|
||||
print(f" ⚠️ 清理失败,请手动检查: {cleanup_err}")
|
||||
|
||||
|
||||
def test_set_tp_sl_partial_failure(service, r: TestResult):
|
||||
"""测试 set_tp_sl: 第一个失败不影响第二个"""
|
||||
# 这个测试验证我们的修复:独立的 try-except
|
||||
# 如果 TP 失败(例如价格为 0),SL 应该仍然被设置
|
||||
opened = False
|
||||
try:
|
||||
# 先清理
|
||||
try:
|
||||
service.cancel_all_orders(TEST_SYMBOL)
|
||||
except:
|
||||
pass
|
||||
pos = service.get_position_for_symbol(TEST_SYMBOL)
|
||||
if pos:
|
||||
service.market_close_position(TEST_SYMBOL)
|
||||
time.sleep(1)
|
||||
|
||||
# 1. 市价开仓
|
||||
sz_decimals = service.get_sz_decimals(TEST_SYMBOL)
|
||||
import math
|
||||
size = max(math.floor(TEST_SIZE * (10 ** sz_decimals)) / (10 ** sz_decimals), 1 / (10 ** sz_decimals))
|
||||
service.update_leverage(TEST_SYMBOL, TEST_LEVERAGE)
|
||||
|
||||
result = service.place_market_order(
|
||||
symbol=TEST_SYMBOL,
|
||||
is_buy=True,
|
||||
size=size,
|
||||
reduce_only=False
|
||||
)
|
||||
if not result.get('success'):
|
||||
r.record("部分失败测试: 开仓", False, result.get('error', '未知'))
|
||||
return
|
||||
opened = True
|
||||
time.sleep(2)
|
||||
|
||||
# 2. 设置一个有效的 SL(但故意不设置 TP → tp_price=None)
|
||||
all_mids = service.info.all_mids()
|
||||
current_price = float(all_mids.get(TEST_SYMBOL, 0))
|
||||
sl_price = round(current_price * 0.98, 2)
|
||||
|
||||
tp_sl_result = service.set_tp_sl(
|
||||
symbol=TEST_SYMBOL,
|
||||
is_long=True,
|
||||
size=size,
|
||||
tp_price=None, # 不设 TP
|
||||
sl_price=sl_price # 只设 SL
|
||||
)
|
||||
sl_set = tp_sl_result.get('sl_set', False)
|
||||
r.record("部分设置测试 (仅 SL)", sl_set, f"sl_set={sl_set}, errors={tp_sl_result.get('errors', [])}")
|
||||
|
||||
# 3. 验证 SL 挂单存在
|
||||
time.sleep(1)
|
||||
tp_sl_prices = service.get_tp_sl_prices(TEST_SYMBOL)
|
||||
has_sl = tp_sl_prices.get('stop_loss') is not None
|
||||
r.record("验证 SL 挂单", has_sl, f"SL={tp_sl_prices.get('stop_loss')}")
|
||||
|
||||
# 4. 清理
|
||||
service.cancel_all_orders(TEST_SYMBOL)
|
||||
time.sleep(1)
|
||||
service.market_close_position(TEST_SYMBOL)
|
||||
opened = False
|
||||
|
||||
except Exception as e:
|
||||
r.record("部分失败测试异常", False, f"{e}\n{traceback.format_exc()}")
|
||||
finally:
|
||||
if opened:
|
||||
try:
|
||||
service.cancel_all_orders(TEST_SYMBOL)
|
||||
time.sleep(0.5)
|
||||
service.market_close_position(TEST_SYMBOL)
|
||||
print(" 🧹 已自动清理残留持仓")
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
# ==================== 主入口 ====================
|
||||
|
||||
def main():
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Hyperliquid 实盘接口集成测试")
|
||||
print(f"{'='*60}")
|
||||
print(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f" 交易对: {TEST_SYMBOL}")
|
||||
print(f" 下单量: {TEST_SIZE}")
|
||||
print(f" 杠杆: {TEST_LEVERAGE}x")
|
||||
print(f"{'='*60}")
|
||||
|
||||
r = TestResult()
|
||||
|
||||
# 初始化
|
||||
try:
|
||||
from app.services.hyperliquid_trading_service import HyperliquidTradingService
|
||||
service = HyperliquidTradingService()
|
||||
print(f" 钱包: {service.wallet_address[:10]}...")
|
||||
except Exception as e:
|
||||
print(f"\n❌ 初始化失败: {e}")
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
# ---- 基础测试 ----
|
||||
print(f"\n{'─'*40}")
|
||||
print(" 基础接口")
|
||||
print(f"{'─'*40}")
|
||||
|
||||
test_account_state(service, r)
|
||||
time.sleep(0.3)
|
||||
|
||||
test_update_leverage(service, r)
|
||||
time.sleep(0.3)
|
||||
|
||||
test_get_positions(service, r)
|
||||
time.sleep(0.3)
|
||||
|
||||
# ---- 核心测试: 开仓 → TP/SL → 平仓 ----
|
||||
print(f"\n{'─'*40}")
|
||||
print(" 开仓 → 止盈止损 → 验证 → 平仓")
|
||||
print(f"{'─'*40}")
|
||||
|
||||
test_market_order_with_tp_sl(service, r)
|
||||
time.sleep(1)
|
||||
|
||||
# ---- 边界测试: 部分设置 ----
|
||||
print(f"\n{'─'*40}")
|
||||
print(" 部分设置测试")
|
||||
print(f"{'─'*40}")
|
||||
|
||||
test_set_tp_sl_partial_failure(service, r)
|
||||
|
||||
# ---- 汇总 ----
|
||||
all_passed = r.summary()
|
||||
sys.exit(0 if all_passed else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("\n⚠️ 此测试会产生真实订单和手续费!")
|
||||
print(f" 使用 {TEST_SYMBOL} 最小量 {TEST_SIZE}")
|
||||
|
||||
confirm = input("\n是否继续?(yes/no): ")
|
||||
if confirm.strip().lower() != 'yes':
|
||||
print("已取消")
|
||||
sys.exit(0)
|
||||
|
||||
main()
|
||||
@ -132,8 +132,8 @@ def test_lane_specific_risk_reward_and_distance_thresholds():
|
||||
signal = {
|
||||
"action": "sell",
|
||||
"entry_price": 100.0,
|
||||
"stop_loss": 101.0,
|
||||
"take_profit": 98.0,
|
||||
"stop_loss": 101.6,
|
||||
"take_profit": 96.8,
|
||||
}
|
||||
|
||||
assert analyzer._meets_min_risk_reward(signal, "short_term") is True
|
||||
@ -144,8 +144,8 @@ def test_lane_specific_risk_reward_and_distance_thresholds():
|
||||
tighter_signal = {
|
||||
"action": "sell",
|
||||
"entry_price": 100.0,
|
||||
"stop_loss": 101.0,
|
||||
"take_profit": 98.4,
|
||||
"stop_loss": 100.8,
|
||||
"take_profit": 98.7,
|
||||
}
|
||||
|
||||
assert analyzer._meets_min_risk_reward(tighter_signal, "short_term") is True
|
||||
@ -153,6 +153,16 @@ def test_lane_specific_risk_reward_and_distance_thresholds():
|
||||
assert analyzer._meets_min_price_distance(tighter_signal, "short_term") is True
|
||||
assert analyzer._meets_min_price_distance(tighter_signal, "medium_term") is False
|
||||
|
||||
too_tight_intraday = {
|
||||
"action": "sell",
|
||||
"entry_price": 100.0,
|
||||
"stop_loss": 100.6,
|
||||
"take_profit": 98.8,
|
||||
}
|
||||
|
||||
assert analyzer._meets_min_price_distance(too_tight_intraday, "short_term") is False
|
||||
assert analyzer._meets_min_risk_reward(too_tight_intraday, "short_term") is True
|
||||
|
||||
|
||||
def test_fibonacci_context_marks_kind_and_trade_zone():
|
||||
analyzer = make_analyzer()
|
||||
@ -175,3 +185,56 @@ def test_fibonacci_context_marks_kind_and_trade_zone():
|
||||
|
||||
formatted = analyzer._format_fib_levels(result["support_details"] or result["resistance_details"])
|
||||
assert "回撤Fib" in formatted or "扩展Fib" in formatted
|
||||
|
||||
|
||||
def test_market_location_summary_marks_middle_of_range_and_far_from_trade_zone():
|
||||
analyzer = make_analyzer()
|
||||
|
||||
location = analyzer._build_market_location_summary(
|
||||
current_price=100.0,
|
||||
range_zone={"is_ranging": True, "support_level": 90.0, "resistance_level": 110.0},
|
||||
key_levels={
|
||||
"best_long_zone": {"center": 92.0},
|
||||
"best_short_zone": {"center": 108.0},
|
||||
},
|
||||
)
|
||||
|
||||
assert location["relative_to_range"] == "middle_of_range"
|
||||
assert location["location_tag"] == "middle_of_range"
|
||||
|
||||
far_location = analyzer._build_market_location_summary(
|
||||
current_price=100.0,
|
||||
range_zone={"is_ranging": False},
|
||||
key_levels={
|
||||
"best_long_zone": {"center": 95.0},
|
||||
"best_short_zone": {"center": 105.0},
|
||||
},
|
||||
)
|
||||
|
||||
assert far_location["location_tag"] == "far_from_trade_zone"
|
||||
|
||||
|
||||
def test_build_analysis_prompt_includes_structured_market_and_derivatives_blocks():
|
||||
analyzer = make_analyzer()
|
||||
prompt = analyzer._build_analysis_prompt(
|
||||
symbol="BTCUSDT",
|
||||
lane="intraday",
|
||||
market_context={
|
||||
"snapshot": "## 市场快照\n- 当前价格: 100",
|
||||
"intraday_structured": "```json\n{\"lane\":\"intraday\"}\n```",
|
||||
"intraday": "## 日内特征\n- 5m: ...",
|
||||
"levels": "## 关键位\n- 支撑位: 99",
|
||||
},
|
||||
news_context="无最新新闻",
|
||||
futures_context="## 衍生品特征\n- 资金费率: +0.01%",
|
||||
futures_market_data={
|
||||
"funding_rate": {"funding_rate_percent": 0.01, "sentiment_level": "neutral"},
|
||||
"open_interest": {"open_interest": 12345},
|
||||
"oi_change_percent_24h": 9.2,
|
||||
"premium_rate": 0.31,
|
||||
},
|
||||
)
|
||||
|
||||
assert "## 衍生品结构化特征" in prompt
|
||||
assert "\"lane\":\"intraday\"" in prompt
|
||||
assert "\"crowding_regime\": \"medium\"" in prompt or "\"crowding_regime\": \"high\"" in prompt
|
||||
|
||||
1393
frontend/console.html
Normal file
1393
frontend/console.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -314,6 +314,39 @@
|
||||
.pending-signal {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.platform-halts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.platform-halt-card {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.platform-halt-card.halted {
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--error) 20%, transparent);
|
||||
}
|
||||
|
||||
.platform-halt-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.platform-halt-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -383,6 +416,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="adminMode" class="platform-halts">
|
||||
<div
|
||||
v-for="(status, platform) in platformHalts"
|
||||
:key="platform"
|
||||
class="platform-halt-card"
|
||||
:class="{ halted: status.halted }"
|
||||
>
|
||||
<div class="platform-halt-title">
|
||||
{{ platform }} · {{ status.halted ? '已暂停' : '运行中' }}
|
||||
</div>
|
||||
<div class="platform-halt-text" v-if="status.halted">
|
||||
{{ status.reason || '无暂停原因' }}
|
||||
</div>
|
||||
<div class="platform-halt-text" v-if="status.halted && status.halted_at">
|
||||
暂停时间: {{ formatTime(status.halted_at) }}
|
||||
</div>
|
||||
<button
|
||||
v-if="status.halted"
|
||||
class="btn btn-secondary btn-small"
|
||||
@click="resumePlatform(platform)"
|
||||
>
|
||||
恢复平台
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Real-time Prices -->
|
||||
<div v-if="Object.keys(latestPrices).length > 0" class="price-section">
|
||||
<div class="stat-label" style="margin-bottom: 8px;">实时价格</div>
|
||||
@ -734,7 +793,8 @@
|
||||
titleClickCount: 0,
|
||||
titleClickTimer: null,
|
||||
refreshInterval: null,
|
||||
latestPrices: {}
|
||||
latestPrices: {},
|
||||
platformHalts: {}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -805,7 +865,8 @@
|
||||
this.fetchAccountStatus(),
|
||||
this.fetchStatistics(),
|
||||
this.fetchOrders(),
|
||||
this.fetchLatestPrices()
|
||||
this.fetchLatestPrices(),
|
||||
this.fetchPlatformHalts()
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error('刷新数据失败:', e);
|
||||
@ -820,7 +881,8 @@
|
||||
this.fetchAccountStatus(),
|
||||
this.fetchStatistics(),
|
||||
this.fetchOrders(),
|
||||
this.fetchLatestPrices()
|
||||
this.fetchLatestPrices(),
|
||||
this.fetchPlatformHalts()
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error('静默刷新失败:', e);
|
||||
@ -871,6 +933,17 @@
|
||||
}
|
||||
},
|
||||
|
||||
async fetchPlatformHalts() {
|
||||
try {
|
||||
const response = await axios.get('/api/trading/platform-halts');
|
||||
if (response.data.success) {
|
||||
this.platformHalts = response.data.platform_halts || {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取平台停机状态失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async closeOrder(order) {
|
||||
if (!confirm('确定要平仓吗?')) return;
|
||||
|
||||
@ -925,6 +998,25 @@
|
||||
}
|
||||
},
|
||||
|
||||
async resumePlatform(platform) {
|
||||
if (!confirm(`确定恢复 ${platform} 吗?恢复后会重新允许该平台执行。`)) return;
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/trading/platform-halts/resume', {
|
||||
platform
|
||||
});
|
||||
if (response.data.success) {
|
||||
await this.refreshData();
|
||||
alert(`${platform} 已恢复`);
|
||||
} else {
|
||||
alert(`恢复失败: ${response.data.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('恢复平台失败:', error);
|
||||
alert('恢复失败: ' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
},
|
||||
|
||||
async sendReport() {
|
||||
this.sendingReport = true;
|
||||
try {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user