fix bug!

This commit is contained in:
aaron 2026-04-22 10:38:25 +08:00
parent 61ed81c9b6
commit 491a1d29f1
16 changed files with 4099 additions and 167 deletions

View File

@ -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.bitget_service import bitget_service
from app.services.db_service import db_service from app.services.db_service import db_service
from app.utils.logger import logger from app.utils.logger import logger
from app.crypto_agent.crypto_agent import get_crypto_agent
router = APIRouter(prefix="/api/trading", tags=["交易"]) router = APIRouter(prefix="/api/trading", tags=["交易"])
@ -27,6 +28,11 @@ class DeleteOrdersRequest(BaseModel):
recalculate: bool = True # 是否重新计算统计数据 recalculate: bool = True # 是否重新计算统计数据
class ResumePlatformRequest(BaseModel):
"""恢复平台执行请求"""
platform: str
class OrderResponse(BaseModel): class OrderResponse(BaseModel):
"""订单响应""" """订单响应"""
success: bool success: bool
@ -219,6 +225,39 @@ async def delete_order(
raise HTTPException(status_code=500, detail=str(e)) 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") @router.post("/orders/batch-delete")
async def batch_delete_orders(request: DeleteOrdersRequest): async def batch_delete_orders(request: DeleteOrdersRequest):
""" """

View File

@ -1,14 +1,33 @@
""" """
系统状态 API 系统状态 API
""" """
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from typing import Dict, Any from typing import Dict, Any
from app.utils.logger import logger from app.utils.logger import logger
from app.utils.system_status import get_system_monitor 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() 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]) @router.get("/status", response_model=Dict[str, Any])
async def get_system_status(): async def get_system_status():
""" """
@ -91,3 +110,155 @@ async def get_agent_status(agent_id: str):
except Exception as e: except Exception as e:
logger.error(f"获取 Agent 状态失败: {e}") logger.error(f"获取 Agent 状态失败: {e}")
raise HTTPException(status_code=500, detail=f"获取 Agent 状态失败: {str(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

View File

@ -22,6 +22,7 @@ class HyperliquidExecutor(BaseExecutor):
try: try:
symbol = decision.get('symbol', '').replace('USDT', '') symbol = decision.get('symbol', '').replace('USDT', '')
action = decision.get('signal_action', decision.get('action')) # buy/sell 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) entry_price = decision.get('entry_price', current_price)
stop_loss = decision.get('stop_loss') stop_loss = decision.get('stop_loss')
take_profit = decision.get('take_profit') take_profit = decision.get('take_profit')
@ -33,15 +34,15 @@ class HyperliquidExecutor(BaseExecutor):
# 获取账户状态 # 获取账户状态
account_state = self.hyperliquid.get_account_state() account_state = self.hyperliquid.get_account_state()
available = account_state.get('available_balance', 0) available = account_state.get('available_balance', 0)
account_value = account_state.get('account_value', 0)
# 仓位价值 = 1x 账户价值,所需保证金 = 账户价值 / 杠杆
leverage = min(decision.get('leverage', 10), 10) leverage = min(decision.get('leverage', 10), 10)
target_position_value = account_value # 1倍账户价值 adjusted_margin = self.calculate_effective_margin(available, margin)
margin_needed = target_position_value / leverage if leverage > 0 else 0
# 预留手续费并限制在可用余额内 if adjusted_margin <= 0:
adjusted_margin = self.calculate_effective_margin(available, margin_needed) return {
'success': False,
'error': f'保证金无效: {adjusted_margin}'
}
# 计算仓位大小 # 计算仓位大小
position_size = self._calculate_position_size(symbol, adjusted_margin, entry_price, leverage) position_size = self._calculate_position_size(symbol, adjusted_margin, entry_price, leverage)
@ -84,9 +85,10 @@ class HyperliquidExecutor(BaseExecutor):
logger.info(f" ✅ 开仓成功: {symbol} {position_size} @ ${order_type}") logger.info(f" ✅ 开仓成功: {symbol} {position_size} @ ${order_type}")
# 成交后设置止盈止损Hyperliquid 不支持下单时设置 TP/SL # 设置止盈止损
# 必须在飞书通知之前设置,避免通知异常导致止盈止损跳过
if stop_loss or take_profit: if stop_loss or take_profit:
if order_status == 'filled':
# 市价单已成交,直接设置 TP/SL
try: try:
tp_sl_result = self.hyperliquid.set_tp_sl( tp_sl_result = self.hyperliquid.set_tp_sl(
symbol=symbol, symbol=symbol,
@ -95,14 +97,28 @@ class HyperliquidExecutor(BaseExecutor):
tp_price=take_profit, tp_price=take_profit,
sl_price=stop_loss sl_price=stop_loss
) )
if not tp_sl_result.get('success'): tp_set = tp_sl_result.get('tp_set', False)
logger.warning(f" ⚠️ 止盈止损设置失败: {tp_sl_result.get('error', tp_sl_result.get('message'))}") sl_set = tp_sl_result.get('sl_set', False)
result['tp_sl_warning'] = tp_sl_result.get('error', tp_sl_result.get('message'))
else: if tp_set and sl_set:
logger.info(f" ✅ 止盈止损已设置: TP={take_profit}, SL={stop_loss}") 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: except Exception as tp_sl_err:
logger.error(f" ⚠️ 止盈止损设置异常: {tp_sl_err}") logger.error(f" ⚠️ 止盈止损设置异常: {tp_sl_err}")
result['tp_sl_warning'] = str(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( await self.send_execution_notification(

View File

@ -27,45 +27,49 @@ from app.services.bitget_service import bitget_service
class MarketSignalAnalyzer: class MarketSignalAnalyzer:
"""市场信号分析器 - 只关注市场,输出客观信号""" """市场信号分析器 - 只关注市场,输出客观信号"""
INTRADAY_ANALYSIS_TEMPERATURE = 0.15 INTRADAY_ANALYSIS_TEMPERATURE = 0.12
TREND_ANALYSIS_TEMPERATURE = 0.10 TREND_ANALYSIS_TEMPERATURE = 0.08
ANALYSIS_MAX_TOKENS = 1200 ANALYSIS_MAX_TOKENS = 1200
LANE_MIN_CONFIDENCE = { LANE_MIN_CONFIDENCE = {
'short_term': 70, 'short_term': 70,
'medium_term': 70, 'medium_term': 70,
} }
LANE_MIN_RISK_REWARD = { LANE_MIN_RISK_REWARD = {
'short_term': 1.5, 'short_term': 1.6,
'medium_term': 1.8, 'medium_term': 2.0,
} }
LANE_MIN_STOP_LOSS_PCT = { LANE_MIN_STOP_LOSS_PCT = {
'short_term': 0.6, 'short_term': 0.7,
'medium_term': 1.0, 'medium_term': 1.5,
} }
LANE_MIN_TAKE_PROFIT_PCT = { LANE_MIN_TAKE_PROFIT_PCT = {
'short_term': 1.0, 'short_term': 1.2,
'medium_term': 2.0, 'medium_term': 3.0,
} }
FIB_MIN_PIVOT_SEPARATION_BARS = 4 FIB_MIN_PIVOT_SEPARATION_BARS = 4
FIB_PIVOT_VOLUME_LOOKBACK = 20 FIB_PIVOT_VOLUME_LOOKBACK = 20
INTRADAY_ANALYSIS_PROMPT = """你是一位专业的加密货币日内交易员,只负责生成 short_term 信号。 INTRADAY_ANALYSIS_PROMPT = """你是一位专业的加密货币日内交易员,只负责生成 short_term 信号。
你的任务是基于 5m / 15m当日开盘VWAP开盘区间关键位Fib 回撤位和衍生品拥挤度判断未来 30 分钟到 4 小时内是否存在可执行 setup 你的任务是基于 5m / 15m当日开盘VWAP开盘区间关键位Fib 回撤位和衍生品拥挤度判断未来 30 分钟到 4 小时内是否存在可执行的合约日内 setup
执行原则 执行原则
1. 先判断日内 regimetrending / ranging / neutral 1. 先判断日内 regimetrending / ranging / neutral
2. 趋势日内只做顺势回调或突破后的回踩确认不追涨杀跌 2. 趋势日内只做顺势回调或突破后的回踩确认不追涨杀跌
3. 震荡日内只做区间边界附近的反转不在区间中部开仓 3. 震荡日内只做区间边界附近的反转不在区间中部开仓
4. 技术指标只做辅助优先看结构关键位波动率量能VWAP 偏离和距离 4. 技术指标只做辅助优先看结构关键位波动率量能VWAP 偏离和位置优势
5. 优先使用优先支撑 / 优先阻力可交易多头区 / 可交易空头区普通支撑阻力只作补充 5. 优先使用优先支撑 / 优先阻力可交易多头区 / 可交易空头区普通支撑阻力只作补充
6. 没有清晰止损止盈和盈亏比就不交易 6. 没有清晰止损止盈和盈亏比就不交易
7. 本次分析独立进行不参考任何上一轮信号 7. 本次分析独立进行不参考任何上一轮信号
8. 硬性禁止
- 如果多周期特征已确认上升趋势HH+HL 结构或突破震荡区间向上禁止输出 sell 信号
- 如果多周期特征已确认下降趋势LL+LH 结构或跌破震荡区间向下禁止输出 buy 信号
- 逆势信号只允许在 trend_direction=neutral 且有明确区间边界反转结构时输出
信号要求 信号要求
1. 只允许输出 0 1 short_term 信号 1. 只允许输出 0 1 short_term 信号
2. 盈亏比至少 1:1.5 2. 盈亏比至少 1:1.6
3. 如果价格处于加速延伸优先返回空信号 3. 如果价格处于加速延伸远离优先交易区或衍生品同向拥挤优先返回空信号
4. 如果价格位于区间中部离关键位太远止损过宽或方向证据冲突必须返回空信号 4. 如果价格位于区间中部离关键位太远止损过宽或方向证据冲突必须返回空信号
5. 做多时entry 应尽量靠近优先支撑或多头共振区做空时entry 应尽量靠近优先阻力或空头共振区 5. 做多时entry 应尽量靠近优先支撑或多头共振区做空时entry 应尽量靠近优先阻力或空头共振区
6. 只有在 setup 足够清晰时才允许输出信号宁可空仓不要勉强给单 6. 只有在 setup 足够清晰时才允许输出信号宁可空仓不要勉强给单
@ -78,8 +82,10 @@ class MarketSignalAnalyzer:
- C: 70-71只有轻仓试错级别 - C: 70-71只有轻仓试错级别
- 70 以下不要输出交易信号 - 70 以下不要输出交易信号
9. 止损止盈距离下限 9. 止损止盈距离下限
- short_term 止损距离至少 0.6% - short_term 止损距离至少 0.7%
- short_term 止盈距离至少 1.0% - short_term 止盈距离至少 1.2%
10. reasoning 必须覆盖四点中的至少三点结构位置量价/波动衍生品拥挤度
11. 如果数据明确显示 `market_location=middle_of_range` `far_from_trade_zone`必须返回空信号
输出 JSON禁止输出解释性正文 输出 JSON禁止输出解释性正文
```json ```json
@ -102,7 +108,7 @@ class MarketSignalAnalyzer:
"entry_price": 0, "entry_price": 0,
"stop_loss": 0, "stop_loss": 0,
"take_profit": 0, "take_profit": 0,
"reasoning": "结构+关键位+量能+波动率" "reasoning": "结构+位置+量价/波动+拥挤度"
} }
] ]
} }
@ -118,7 +124,7 @@ class MarketSignalAnalyzer:
TREND_ANALYSIS_PROMPT = """你是一位专业的加密货币趋势交易员,只负责生成 medium_term 信号。 TREND_ANALYSIS_PROMPT = """你是一位专业的加密货币趋势交易员,只负责生成 medium_term 信号。
你的任务是基于 1h / 4h / 1d关键位Fib 回撤/扩展位趋势阶段反转检测衍生品拥挤度和新闻催化判断未来 4 小时到 1 周内是否存在趋势 setup 你的任务是基于 1h / 4h / 1d关键位Fib 回撤/扩展位趋势阶段反转检测衍生品拥挤度和新闻催化判断未来 4 小时到 1 周内是否存在可执行的合约趋势 setup
执行原则 执行原则
1. 4h/1d 决定大方向1h 决定节奏与入场位置 1. 4h/1d 决定大方向1h 决定节奏与入场位置
@ -126,14 +132,15 @@ class MarketSignalAnalyzer:
- 趋势延续4h/1d 趋势明确1h 回踩关键位后确认继续 - 趋势延续4h/1d 趋势明确1h 回踩关键位后确认继续
- 趋势反转4h/1d 结构和 1h 动能同时改善且反转证据充分 - 趋势反转4h/1d 结构和 1h 动能同时改善且反转证据充分
3. 禁止仅凭 15m 噪音逆 4h 开仓 3. 禁止仅凭 15m 噪音逆 4h 开仓
4. 趋势晚期资金费率过热或价格过度偏离关键均线要显著降低开仓积极性 4. 趋势晚期资金费率过热价格过度偏离关键均线或衍生品顺向拥挤要显著降低开仓积极性
5. 没有清晰位置优势就不交易 5. 没有清晰位置优势就不交易
6. 本次分析独立进行不参考任何上一轮信号 6. 本次分析独立进行不参考任何上一轮信号
7. 优先使用优先支撑 / 优先阻力可交易多头区 / 可交易空头区普通关键位只作补充 7. 优先使用优先支撑 / 优先阻力可交易多头区 / 可交易空头区普通关键位只作补充
8. 趋势单的核心不是猜方向而是等待大级别方向明确后在有位置优势的回踩/反抽处开仓
信号要求 信号要求
1. 只允许输出 0 1 medium_term 信号 1. 只允许输出 0 1 medium_term 信号
2. 盈亏比至少 1:1.8 2. 盈亏比至少 1:2.0
3. 如果 4h/1d 1h 明显冲突优先返回空信号 3. 如果 4h/1d 1h 明显冲突优先返回空信号
4. 反转信号必须比延续信号更严格 4. 反转信号必须比延续信号更严格
5. 如果趋势处于晚期且没有回踩确认或反转证据不足必须返回空信号 5. 如果趋势处于晚期且没有回踩确认或反转证据不足必须返回空信号
@ -145,8 +152,10 @@ class MarketSignalAnalyzer:
- C: 70-71仅限早期确认不足的轻仓趋势尝试 - C: 70-71仅限早期确认不足的轻仓趋势尝试
- 70 以下不要输出交易信号 - 70 以下不要输出交易信号
9. 止损止盈距离下限 9. 止损止盈距离下限
- medium_term 止损距离至少 1.0% - medium_term 止损距离至少 1.5%
- medium_term 止盈距离至少 2.0% - medium_term 止盈距离至少 3.0%
10. reasoning 必须明确大级别方向1h 入场节奏位置优势拥挤度风险
11. 如果价格已经远离优先交易区或趋势方向虽对但没有回踩/反抽确认必须返回空信号
输出 JSON禁止输出解释性正文 输出 JSON禁止输出解释性正文
```json ```json
@ -169,7 +178,7 @@ class MarketSignalAnalyzer:
"entry_price": 0, "entry_price": 0,
"stop_loss": 0, "stop_loss": 0,
"take_profit": 0, "take_profit": 0,
"reasoning": "4h方向+1h节奏+关键位+量价" "reasoning": "4h/1d方向+1h节奏+位置优势+拥挤度"
} }
] ]
} }
@ -216,14 +225,16 @@ class MarketSignalAnalyzer:
lane="intraday", lane="intraday",
market_context=market_context, market_context=market_context,
news_context=news_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( trend_prompt = self._build_analysis_prompt(
symbol=symbol, symbol=symbol,
lane="trend", lane="trend",
market_context=market_context, market_context=market_context,
news_context=news_context, news_context=news_context,
futures_context=futures_context futures_context=futures_context,
futures_market_data=futures_market_data,
) )
intraday_messages = [ intraday_messages = [
@ -298,6 +309,7 @@ class MarketSignalAnalyzer:
trend_stage = self._detect_trend_stage(data) trend_stage = self._detect_trend_stage(data)
fib_context = self._build_fibonacci_context(data, current_price) fib_context = self._build_fibonacci_context(data, current_price)
key_levels = self._derive_key_levels(data, range_zone, fib_context, 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 = [ snapshot_parts = [
f"## 市场快照", f"## 市场快照",
@ -316,6 +328,7 @@ class MarketSignalAnalyzer:
snapshot_parts.append( snapshot_parts.append(
f"- 开盘区间(前30分钟): 高 {opening_range['high']:.2f} / 低 {opening_range['low']:.2f}" f"- 开盘区间(前30分钟): 高 {opening_range['high']:.2f} / 低 {opening_range['low']:.2f}"
) )
snapshot_parts.append(f"- 市场位置: {market_location['summary']}")
intraday_parts = [ intraday_parts = [
"## 日内特征", "## 日内特征",
@ -396,6 +409,53 @@ class MarketSignalAnalyzer:
f"{'BB收口' if range_metrics['bb_squeeze'] else 'BB正常'}" 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 { return {
'snapshot': "\n".join(snapshot_parts), 'snapshot': "\n".join(snapshot_parts),
'intraday': "\n".join(intraday_parts), 'intraday': "\n".join(intraday_parts),
@ -403,6 +463,9 @@ class MarketSignalAnalyzer:
'levels': "\n".join(levels_parts), 'levels': "\n".join(levels_parts),
'range_warning': range_warning, 'range_warning': range_warning,
'range_metrics': range_metrics, '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]: def _get_session_open(self, df: Optional[pd.DataFrame]) -> Optional[float]:
@ -1118,6 +1181,199 @@ class MarketSignalAnalyzer:
return "N/A" return "N/A"
return ", ".join(f"{level:.2f}" for level in levels[:3]) 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: def _infer_price_structure(self, df: pd.DataFrame, lookback: int = 20) -> str:
"""根据分段高低点判断 HH/HL / LH/LL / 区间""" """根据分段高低点判断 HH/HL / LH/LL / 区间"""
if df is None or len(df) < lookback: if df is None or len(df) < lookback:
@ -1227,6 +1483,7 @@ class MarketSignalAnalyzer:
funding = market_data.get('funding_rate', {}) funding = market_data.get('funding_rate', {})
oi = market_data.get('open_interest', {}) oi = market_data.get('open_interest', {})
premium = market_data.get('premium_rate') premium = market_data.get('premium_rate')
derivatives_state = self._summarize_derivatives_state(market_data)
lines = [ lines = [
f"## 衍生品特征", f"## 衍生品特征",
@ -1247,30 +1504,137 @@ class MarketSignalAnalyzer:
if premium is not None: if premium is not None:
lines.append(f"- 溢价率: {premium:+.2f}%") lines.append(f"- 溢价率: {premium:+.2f}%")
if derivatives_state.get('summary'):
lines.append(f"- 拥挤度结论: {derivatives_state['summary']}")
return "\n".join(lines) 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, def _build_analysis_prompt(self, symbol: str, lane: str,
market_context: Dict[str, str], market_context: Dict[str, str],
news_context: 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_text = "日内交易分析" if lane == "intraday" else "趋势交易分析"
lane_scope = ( lane_scope = (
[ [
"只根据下面提供的日内结构化特征做判断,不要脑补未提供的数据。", "只根据下面提供的日内结构化特征做判断,不要脑补未提供的数据。",
"重点阅读 5m/15m、当日开盘、VWAP、开盘区间、区间状态、关键位、Fib 回撤位和衍生品过热程度。", "先看 JSON 结构块,再用后面的说明性摘要做交叉验证。",
"优先参考“优先支撑/优先阻力”和“可交易多头区/可交易空头区”,不要在远离关键位的位置给 entry。", "重点判断是否存在位置优势,而不是只判断方向。",
"优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone。",
] ]
if lane == "intraday" if lane == "intraday"
else [ 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 = [ selected_sections = [
market_context.get('snapshot', ''), market_context.get('snapshot', ''),
structured_market_context,
market_context.get('intraday', '') if lane == "intraday" else market_context.get('trend', ''), market_context.get('intraday', '') if lane == "intraday" else market_context.get('trend', ''),
market_context.get('levels', ''), market_context.get('levels', ''),
] ]
@ -1280,6 +1644,15 @@ class MarketSignalAnalyzer:
*lane_scope, *lane_scope,
] ]
if futures_block:
prompt_parts.extend([
"",
"## 衍生品结构化特征",
"```json",
json.dumps(futures_block, ensure_ascii=False, indent=2),
"```",
])
for section in selected_sections: for section in selected_sections:
if section: if section:
prompt_parts.append("") prompt_parts.append("")
@ -1297,6 +1670,12 @@ class MarketSignalAnalyzer:
prompt_parts.append("") prompt_parts.append("")
prompt_parts.append(market_context['range_warning']) 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("")
prompt_parts.append("输出要求:只返回 system prompt 定义的 JSON 对象。没有高质量 setup 就返回 signals: []。") prompt_parts.append("输出要求:只返回 system prompt 定义的 JSON 对象。没有高质量 setup 就返回 signals: []。")
@ -1312,8 +1691,22 @@ class MarketSignalAnalyzer:
'trend': trend_result.get('raw_response', '') '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') intraday_signals = self._normalize_lane_signals(intraday_result.get('signals', []), 'short_term')
trend_signals = self._normalize_lane_signals(trend_result.get('signals', []), 'medium_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( merged_signals = sorted(
intraday_signals + trend_signals, intraday_signals + trend_signals,
key=lambda signal: signal.get('confidence', 0), 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') trend_strength = trend_result.get('trend_strength')
if trend_strength in (None, 'weak') and result['trend_direction'] == 'neutral': if trend_strength in (None, 'weak') and result['trend_direction'] == 'neutral':
trend_strength = intraday_result.get('trend_strength', 'weak') trend_strength = intraday_result.get('trend_strength', 'weak')
@ -1397,6 +1785,32 @@ class MarketSignalAnalyzer:
normalized.append(signal) normalized.append(signal)
return normalized[:1] 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: def _infer_signal_grade(self, confidence: float, lane_type: str) -> str:
"""根据 lane 规则统一 grade避免模型随意给等级""" """根据 lane 规则统一 grade避免模型随意给等级"""
if lane_type == 'medium_term': if lane_type == 'medium_term':

View File

@ -738,6 +738,14 @@ async def status_page():
return FileResponse(page_path) return FileResponse(page_path)
return {"message": "页面不存在"} 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") @app.get("/hyperliquid")
async def hyperliquid_page(): async def hyperliquid_page():
"""Hyperliquid 交易监控页面""" """Hyperliquid 交易监控页面"""

View File

@ -661,24 +661,75 @@ class BitgetService:
premium_rate = 0 premium_rate = 0
index_price = float(ticker.get('indexPrice', 0)) index_price = float(ticker.get('indexPrice', 0))
mark_price = float(ticker.get('markPrice', 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: if index_price > 0:
premium_rate = ((mark_price - index_price) / index_price * 100) 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 { return {
'funding_rate': funding_rate, 'funding_rate': funding_rate,
'open_interest': open_interest, 'open_interest': open_interest,
'oi_change_percent_24h': oi_change_percent_24h,
'premium_rate': premium_rate, 'premium_rate': premium_rate,
'market_sentiment': funding_rate.get('sentiment', ''), 'market_sentiment': funding_rate.get('sentiment', ''),
'sentiment_level': funding_rate.get('sentiment_level', ''), 'sentiment_level': funding_rate.get('sentiment_level', ''),
'mark_price': mark_price, '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: except Exception as e:
logger.error(f"获取 {symbol} 合约市场数据失败: {e}") logger.error(f"获取 {symbol} 合约市场数据失败: {e}")
return None 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, def format_futures_data_for_llm(self, symbol: str,
market_data: Dict[str, Any]) -> str: market_data: Dict[str, Any]) -> str:
""" """

View File

@ -424,23 +424,24 @@ class BitgetTradingAPI:
try: try:
ccxt_symbol = self._standardize_symbol(symbol) ccxt_symbol = self._standardize_symbol(symbol)
# 获取当前持仓 # 获取当前持仓(重试最多 3 次,间隔 0.5s,等待仓位数据同步)
positions = self.get_position(symbol)
if not positions:
logger.warning(f"没有找到 {symbol} 的持仓")
result["errors"].append("没有找到持仓")
return result
# 查找有持仓的仓位
position = None position = None
for attempt in range(3):
positions = self.get_position(symbol)
for pos in positions: for pos in positions:
if float(pos.get('contracts', 0)) != 0: if float(pos.get('contracts', 0)) != 0:
position = pos position = pos
break break
if position:
break
if attempt < 2:
import time
logger.info(f"持仓数据未同步,等待重试 ({attempt + 1}/3)...")
time.sleep(0.5)
if not position: if not position:
logger.warning(f"{symbol} 持仓数量为 0") logger.warning(f"没有找到 {symbol} 持仓")
result["errors"].append("持仓数量为 0") result["errors"].append("没有找到持仓")
return result return result
contracts = float(position.get('contracts', 0)) contracts = float(position.get('contracts', 0))

View File

@ -313,7 +313,8 @@ class HyperliquidTradingService:
# Hyperliquid API 不直接返回 reduce_only 标记 # Hyperliquid API 不直接返回 reduce_only 标记
# 但我们可以根据其他信息判断 # 但我们可以根据其他信息判断
# 暂时将所有订单都标记为非 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({ orders.append({
"order_id": order.get("oid"), "order_id": order.get("oid"),
@ -396,54 +397,86 @@ class HyperliquidTradingService:
sl_price: 止损价格可选 sl_price: 止损价格可选
Returns: Returns:
执行结果 {"success": bool, "tp_set": bool, "sl_set": bool, "errors": [...]}
success=True 仅当所有请求的都设置成功
""" """
try: result = {"success": False, "tp_set": False, "sl_set": False, "errors": []}
results = []
close_is_buy = not is_long # 平多头=卖出,平空头=买入 close_is_buy = not is_long # 平多头=卖出,平空头=买入
# 设置止盈(限价单) # 设置止盈(限价单)— 独立 try-except失败不影响止损
if tp_price: if tp_price:
# 四舍五入价格到合适精度(避免 float_to_wire rounding 错误) try:
tp_price = round(float(tp_price), 5) tp_price = round(float(tp_price), 5)
tp_result = self.exchange.order( tp_result = self.exchange.order(
symbol, close_is_buy, size, tp_price, symbol, close_is_buy, size, tp_price,
{"limit": {"tif": "Gtc"}}, {"limit": {"tif": "Gtc"}},
reduce_only=True reduce_only=True
) )
results.append({"type": "take_profit", "result": tp_result}) # 验证响应
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}") 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}")
# 设置止损(触发单) # 设置止损(触发单)— 独立 try-except失败不影响止盈
if sl_price: if sl_price:
# 触发价格需要稍微偏离(避免滑点问题) try:
exec_px = sl_price * 0.999 if close_is_buy else sl_price * 1.001 # 买单止损exec_px 略高于 trigger接受更高的买入价
# 卖单止损exec_px 略低于 trigger接受更低的卖出价
# 四舍五入价格到合适精度(避免 float_to_wire rounding 错误) exec_px = sl_price * 1.001 if close_is_buy else sl_price * 0.999
# Hyperliquid 要求价格最多 5 位小数
sl_price = round(float(sl_price), 5) sl_price = round(float(sl_price), 5)
exec_px = round(float(exec_px), 5) exec_px = round(float(exec_px), 5)
sl_result = self.exchange.order( sl_result = self.exchange.order(
symbol, close_is_buy, size, exec_px, symbol, close_is_buy, size, exec_px,
{"trigger": {"triggerPx": sl_price, "isMarket": True, "tpsl": "sl"}}, {"trigger": {"triggerPx": sl_price, "isMarket": True, "tpsl": "sl"}},
reduce_only=True reduce_only=True
) )
results.append({"type": "stop_loss", "result": sl_result}) # 验证响应
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}(触发)") logger.info(f"✅ 设置止损: {symbol} @ ${sl_price}(触发)")
else:
return { err_msg = sl_result.get("response", str(sl_result))
"success": True, logger.warning(f"设置止损失败: {symbol} {err_msg}")
"results": results result["errors"].append(f"止损设置失败: {err_msg}")
}
except Exception as e: except Exception as e:
logger.error(f"设置止盈止损失败: {e}") logger.warning(f"设置止损失败: {symbol} {e}")
return { result["errors"].append(f"止损设置失败: {e}")
"success": False,
"error": str(e) # 判断整体成功
} 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
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]: def cancel_tp_sl_orders(self, symbol: str) -> Dict[str, Any]:
""" """

View 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'] == '替换旧挂单'

View 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

View File

@ -77,6 +77,41 @@ def load_bitget_executor_class():
return sys.modules['app.crypto_agent.executor.bitget_executor'].BitgetExecutor 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(): def test_bitget_market_close_position_only_closes_requested_symbol():
service, mock_api = make_bitget_service() service, mock_api = make_bitget_service()
mock_api.get_position.return_value = [ 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 assert result['success'] is True
executor.bitget.update_leverage.assert_called_once_with('ETH', 10) 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) 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,
)

View File

@ -0,0 +1,369 @@
"""
Hyperliquid 真实 API 集成测试
警告此测试会使用真实 API 调用和真实订单
- 使用最小下单量ETHszDecimals=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 失败(例如价格为 0SL 应该仍然被设置
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()

View File

@ -132,8 +132,8 @@ def test_lane_specific_risk_reward_and_distance_thresholds():
signal = { signal = {
"action": "sell", "action": "sell",
"entry_price": 100.0, "entry_price": 100.0,
"stop_loss": 101.0, "stop_loss": 101.6,
"take_profit": 98.0, "take_profit": 96.8,
} }
assert analyzer._meets_min_risk_reward(signal, "short_term") is True 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 = { tighter_signal = {
"action": "sell", "action": "sell",
"entry_price": 100.0, "entry_price": 100.0,
"stop_loss": 101.0, "stop_loss": 100.8,
"take_profit": 98.4, "take_profit": 98.7,
} }
assert analyzer._meets_min_risk_reward(tighter_signal, "short_term") is True 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, "short_term") is True
assert analyzer._meets_min_price_distance(tighter_signal, "medium_term") is False 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(): def test_fibonacci_context_marks_kind_and_trade_zone():
analyzer = make_analyzer() 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"]) formatted = analyzer._format_fib_levels(result["support_details"] or result["resistance_details"])
assert "回撤Fib" in formatted or "扩展Fib" in formatted 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

File diff suppressed because it is too large Load Diff

View File

@ -314,6 +314,39 @@
.pending-signal { .pending-signal {
min-width: 140px; 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> </style>
</head> </head>
<body> <body>
@ -383,6 +416,32 @@
</div> </div>
</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 --> <!-- Real-time Prices -->
<div v-if="Object.keys(latestPrices).length > 0" class="price-section"> <div v-if="Object.keys(latestPrices).length > 0" class="price-section">
<div class="stat-label" style="margin-bottom: 8px;">实时价格</div> <div class="stat-label" style="margin-bottom: 8px;">实时价格</div>
@ -734,7 +793,8 @@
titleClickCount: 0, titleClickCount: 0,
titleClickTimer: null, titleClickTimer: null,
refreshInterval: null, refreshInterval: null,
latestPrices: {} latestPrices: {},
platformHalts: {}
}; };
}, },
computed: { computed: {
@ -805,7 +865,8 @@
this.fetchAccountStatus(), this.fetchAccountStatus(),
this.fetchStatistics(), this.fetchStatistics(),
this.fetchOrders(), this.fetchOrders(),
this.fetchLatestPrices() this.fetchLatestPrices(),
this.fetchPlatformHalts()
]); ]);
} catch (e) { } catch (e) {
console.error('刷新数据失败:', e); console.error('刷新数据失败:', e);
@ -820,7 +881,8 @@
this.fetchAccountStatus(), this.fetchAccountStatus(),
this.fetchStatistics(), this.fetchStatistics(),
this.fetchOrders(), this.fetchOrders(),
this.fetchLatestPrices() this.fetchLatestPrices(),
this.fetchPlatformHalts()
]); ]);
} catch (e) { } catch (e) {
console.error('静默刷新失败:', 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) { async closeOrder(order) {
if (!confirm('确定要平仓吗?')) return; 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() { async sendReport() {
this.sendingReport = true; this.sendingReport = true;
try { try {