feat: 添加 Redis 消息展示功能到监控面板
- 新增 /api/messages API 接口,支持从 Redis Stream 读取消息 - 支持按策略筛选消息和分页展示 - 前端新增消息列表卡片,展示时间、策略、股票代码、动作、价格和状态 - 自动判断消息处理状态(已处理/待处理) - 消息列表每30秒自动刷新,支持手动刷新
This commit is contained in:
@@ -49,6 +49,19 @@ class ConfigResponse(BaseModel):
|
||||
reconnect_time: str
|
||||
auto_reconnect_enabled: bool
|
||||
|
||||
class MessageItem(BaseModel):
|
||||
"""消息项模型"""
|
||||
message_id: str
|
||||
data: Dict[str, Any]
|
||||
timestamp: str
|
||||
is_processed: bool = False
|
||||
|
||||
class MessagesResponse(BaseModel):
|
||||
"""消息响应模型"""
|
||||
messages: List[MessageItem]
|
||||
total: int
|
||||
strategy_name: str
|
||||
|
||||
|
||||
class FileConfigResponse(BaseModel):
|
||||
"""配置文件响应模型"""
|
||||
@@ -325,6 +338,116 @@ class QMTAPIServer:
|
||||
raw_config=f"读取配置文件失败: {str(e)}",
|
||||
config_path=config_path
|
||||
)
|
||||
@self.app.get("/api/messages", response_model=MessagesResponse, summary="获取Redis消息")
|
||||
def get_messages(
|
||||
strategy: str = Query("all", description="策略名称,all表示所有策略"),
|
||||
count: int = Query(50, ge=1, le=200, description="获取消息数量"),
|
||||
is_backtest: bool = Query(False, description="是否为回测消息")
|
||||
):
|
||||
"""从Redis Stream获取消息列表"""
|
||||
messages = []
|
||||
|
||||
try:
|
||||
# 从manager获取redis连接
|
||||
if hasattr(self.manager, 'pos_manager') and self.manager.pos_manager:
|
||||
r = self.manager.pos_manager.r
|
||||
elif hasattr(self.manager, 'stream_processor') and self.manager.stream_processor:
|
||||
r = self.manager.stream_processor.r
|
||||
else:
|
||||
# 尝试从配置创建连接
|
||||
import redis
|
||||
redis_config = getattr(self.manager.config, 'redis', {})
|
||||
r = redis.Redis(
|
||||
host=redis_config.get('host', 'localhost'),
|
||||
port=redis_config.get('port', 6379),
|
||||
password=redis_config.get('password'),
|
||||
db=redis_config.get('db', 0),
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
if strategy == "all":
|
||||
# 获取所有策略的消息
|
||||
pattern = f"qmt:*:{'backtest' if is_backtest else 'real'}"
|
||||
stream_keys = []
|
||||
for key in r.scan_iter(match=pattern):
|
||||
stream_keys.append(key)
|
||||
else:
|
||||
# 获取指定策略的消息
|
||||
stream_key = f"qmt:{strategy}:{'backtest' if is_backtest else 'real'}"
|
||||
stream_keys = [stream_key]
|
||||
|
||||
# 从每个stream读取消息
|
||||
for stream_key in stream_keys:
|
||||
try:
|
||||
# 使用XREVRANGE获取最新消息
|
||||
stream_msgs = r.xrevrange(stream_key, max="+", min="-", count=count)
|
||||
|
||||
for msg_id, msg_fields in stream_msgs:
|
||||
# 解析消息数据
|
||||
data_str = msg_fields.get("data", "{}")
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
data = {"raw_data": data_str}
|
||||
|
||||
# 解析消息ID获取时间戳
|
||||
# Redis消息ID格式: timestamp-sequence
|
||||
timestamp_ms = int(msg_id.split("-")[0])
|
||||
timestamp = datetime.datetime.fromtimestamp(
|
||||
timestamp_ms / 1000
|
||||
).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 检查消息是否已处理(通过检查pending列表)
|
||||
is_processed = True
|
||||
try:
|
||||
# 获取消费者组的pending消息
|
||||
pending_info = r.xpending(
|
||||
stream_key,
|
||||
"qmt_consumers"
|
||||
)
|
||||
# 如果消息在pending列表中,说明还未确认
|
||||
if pending_info and pending_info.get('pending', 0) > 0:
|
||||
# 获取具体的pending消息ID
|
||||
pending_range = r.xpending_range(
|
||||
stream_key,
|
||||
"qmt_consumers",
|
||||
min="-",
|
||||
max="+",
|
||||
count=100
|
||||
)
|
||||
pending_ids = [item['message_id'] for item in pending_range] if pending_range else []
|
||||
is_processed = msg_id not in pending_ids
|
||||
except:
|
||||
# 如果没有消费者组或出错,默认认为已处理
|
||||
is_processed = True
|
||||
|
||||
messages.append(MessageItem(
|
||||
message_id=msg_id,
|
||||
data=data,
|
||||
timestamp=timestamp,
|
||||
is_processed=is_processed
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"读取stream {stream_key}失败: {e}")
|
||||
continue
|
||||
|
||||
# 按时间戳倒序排序
|
||||
messages.sort(key=lambda x: x.timestamp, reverse=True)
|
||||
|
||||
return MessagesResponse(
|
||||
messages=messages[:count],
|
||||
total=len(messages),
|
||||
strategy_name=strategy
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"获取消息失败: {e}")
|
||||
return MessagesResponse(
|
||||
messages=[],
|
||||
total=0,
|
||||
strategy_name=strategy
|
||||
)
|
||||
|
||||
|
||||
def get_app(self) -> FastAPI:
|
||||
"""获取FastAPI应用实例"""
|
||||
|
||||
@@ -131,6 +131,28 @@
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<n-card title="消息列表" style="margin-top: 20px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<template #header-extra>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<n-select v-model:value="messageFilter.strategy" :options="strategyOptions" size="small" style="width: 120px;" @update:value="fetchMessages" />
|
||||
<n-tag :type="messageStats.unprocessed > 0 ? 'error' : 'success'">
|
||||
{{ messageStats.unprocessed }} 未处理 / {{ messages.length }} 总计
|
||||
</n-tag>
|
||||
<n-button type="primary" size="small" @click="fetchMessages" :loading="messageLoading">刷新</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<n-data-table
|
||||
:columns="messageColumns"
|
||||
:data="messages"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
:pagination="messagePagination"
|
||||
:loading="messageLoading"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<!-- 日志 -->
|
||||
<n-card title="系统日志" style="margin-top: 20px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<template #header-extra>
|
||||
@@ -212,8 +234,8 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, onMounted, onUnmounted, computed, watch } = Vue;
|
||||
const { NCard, NButton, NTag, NGrid, NGi, NDescriptions, NDescriptionsItem, NDataTable, NStatistic, NConfigProvider, NMessageProvider, NNotificationProvider, NDialogProvider, NModal, NTabs, NTabPane, NEmpty } = naive;
|
||||
const { createApp, ref, onMounted, onUnmounted, computed, watch, h } = Vue;
|
||||
const { NCard, NButton, NTag, NGrid, NGi, NDescriptions, NDescriptionsItem, NDataTable, NStatistic, NConfigProvider, NMessageProvider, NNotificationProvider, NDialogProvider, NModal, NTabs, NTabPane, NEmpty, NSelect } = naive;
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
@@ -230,6 +252,74 @@
|
||||
const showConfigModal = ref(false);
|
||||
const fileConfig = ref({ redis: {}, qmt_terminals: [], strategies: {}, raw_config: "", config_path: "" });
|
||||
const configLoading = ref(false);
|
||||
|
||||
// 消息相关
|
||||
const messages = ref([]);
|
||||
const messageLoading = ref(false);
|
||||
const messageFilter = ref({ strategy: 'all' });
|
||||
const messagePagination = ref({ page: 1, pageSize: 10, showSizePicker: true, pageSizes: [10, 20, 50] });
|
||||
|
||||
// 消息表格列
|
||||
const messageColumns = [
|
||||
{ title: '时间', key: 'timestamp', width: 150, fixed: 'left' },
|
||||
{
|
||||
title: '策略',
|
||||
key: 'strategy',
|
||||
width: 120,
|
||||
render: (row) => row.data.strategy_name || '-'
|
||||
},
|
||||
{
|
||||
title: '股票代码',
|
||||
key: 'stock_code',
|
||||
width: 100,
|
||||
render: (row) => row.data.stock_code || '-'
|
||||
},
|
||||
{
|
||||
title: '动作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (row) => {
|
||||
const action = row.data.action;
|
||||
if (action === 'BUY') return h(NTag, { type: 'error', size: 'small' }, { default: () => '买入' });
|
||||
if (action === 'SELL') return h(NTag, { type: 'success', size: 'small' }, { default: () => '卖出' });
|
||||
return action || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
key: 'price',
|
||||
width: 100,
|
||||
render: (row) => row.data.price ? '¥' + row.data.price.toFixed(2) : '-'
|
||||
},
|
||||
{ title: '状态', key: 'is_processed', width: 100, render: (row) => {
|
||||
return row.is_processed
|
||||
? h(NTag, { type: 'success', size: 'small' }, { default: () => '已处理' })
|
||||
: h(NTag, { type: 'warning', size: 'small' }, { default: () => '待处理' });
|
||||
}},
|
||||
{
|
||||
title: '消息ID',
|
||||
key: 'message_id',
|
||||
width: 180,
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
];
|
||||
|
||||
// 策略选项
|
||||
const strategyOptions = computed(() => {
|
||||
const options = [{ label: '全部策略', value: 'all' }];
|
||||
if (fileConfig.value.strategies) {
|
||||
Object.keys(fileConfig.value.strategies).forEach(name => {
|
||||
options.push({ label: name, value: name });
|
||||
});
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
// 消息统计
|
||||
const messageStats = computed(() => {
|
||||
const unprocessed = messages.value.filter(m => !m.is_processed).length;
|
||||
return { unprocessed, total: messages.value.length };
|
||||
});
|
||||
|
||||
// 表格列(使用简单 render,不依赖 h 函数)
|
||||
const realPosColumns = [
|
||||
@@ -326,6 +416,23 @@
|
||||
}
|
||||
};
|
||||
|
||||
// 获取消息列表
|
||||
const fetchMessages = async () => {
|
||||
messageLoading.value = true;
|
||||
try {
|
||||
const strategy = messageFilter.value.strategy;
|
||||
const url = `${API_BASE}/api/messages?strategy=${strategy}&count=50`;
|
||||
const res = await fetch(url).then(r => r.ok ? r.json() : { messages: [] });
|
||||
messages.value = res.messages || [];
|
||||
console.log('[DEBUG] 消息已更新', messages.value.length);
|
||||
} catch (e) {
|
||||
console.error('[MESSAGES] 获取消息失败:', e);
|
||||
messages.value = [];
|
||||
} finally {
|
||||
messageLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 复制配置到剪贴板
|
||||
const copyConfig = () => {
|
||||
if (fileConfig.value.raw_config) {
|
||||
@@ -353,7 +460,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => { fetchData(); timer = setInterval(fetchData, 30000); });
|
||||
onMounted(() => { fetchData(); fetchMessages(); timer = setInterval(() => { fetchData(); fetchMessages(); }, 30000); });
|
||||
onUnmounted(() => { if (timer) clearInterval(timer); unwatch(); });
|
||||
|
||||
return {
|
||||
@@ -366,7 +473,10 @@
|
||||
realPosColumns, virtualPosColumns,
|
||||
// 配置相关
|
||||
showConfigModal, fileConfig, configLoading,
|
||||
fetchConfig, copyConfig
|
||||
fetchConfig, copyConfig,
|
||||
// 消息相关
|
||||
messages, messageLoading, messageFilter, messagePagination,
|
||||
messageColumns, strategyOptions, messageStats, fetchMessages
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -375,4 +485,4 @@
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -725,11 +725,15 @@ class MultiEngineManager:
|
||||
)
|
||||
if is_trading:
|
||||
for s_name in self.config.strategies.keys():
|
||||
self.process_route(s_name)
|
||||
self.process_route(s_name)
|
||||
self.process_route(s_name, is_trading_hours=True)
|
||||
self.process_route(s_name, is_trading_hours=True)
|
||||
else:
|
||||
# 休盘后:消费消息但不下单
|
||||
for s_name in self.config.strategies.keys():
|
||||
self.process_route(s_name, is_trading_hours=False)
|
||||
|
||||
# --- 收盘结算与标志位重置 ---
|
||||
elif "150500" <= curr_hms <= "151500":
|
||||
if "150500" <= curr_hms <= "151500":
|
||||
for unit in self.units.values():
|
||||
if unit.settler and not unit.settler.has_settled:
|
||||
unit.settler.run_settlement()
|
||||
@@ -744,19 +748,20 @@ class MultiEngineManager:
|
||||
self.logger.error(traceback.format_exc())
|
||||
time.sleep(10)
|
||||
|
||||
def process_route(self, strategy_name):
|
||||
def process_route(self, strategy_name, is_trading_hours=True):
|
||||
"""处理策略消息路由 - 使用 Redis Stream
|
||||
|
||||
从 Redis Stream 消费消息,处理成功后 ACK,失败则进入失败队列。
|
||||
|
||||
Args:
|
||||
strategy_name: 策略名称
|
||||
is_trading_hours: 是否在交易时间内,False 表示休盘后只消费消息不下单
|
||||
"""
|
||||
strat_cfg = self.config.get_strategy(strategy_name)
|
||||
if not strat_cfg:
|
||||
self.logger.warning(f"[{strategy_name}] 策略配置不存在")
|
||||
return
|
||||
unit = self.units.get(strat_cfg.qmt_id)
|
||||
if not unit or not unit.callback or not unit.callback.is_connected:
|
||||
return
|
||||
unit = self.units.get(strat_cfg.get("qmt_id"))
|
||||
if not unit or not unit.callback or not unit.callback.is_connected:
|
||||
return
|
||||
|
||||
@@ -808,19 +813,37 @@ class MultiEngineManager:
|
||||
result=True,
|
||||
)
|
||||
|
||||
# 3. 执行交易动作
|
||||
# 3. 执行交易动作(仅在交易时间内执行实际下单)
|
||||
action = data.get("action")
|
||||
|
||||
# 获取策略配置,确定下单模式
|
||||
# 获取策略配置,确定下单模式
|
||||
strat_cfg = self.config.get_strategy(strategy_name)
|
||||
if not strat_cfg:
|
||||
self.logger.warning(f"[{strategy_name}] 策略配置不存在")
|
||||
continue
|
||||
order_mode = strat_cfg.order_mode
|
||||
order_mode = strat_cfg.order_mode
|
||||
order_mode = strat_cfg.get("order_mode", "slots")
|
||||
|
||||
if action == "BUY":
|
||||
if not is_trading_hours:
|
||||
# 休盘后:只记录日志,不下单
|
||||
self.logger.info(
|
||||
f"[{strategy_name}] [休盘后消息处理] "
|
||||
f"action={action}, code={data.get('stock_code')}, "
|
||||
f"order_mode={order_mode}, msg_time={data.get('timestamp')} - "
|
||||
f"仅消费消息,跳过实际下单"
|
||||
)
|
||||
self.qmt_logger.log_validation(
|
||||
validation_type="market_hours_check",
|
||||
strategy_name=strategy_name,
|
||||
details={
|
||||
"action": action,
|
||||
"code": data.get("stock_code"),
|
||||
"order_mode": order_mode,
|
||||
"msg_time": data.get("timestamp"),
|
||||
"skip_reason": "休盘后只消费消息不下单"
|
||||
},
|
||||
result=True,
|
||||
)
|
||||
elif action == "BUY":
|
||||
self.qmt_logger.log_validation(
|
||||
validation_type="action_check",
|
||||
strategy_name=strategy_name,
|
||||
|
||||
Reference in New Issue
Block a user