This commit is contained in:
aaron 2026-04-27 10:10:08 +08:00
parent e933784555
commit e98b1c3c9c
6 changed files with 142 additions and 8 deletions

View File

@ -10,6 +10,7 @@ from app.services.paper_trading_service import get_paper_trading_service
from app.services.price_monitor_service import get_price_monitor_service 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.services.runtime_status_service import get_runtime_status
from app.utils.logger import logger from app.utils.logger import logger
from app.crypto_agent.crypto_agent import get_crypto_agent from app.crypto_agent.crypto_agent import get_crypto_agent
@ -485,6 +486,11 @@ async def get_monitor_status():
# 始终显示配置的交易对价格 # 始终显示配置的交易对价格
configured_symbols = settings.crypto_symbols.split(',') configured_symbols = settings.crypto_symbols.split(',')
for symbol in configured_symbols:
symbol = symbol.strip().upper()
if symbol:
monitor.subscribe_symbol(symbol)
# 获取价格 - 优先使用监控服务的缓存价格 # 获取价格 - 优先使用监控服务的缓存价格
latest_prices = dict(monitor.latest_prices) latest_prices = dict(monitor.latest_prices)
@ -498,9 +504,11 @@ async def get_monitor_status():
return { return {
"success": True, "success": True,
"running": True, "running": monitor.is_running(),
"mode": "websocket" if getattr(monitor, "_use_websocket", False) else "polling",
"subscribed_symbols": configured_symbols, "subscribed_symbols": configured_symbols,
"latest_prices": latest_prices "latest_prices": latest_prices,
"execution_loop": get_runtime_status("price_monitor_loop"),
} }
except Exception as e: except Exception as e:
logger.error(f"获取监控状态失败: {e}") logger.error(f"获取监控状态失败: {e}")

View File

@ -26,8 +26,14 @@ async def price_monitor_loop():
from app.services.bitget_service import bitget_service from app.services.bitget_service import bitget_service
from app.services.feishu_service import get_feishu_paper_trading_service from app.services.feishu_service import get_feishu_paper_trading_service
from app.services.telegram_service import get_telegram_service from app.services.telegram_service import get_telegram_service
from app.services.runtime_status_service import (
mark_runtime_started,
mark_runtime_heartbeat,
mark_runtime_error,
)
logger.info("后台价格监控任务已启动(轮询模式)") logger.info("后台价格监控任务已启动(轮询模式)")
mark_runtime_started("price_monitor_loop", mode="rest_polling")
feishu = get_feishu_paper_trading_service() # 使用 trading webhook feishu = get_feishu_paper_trading_service() # 使用 trading webhook
telegram = get_telegram_service() telegram = get_telegram_service()
@ -138,6 +144,11 @@ async def price_monitor_loop():
try: try:
# 获取活跃订单 # 获取活跃订单
active_orders = paper_trading.get_active_orders() active_orders = paper_trading.get_active_orders()
mark_runtime_heartbeat(
"price_monitor_loop",
active_orders=len(active_orders),
last_symbols=sorted({order.get('symbol') for order in active_orders if order.get('symbol')}),
)
if not active_orders: if not active_orders:
await asyncio.sleep(10) # 没有活跃订单时10秒检查一次 await asyncio.sleep(10) # 没有活跃订单时10秒检查一次
continue continue
@ -316,6 +327,7 @@ async def price_monitor_loop():
except Exception as e: except Exception as e:
logger.error(f"价格监控循环出错: {e}") logger.error(f"价格监控循环出错: {e}")
mark_runtime_error("price_monitor_loop", str(e))
await asyncio.sleep(5) await asyncio.sleep(5)

View File

@ -111,12 +111,13 @@ class BitgetWebSocketClient:
logger.info("Bitget WebSocket 已断开") logger.info("Bitget WebSocket 已断开")
async def subscribe(self, symbols: list) -> bool: async def subscribe(self, symbols: list, force: bool = False) -> bool:
""" """
订阅交易对价格 订阅交易对价格
Args: Args:
symbols: 交易对列表 ['BTCUSDT', 'ETHUSDT'] symbols: 交易对列表 ['BTCUSDT', 'ETHUSDT']
force: 是否强制重新发送订阅请求用于重连后恢复订阅
Returns: Returns:
是否订阅成功 是否订阅成功
@ -130,13 +131,12 @@ class BitgetWebSocketClient:
# USDT-FUTURES = USDT 永续合约 # USDT-FUTURES = USDT 永续合约
args = [] args = []
for symbol in symbols: for symbol in symbols:
if symbol not in self._subscribed_symbols: if force or symbol not in self._subscribed_symbols:
args.append({ args.append({
"instType": "USDT-FUTURES", "instType": "USDT-FUTURES",
"channel": "ticker", "channel": "ticker",
"instId": symbol "instId": symbol
}) })
self._subscribed_symbols.add(symbol)
if not args: if not args:
logger.info("所有交易对已订阅") logger.info("所有交易对已订阅")
@ -148,6 +148,8 @@ class BitgetWebSocketClient:
} }
await self._ws.send(json.dumps(message)) await self._ws.send(json.dumps(message))
for item in args:
self._subscribed_symbols.add(item["instId"])
logger.info(f"✅ 订阅 {len(args)} 个交易对: {[s['instId'] for s in args]}") logger.info(f"✅ 订阅 {len(args)} 个交易对: {[s['instId'] for s in args]}")
return True return True
@ -397,7 +399,7 @@ class BitgetWebSocketClient:
if await self.connect(): if await self.connect():
# 重新订阅之前的交易对 # 重新订阅之前的交易对
if self._subscribed_symbols: if self._subscribed_symbols:
await self.subscribe(list(self._subscribed_symbols)) await self.subscribe(list(self._subscribed_symbols), force=True)
self._reconnect_task = asyncio.create_task(reconnect()) self._reconnect_task = asyncio.create_task(reconnect())

View File

@ -314,6 +314,9 @@ class PriceMonitorService:
if callback not in self.price_callbacks: if callback not in self.price_callbacks:
self.price_callbacks.append(callback) self.price_callbacks.append(callback)
if not self.running:
self.start()
def remove_price_callback(self, callback: Callable): def remove_price_callback(self, callback: Callable):
"""移除价格回调函数""" """移除价格回调函数"""
with self._lock: with self._lock:
@ -352,4 +355,7 @@ _price_monitor_service: Optional[PriceMonitorService] = None
def get_price_monitor_service() -> PriceMonitorService: def get_price_monitor_service() -> PriceMonitorService:
"""获取价格监控服务单例""" """获取价格监控服务单例"""
# 直接使用类单例,不使用全局变量(避免 reload 时重置) # 直接使用类单例,不使用全局变量(避免 reload 时重置)
return PriceMonitorService() service = PriceMonitorService()
if not service.running:
service.start()
return service

View File

@ -0,0 +1,61 @@
"""
运行时状态服务
用于暴露后台任务如价格监控/模拟盘执行循环的运行状态
方便页面与 API 判断前端价格链路后台执行链路是否正常
"""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime
from typing import Any, Dict
_runtime_status: Dict[str, Dict[str, Any]] = {
"price_monitor_loop": {
"running": False,
"started_at": None,
"last_heartbeat_at": None,
"last_error": "",
"mode": "rest_polling",
"active_orders": 0,
"last_symbols": [],
}
}
def mark_runtime_started(task_name: str, **extra: Any) -> None:
state = _runtime_status.setdefault(task_name, {})
now = datetime.now().isoformat()
state["running"] = True
state["started_at"] = state.get("started_at") or now
state["last_heartbeat_at"] = now
state["last_error"] = ""
state.update(extra)
def mark_runtime_heartbeat(task_name: str, **extra: Any) -> None:
state = _runtime_status.setdefault(task_name, {})
state["running"] = True
state["last_heartbeat_at"] = datetime.now().isoformat()
state.update(extra)
def mark_runtime_error(task_name: str, error: str, **extra: Any) -> None:
state = _runtime_status.setdefault(task_name, {})
state["running"] = True
state["last_heartbeat_at"] = datetime.now().isoformat()
state["last_error"] = str(error or "")
state.update(extra)
def mark_runtime_stopped(task_name: str, **extra: Any) -> None:
state = _runtime_status.setdefault(task_name, {})
state["running"] = False
state["last_heartbeat_at"] = datetime.now().isoformat()
state.update(extra)
def get_runtime_status(task_name: str) -> Dict[str, Any]:
return deepcopy(_runtime_status.get(task_name, {}))

View File

@ -425,7 +425,12 @@
<!-- 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;">
实时价格
<span style="margin-left: 8px; font-size: 12px; color: #8fa0b3;">
{{ priceMonitor.mode === 'websocket' ? 'WebSocket' : '轮询' }} · {{ priceMonitor.running ? '运行中' : '未运行' }}
</span>
</div>
<div class="price-list"> <div class="price-list">
<div class="price-item" v-for="(price, symbol) in latestPrices" :key="symbol"> <div class="price-item" v-for="(price, symbol) in latestPrices" :key="symbol">
<span class="symbol">{{ symbol }}</span> <span class="symbol">{{ symbol }}</span>
@ -434,6 +439,22 @@
</div> </div>
</div> </div>
<div class="price-section" style="margin-top: 12px;">
<div class="stat-label" style="margin-bottom: 8px;">
模拟盘执行链路
<span style="margin-left: 8px; font-size: 12px; color: #8fa0b3;">
{{ executionLoop.running ? '运行中' : '未运行' }}
</span>
</div>
<div style="font-size: 13px; color: #8fa0b3; line-height: 1.7;">
心跳: {{ executionLoop.last_heartbeat_at || '-' }}<br>
活跃订单: {{ executionLoop.active_orders ?? 0 }}<br>
标的: {{ (executionLoop.last_symbols || []).join(', ') || '-' }}<br>
<span v-if="executionLoop.last_error">错误: {{ executionLoop.last_error }}</span>
<span v-else>错误: 无</span>
</div>
</div>
<div v-if="haltEntries.length > 0" class="platform-halts"> <div v-if="haltEntries.length > 0" class="platform-halts">
<div <div
v-for="halt in haltEntries" v-for="halt in haltEntries"
@ -809,6 +830,17 @@
titleClickTimer: null, titleClickTimer: null,
refreshInterval: null, refreshInterval: null,
latestPrices: {}, latestPrices: {},
priceMonitor: {
running: false,
mode: 'unknown'
},
executionLoop: {
running: false,
last_heartbeat_at: null,
active_orders: 0,
last_symbols: [],
last_error: ''
},
platformHalts: {} platformHalts: {}
}; };
}, },
@ -935,6 +967,19 @@
async fetchLatestPrices() { async fetchLatestPrices() {
try { try {
const response = await axios.get('/api/trading/monitor/status'); const response = await axios.get('/api/trading/monitor/status');
if (response.data.success) {
this.priceMonitor = {
running: !!response.data.running,
mode: response.data.mode || 'unknown'
};
this.executionLoop = {
running: !!response.data.execution_loop?.running,
last_heartbeat_at: response.data.execution_loop?.last_heartbeat_at || null,
active_orders: response.data.execution_loop?.active_orders ?? 0,
last_symbols: response.data.execution_loop?.last_symbols || [],
last_error: response.data.execution_loop?.last_error || ''
};
}
if (response.data.success && response.data.latest_prices) { if (response.data.success && response.data.latest_prices) {
this.latestPrices = response.data.latest_prices; this.latestPrices = response.data.latest_prices;
} }