stock-ai-agent/backend/app/api/system.py
2026-04-27 14:51:18 +08:00

550 lines
22 KiB
Python

"""
系统状态 API
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends
from typing import Dict, Any
import numpy as np
from app.config import get_settings
from app.middleware.console_auth import require_console_access
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_all_bitget_live_services, get_bitget_live_service
from app.services.price_monitor_service import get_price_monitor_service
from app.services.runtime_status_service import get_runtime_status
router = APIRouter()
def _sanitize_for_response(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _sanitize_for_response(item) for key, item in value.items()}
if isinstance(value, list):
return [_sanitize_for_response(item) for item in value]
if isinstance(value, tuple):
return [_sanitize_for_response(item) for item in value]
if isinstance(value, set):
return [_sanitize_for_response(item) for item in value]
if isinstance(value, np.bool_):
return bool(value)
if isinstance(value, np.integer):
return int(value)
if isinstance(value, np.floating):
return float(value)
if isinstance(value, np.ndarray):
return [_sanitize_for_response(item) for item in value.tolist()]
if isinstance(value, np.generic):
return value.item()
return value
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
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except (TypeError, ValueError):
return default
def _normalize_platform_position(platform: str, position: Dict[str, Any]) -> Dict[str, Any]:
side_raw = str(position.get("side") or "").lower()
side = "long" if side_raw in {"buy", "long"} else "short"
symbol = position.get("symbol") or position.get("coin") or "-"
if isinstance(symbol, str) and symbol.endswith("USDTUSDT"):
symbol = symbol.replace("USDTUSDT", "USDT")
entry_price = _safe_float(position.get("entry_price") or position.get("filled_price"))
mark_price = _safe_float(position.get("mark_price") or position.get("current_price"))
if mark_price <= 0:
mark_price = entry_price
size = abs(_safe_float(position.get("size") or position.get("quantity")))
leverage = _safe_float(position.get("leverage"))
margin = _safe_float(position.get("margin") or position.get("initialMargin") or position.get("initial_margin"))
unrealized_pnl = _safe_float(position.get("unrealized_pnl") or position.get("pnl_amount"))
pnl_percent = _safe_float(position.get("unrealized_pnl_pct") or position.get("pnl_percent") or position.get("percentage"))
return {
"platform": platform,
"symbol": symbol,
"side": side,
"size": size,
"entry_price": entry_price,
"mark_price": mark_price,
"leverage": leverage,
"margin": margin,
"unrealized_pnl": unrealized_pnl,
"pnl_percent": pnl_percent,
"take_profit": position.get("take_profit"),
"stop_loss": position.get("stop_loss"),
"liquidation_price": position.get("liquidation_price"),
"setup_type": position.get("setup_type"),
"setup_basis": position.get("setup_basis"),
"entry_basis": position.get("entry_basis"),
"opened_at": position.get("opened_at") or position.get("created_at"),
}
def _normalize_platform_order(platform: str, order: Dict[str, Any]) -> Dict[str, Any]:
side_raw = str(order.get("side") or "").lower()
side = "long" if side_raw in {"buy", "long", "b"} else "short"
symbol = order.get("symbol") or order.get("coin") or "-"
if isinstance(symbol, str) and symbol.endswith("USDTUSDT"):
symbol = symbol.replace("USDTUSDT", "USDT")
price = _safe_float(order.get("price") or order.get("entry_price"))
size = abs(_safe_float(order.get("size") or order.get("quantity")))
order_type = str(order.get("order_type") or order.get("entry_type") or order.get("type") or "").lower()
status = str(order.get("status") or "").lower()
is_reduce_only = bool(order.get("is_reduce_only"))
category = "tp_sl" if is_reduce_only else "entry"
if platform == "paper" and status == "pending":
category = "entry"
return {
"platform": platform,
"account_id": order.get("account_id"),
"symbol": symbol,
"side": side,
"category": category,
"price": price,
"size": size,
"leverage": _safe_float(order.get("leverage")),
"margin": _safe_float(order.get("margin")),
"order_type": order_type,
"status": status,
"stop_loss": order.get("stop_loss"),
"take_profit": order.get("take_profit"),
"signal_grade": order.get("signal_grade"),
"signal_type": order.get("signal_type"),
"setup_type": order.get("setup_type"),
"setup_basis": order.get("setup_basis"),
"entry_basis": order.get("entry_basis"),
"confidence": _safe_float(order.get("confidence")),
"created_at": order.get("created_at") or order.get("timestamp"),
}
def _build_bitget_account_summary(account_id: str, service: Any) -> Dict[str, Any]:
bg_account = service.get_account_state()
bg_positions = service.get_open_positions()
bg_orders = 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 service.initial_balance and service.initial_balance > 0:
bg_drawdown = (service.initial_balance - bg_account["account_value"]) / service.initial_balance * 100
return {
"account_id": account_id,
"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": 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": service.max_total_leverage,
"drawdown_percent": bg_drawdown,
"circuit_breaker_threshold": service.circuit_breaker_drawdown * 100,
},
}
def _build_attention_items(
platform_halts: Dict[str, Any],
platforms: Dict[str, Any],
unified_positions: list[Dict[str, Any]],
unified_orders: list[Dict[str, Any]],
execution_events: list[Dict[str, Any]],
) -> list[Dict[str, Any]]:
items: list[Dict[str, Any]] = []
for platform, halt in (platform_halts or {}).items():
if halt and halt.get("halted"):
items.append({
"severity": "danger",
"title": f"{platform} 已停机",
"detail": halt.get("reason") or "平台已触发停机/熔断",
"timestamp": halt.get("halted_at"),
})
for platform_name, payload in (platforms or {}).items():
if payload.get("enabled") is False:
continue
risk = payload.get("risk") or {}
current_leverage = _safe_float(risk.get("current_leverage"))
max_leverage = _safe_float(risk.get("max_leverage"))
drawdown_pct = _safe_float(risk.get("drawdown_percent") or risk.get("drawdown"))
breaker_pct = _safe_float(risk.get("circuit_breaker_threshold"), 25.0)
if breaker_pct > 0 and drawdown_pct >= breaker_pct * 0.7:
items.append({
"severity": "warning",
"title": f"{platform_name} 回撤接近熔断",
"detail": f"当前回撤 {drawdown_pct:.1f}% / 阈值 {breaker_pct:.1f}%",
"timestamp": None,
})
if max_leverage > 0 and current_leverage >= max_leverage * 0.8:
items.append({
"severity": "warning",
"title": f"{platform_name} 杠杆占用偏高",
"detail": f"当前总杠杆 {current_leverage:.2f}x / 上限 {max_leverage:.2f}x",
"timestamp": None,
})
for pos in unified_positions:
if not pos.get("stop_loss") or not pos.get("take_profit"):
items.append({
"severity": "warning",
"title": f"{pos['platform']} {pos['symbol']} 风控不完整",
"detail": "持仓缺少止盈或止损,请确认执行层保护单状态",
"timestamp": pos.get("opened_at"),
})
pending_entry_count = sum(1 for order in unified_orders if order.get("category") == "entry")
if pending_entry_count > 0:
items.append({
"severity": "info",
"title": "存在待成交入场单",
"detail": f"当前共有 {pending_entry_count} 笔待成交入场单,建议关注价格接近度和资金占用。",
"timestamp": None,
})
for event in (execution_events or [])[:8]:
if event.get("status") in {"error", "warning"}:
items.append({
"severity": event.get("status"),
"title": f"{event.get('platform', '-')}: {event.get('event_type', '-')}",
"detail": event.get("reason") or "最近执行事件需要关注",
"timestamp": event.get("timestamp"),
})
return items[:12]
@router.get("/status", response_model=Dict[str, Any])
async def get_system_status():
"""
获取系统状态
返回所有 Agent 的运行状态和系统信息
"""
try:
monitor = get_system_monitor()
summary = monitor.get_summary()
# 添加额外的系统信息
response = {
"status": "success",
"data": summary
}
return response
except Exception as e:
logger.error(f"获取系统状态失败: {e}")
raise HTTPException(status_code=500, detail=f"获取系统状态失败: {str(e)}")
@router.get("/status/summary")
async def get_status_summary():
"""
获取系统状态摘要
返回简化的状态信息,用于快速检查
"""
try:
monitor = get_system_monitor()
summary = monitor.get_summary()
return {
"status": "success",
"data": {
"total_agents": summary["total_agents"],
"running_agents": summary["running_agents"],
"error_agents": summary["error_agents"],
"uptime_seconds": summary["uptime_seconds"],
"agents": {
agent_id: {
"name": info["name"],
"status": info["status"]
}
for agent_id, info in summary["agents"].items()
}
}
}
except Exception as e:
logger.error(f"获取状态摘要失败: {e}")
raise HTTPException(status_code=500, detail=f"获取状态摘要失败: {str(e)}")
@router.get("/status/agents/{agent_id}")
async def get_agent_status(agent_id: str):
"""
获取指定 Agent 的详细状态
Args:
agent_id: Agent ID (如: crypto_agent)
"""
try:
monitor = get_system_monitor()
agent_info = monitor.get_agent_status(agent_id)
if agent_info is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' 不存在")
return {
"status": "success",
"data": agent_info.to_dict()
}
except HTTPException:
raise
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(_: dict = Depends(require_console_access)):
"""
获取总控台快照
聚合系统运行态、信号统计、模拟盘与实盘平台状态,供总控台页面使用。
"""
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_crypto_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_services = get_all_bitget_live_services()
if not bitget_services:
default_bitget = get_bitget_live_service()
if default_bitget:
bitget_services = {"default": default_bitget}
bitget_accounts = []
for account_id, service in bitget_services.items():
try:
bitget_accounts.append(_build_bitget_account_summary(account_id, service))
except Exception as exc:
logger.error(f"获取 Bitget 账号摘要失败: account={account_id} error={exc}")
if bitget_accounts:
total_account_value = sum(item["account"]["account_value"] for item in bitget_accounts)
total_available_balance = sum(item["account"]["available_balance"] for item in bitget_accounts)
total_margin_used = sum(item["account"]["total_margin_used"] for item in bitget_accounts)
total_positions_count = sum(item["positions"]["count"] for item in bitget_accounts)
total_position_value = sum(item["positions"]["total_value"] for item in bitget_accounts)
total_orders_count = sum(item["orders"]["count"] for item in bitget_accounts)
total_entry_orders = sum(item["orders"]["entry_orders"] for item in bitget_accounts)
total_tp_sl_orders = sum(item["orders"]["tp_sl_orders"] for item in bitget_accounts)
leverage_weight = total_account_value if total_account_value > 0 else len(bitget_accounts)
weighted_drawdown = sum(
item["risk"]["drawdown_percent"] * (
item["account"]["account_value"] if total_account_value > 0 else 1
)
for item in bitget_accounts
) / leverage_weight if leverage_weight > 0 else 0
max_leverage = max((item["risk"]["max_leverage"] for item in bitget_accounts), default=0)
breaker_threshold = max((item["risk"]["circuit_breaker_threshold"] for item in bitget_accounts), default=0)
bitget_summary = {
"enabled": True,
"accounts": bitget_accounts,
"account": {
"account_value": total_account_value,
"available_balance": total_available_balance,
"total_margin_used": total_margin_used,
},
"positions": {
"count": total_positions_count,
"total_value": total_position_value,
"items": [item for account in bitget_accounts for item in account["positions"]["items"]][:12],
},
"orders": {
"count": total_orders_count,
"entry_orders": total_entry_orders,
"tp_sl_orders": total_tp_sl_orders,
"items": [item for account in bitget_accounts for item in account["orders"]["items"]][:12],
},
"risk": {
"current_leverage": total_position_value / total_account_value if total_account_value > 0 else 0,
"max_leverage": max_leverage,
"drawdown_percent": weighted_drawdown,
"circuit_breaker_threshold": breaker_threshold,
},
}
else:
bitget_summary = {"enabled": False, "accounts": []}
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
)
price_monitor = get_price_monitor_service()
configured_symbols = [symbol.strip().upper() for symbol in (get_settings().crypto_symbols or "").split(",") if symbol.strip()]
for symbol in configured_symbols:
price_monitor.subscribe_symbol(symbol)
paper_position_items = [
_normalize_platform_position("paper", pos)
for pos in paper_service.get_open_positions()[:12]
]
paper_order_items = [
_normalize_platform_order("paper", order)
for order in paper_pending[:12]
]
bitget_position_items = []
bitget_order_items = []
for account in bitget_accounts:
for pos in account["positions"]["items"][:12]:
normalized = _normalize_platform_position("bitget", pos)
normalized["account_id"] = account["account_id"]
bitget_position_items.append(normalized)
for order in account["orders"]["items"][:12]:
enriched_order = dict(order)
enriched_order["account_id"] = account["account_id"]
bitget_order_items.append(_normalize_platform_order("bitget", enriched_order))
unified_positions = sorted(
paper_position_items + bitget_position_items,
key=lambda item: _parse_signal_timestamp(item.get("opened_at")) or datetime.min,
reverse=True,
)
unified_orders = sorted(
paper_order_items + bitget_order_items,
key=lambda item: _parse_signal_timestamp(item.get("created_at")) or datetime.min,
reverse=True,
)
platforms_payload = {
"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", {}),
},
"risk": {
"current_leverage": paper_account.get("current_total_leverage", 0),
"max_leverage": paper_account.get("max_total_leverage", 0),
"drawdown_percent": paper_account.get("max_drawdown", 0),
"circuit_breaker_threshold": 25,
},
},
"bitget": bitget_summary,
}
execution_events = crypto_agent.get_recent_execution_events(limit=40)
attention_items = _build_attention_items(
crypto_status.get("platform_halts", {}),
platforms_payload,
unified_positions,
unified_orders,
execution_events,
)
payload = {
"status": "success",
"data": {
"generated_at": now.isoformat(),
"system": summary,
"crypto_agent": crypto_status,
"execution_events": execution_events,
"signals": {
"stats_7d": {
"crypto": signal_stats.get("crypto", {"total": 0, "buy": 0, "sell": 0, "recent_24h": 0}),
"grades": signal_stats.get("grades", {}),
"total": signal_stats.get("crypto", {}).get("total", 0),
},
"latest": latest_signals,
"recent_30m_count": recent_signal_count,
},
"platforms": platforms_payload,
"monitoring": {
"price_monitor": {
"running": price_monitor.is_running(),
"mode": "websocket" if getattr(price_monitor, "_use_websocket", False) else "polling",
"subscribed_symbols": price_monitor.get_subscribed_symbols(),
"latest_prices": price_monitor.get_all_prices(),
"checked_at": now.isoformat(),
},
"execution_loop": get_runtime_status("price_monitor_loop"),
},
"management": {
"positions": unified_positions[:18],
"orders": unified_orders[:24],
"attention_items": attention_items,
},
}
}
return _sanitize_for_response(payload)
except Exception as e:
logger.error(f"获取总控台快照失败: {e}")
raise HTTPException(status_code=500, detail=f"获取总控台快照失败: {str(e)}")