258 lines
13 KiB
HTML
258 lines
13 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="zh-CN">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>QMT 实盘监控看板</title>
|
||
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||
|
|
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
|
||
|
|
<script src="https://unpkg.com/element-plus"></script>
|
||
|
|
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
|
||
|
|
<style>
|
||
|
|
body { background-color: #f0f2f5; margin: 0; padding: 20px; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', sans-serif; }
|
||
|
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||
|
|
.box-card { margin-bottom: 20px; }
|
||
|
|
.log-box {
|
||
|
|
background: #1e1e1e;
|
||
|
|
color: #d4d4d4;
|
||
|
|
padding: 10px;
|
||
|
|
border-radius: 4px;
|
||
|
|
height: 400px;
|
||
|
|
overflow-y: scroll;
|
||
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
||
|
|
font-size: 13px;
|
||
|
|
line-height: 1.5;
|
||
|
|
}
|
||
|
|
.log-line { margin: 0; border-bottom: 1px solid #333; white-space: pre-wrap; word-break: break-all; }
|
||
|
|
.log-line:hover { background-color: #2a2a2a; }
|
||
|
|
.virtual-item { margin-bottom: 20px; border-left: 4px solid #409EFF; padding-left: 10px; }
|
||
|
|
.virtual-title { font-weight: bold; font-size: 14px; margin-bottom: 8px; color: #606266; }
|
||
|
|
.status-badge { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; }
|
||
|
|
.bg-green { background-color: #67C23A; }
|
||
|
|
.bg-gray { background-color: #909399; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div id="app">
|
||
|
|
<el-container>
|
||
|
|
<el-header height="auto" style="padding: 0;">
|
||
|
|
<el-card class="box-card">
|
||
|
|
<template #header>
|
||
|
|
<div class="card-header">
|
||
|
|
<div style="display:flex; align-items:center;">
|
||
|
|
<el-icon size="24" style="margin-right: 10px;"><Monitor /></el-icon>
|
||
|
|
<span style="font-weight: bold; font-size: 20px;">QMT 实盘守护系统</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<el-tag :type="status.running ? 'success' : 'info'" effect="dark" style="margin-right: 10px;">
|
||
|
|
API: {{ status.running ? 'Running' : 'Offline' }}
|
||
|
|
</el-tag>
|
||
|
|
<el-tag :type="status.qmt_connected ? 'success' : 'danger'" effect="dark">
|
||
|
|
QMT: {{ status.qmt_connected ? 'Connected' : 'Disconnected' }}
|
||
|
|
</el-tag>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
<el-descriptions border :column="4" size="large">
|
||
|
|
<el-descriptions-item label="资金账号">{{ status.account_id || '---' }}</el-descriptions-item>
|
||
|
|
<el-descriptions-item label="启动时间">{{ status.start_time || '---' }}</el-descriptions-item>
|
||
|
|
<el-descriptions-item label="心跳时间">
|
||
|
|
<span :style="{color: isHeartbeatStalled ? 'red' : 'green', fontWeight: 'bold'}">
|
||
|
|
{{ status.last_loop_update || '---' }}
|
||
|
|
</span>
|
||
|
|
</el-descriptions-item>
|
||
|
|
<el-descriptions-item label="控制">
|
||
|
|
<el-button type="primary" :icon="Refresh" @click="manualRefresh" :loading="loading">手动刷新</el-button>
|
||
|
|
|
||
|
|
<div style="margin-left: 15px; display: inline-flex; align-items: center;">
|
||
|
|
<el-checkbox v-model="autoRefresh" label="自动刷新(1min)" border></el-checkbox>
|
||
|
|
<span style="font-size: 12px; margin-left: 8px; color: #909399;">
|
||
|
|
{{ tradingStatusText }}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</el-descriptions-item>
|
||
|
|
</el-descriptions>
|
||
|
|
</el-card>
|
||
|
|
</el-header>
|
||
|
|
|
||
|
|
<el-main style="padding: 0;">
|
||
|
|
<el-row :gutter="20">
|
||
|
|
<el-col :span="12">
|
||
|
|
<el-card class="box-card" shadow="hover">
|
||
|
|
<template #header>
|
||
|
|
<div class="card-header">
|
||
|
|
<span><span class="status-badge bg-green"></span>实盘真实持仓 (QMT)</span>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
<el-table :data="positions.real_positions" style="width: 100%" border stripe size="small" empty-text="当前空仓">
|
||
|
|
<el-table-column prop="code" label="代码" width="100" sortable></el-table-column>
|
||
|
|
<el-table-column prop="volume" label="总持仓" width="100"></el-table-column>
|
||
|
|
<el-table-column prop="can_use" label="可用" width="100"></el-table-column>
|
||
|
|
<el-table-column prop="market_value" label="市值"></el-table-column>
|
||
|
|
</el-table>
|
||
|
|
</el-card>
|
||
|
|
</el-col>
|
||
|
|
|
||
|
|
<el-col :span="12">
|
||
|
|
<el-card class="box-card" shadow="hover">
|
||
|
|
<template #header>
|
||
|
|
<div class="card-header">
|
||
|
|
<span><span class="status-badge bg-gray"></span>Redis 虚拟账本 (策略隔离)</span>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
<div v-if="Object.keys(positions.virtual_positions).length === 0" style="color:#909399; text-align:center; padding: 20px;">
|
||
|
|
暂无策略数据 / Redis未连接
|
||
|
|
</div>
|
||
|
|
<div v-for="(posObj, strategyName) in positions.virtual_positions" :key="strategyName" class="virtual-item">
|
||
|
|
<div class="virtual-title">{{ strategyName }}</div>
|
||
|
|
<el-table :data="formatVirtual(posObj)" style="width: 100%;" border size="small" empty-text="该策略当前空仓">
|
||
|
|
<el-table-column prop="code" label="代码"></el-table-column>
|
||
|
|
<el-table-column prop="vol" label="记账数量">
|
||
|
|
<template #default="scope"><span style="font-weight: bold;">{{ scope.row.vol }}</span></template>
|
||
|
|
</el-table-column>
|
||
|
|
</el-table>
|
||
|
|
</div>
|
||
|
|
</el-card>
|
||
|
|
</el-col>
|
||
|
|
</el-row>
|
||
|
|
|
||
|
|
<el-row>
|
||
|
|
<el-col :span="24">
|
||
|
|
<el-card class="box-card" shadow="never">
|
||
|
|
<template #header>
|
||
|
|
<div class="card-header"><span>系统实时日志 (Last 50 lines)</span></div>
|
||
|
|
</template>
|
||
|
|
<div class="log-box" ref="logBox">
|
||
|
|
<div v-for="(line, index) in logs" :key="index" class="log-line">{{ line }}</div>
|
||
|
|
</div>
|
||
|
|
</el-card>
|
||
|
|
</el-col>
|
||
|
|
</el-row>
|
||
|
|
</el-main>
|
||
|
|
</el-container>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
const { createApp, ref, onMounted, onUnmounted, computed } = Vue;
|
||
|
|
const { Monitor, Refresh } = ElementPlusIconsVue;
|
||
|
|
|
||
|
|
const app = createApp({
|
||
|
|
setup() {
|
||
|
|
const status = ref({});
|
||
|
|
const positions = ref({ real_positions: [], virtual_positions: {} });
|
||
|
|
const logs = ref([]);
|
||
|
|
const autoRefresh = ref(true); // 默认开启自动刷新
|
||
|
|
const loading = ref(false);
|
||
|
|
const logBox = ref(null);
|
||
|
|
let timer = null;
|
||
|
|
|
||
|
|
const API_BASE = "";
|
||
|
|
|
||
|
|
// === 核心逻辑修改:判断是否为交易时间 ===
|
||
|
|
const isTradingTime = () => {
|
||
|
|
const now = new Date();
|
||
|
|
const day = now.getDay();
|
||
|
|
const hour = now.getHours();
|
||
|
|
const minute = now.getMinutes();
|
||
|
|
const currentTimeVal = hour * 100 + minute;
|
||
|
|
|
||
|
|
// 1. 判断是否为周末 (0是周日, 6是周六)
|
||
|
|
if (day === 0 || day === 6) return false;
|
||
|
|
|
||
|
|
// 2. 判断时间段 (09:00 - 15:10)
|
||
|
|
// 包含集合竞价和收盘清算时间
|
||
|
|
if (currentTimeVal >= 900 && currentTimeVal <= 1510) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
};
|
||
|
|
|
||
|
|
// 界面显示的提示文本
|
||
|
|
const tradingStatusText = computed(() => {
|
||
|
|
if (!autoRefresh.value) return "已关闭";
|
||
|
|
return isTradingTime() ? "监控中..." : "休市暂停";
|
||
|
|
});
|
||
|
|
|
||
|
|
const isHeartbeatStalled = computed(() => {
|
||
|
|
if (!status.value.last_loop_update) return true;
|
||
|
|
return false;
|
||
|
|
});
|
||
|
|
|
||
|
|
const fetchData = async () => {
|
||
|
|
loading.value = true;
|
||
|
|
try {
|
||
|
|
const resStatus = await fetch(`${API_BASE}/api/status`);
|
||
|
|
if(resStatus.ok) status.value = await resStatus.json();
|
||
|
|
else status.value = { running: false };
|
||
|
|
|
||
|
|
const resPos = await fetch(`${API_BASE}/api/positions`);
|
||
|
|
if(resPos.ok) positions.value = await resPos.json();
|
||
|
|
|
||
|
|
const resLogs = await fetch(`${API_BASE}/api/logs`);
|
||
|
|
if(resLogs.ok) {
|
||
|
|
const logData = await resLogs.json();
|
||
|
|
const needScroll = (logs.value.length !== logData.logs.length);
|
||
|
|
logs.value = logData.logs;
|
||
|
|
|
||
|
|
// 只有在自动刷新且有新日志时才自动滚动
|
||
|
|
if (needScroll && autoRefresh.value) {
|
||
|
|
setTimeout(() => {
|
||
|
|
if(logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight;
|
||
|
|
}, 100);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
console.error("API Error:", e);
|
||
|
|
status.value.running = false;
|
||
|
|
} finally {
|
||
|
|
loading.value = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 手动刷新按钮:不受时间限制
|
||
|
|
const manualRefresh = () => {
|
||
|
|
fetchData();
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatVirtual = (obj) => {
|
||
|
|
if (!obj) return [];
|
||
|
|
return Object.keys(obj).map(key => ({ code: key, vol: obj[key] }));
|
||
|
|
};
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
// 页面加载时先拉取一次
|
||
|
|
fetchData();
|
||
|
|
|
||
|
|
// === 修改定时器:每 60 秒触发一次 ===
|
||
|
|
timer = setInterval(() => {
|
||
|
|
// 只有在 "开关开启" 且 "处于交易时间" 时才请求
|
||
|
|
if (autoRefresh.value && isTradingTime()) {
|
||
|
|
fetchData();
|
||
|
|
}
|
||
|
|
}, 60000); // 60000ms = 1分钟
|
||
|
|
});
|
||
|
|
|
||
|
|
onUnmounted(() => {
|
||
|
|
if (timer) clearInterval(timer);
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
status, positions, logs, autoRefresh, loading, logBox,
|
||
|
|
manualRefresh, // 绑定到按钮
|
||
|
|
fetchData,
|
||
|
|
formatVirtual,
|
||
|
|
isHeartbeatStalled,
|
||
|
|
tradingStatusText, // 绑定到提示文本
|
||
|
|
Monitor, Refresh
|
||
|
|
};
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||
|
|
app.component(key, component)
|
||
|
|
}
|
||
|
|
app.use(ElementPlus);
|
||
|
|
app.mount('#app');
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|