Files
stock-analysis/web/index.html
WarrenZhou 273fc90a95 feat: 添加异步数据入库任务系统,支持全量/增量更新与实时进度展示
新增完整的异步任务管理体系:
1. 新增Redis缓存层实现任务状态实时存储与降级机制
2. 实现并发拉取+批量写入的高性能入库流程
3. 添加任务管理HTTP接口与路由
4. 重构行情控制器使用共享SQLite存储
5. 配套添加Docker Compose Redis启动配置
6. 前端新增任务管理面板支持操作与进度查看
2026-08-18 10:09:52 +08:00

1238 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>可转债轮动分析系统</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
/* ============ 四大事务所风格全局样式 ============ */
/* ============ Header ============ */
.header {
background: linear-gradient(135deg, var(--navy) 0%, var(--navy-light) 100%);
color: var(--white);
padding: 16px 32px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 12px rgba(13,27,42,0.15);
}
.header-left { display: flex; align-items: center; gap: 16px; }
.header h1 {
font-size: 20px;
font-weight: 600;
letter-spacing: 0.5px;
}
.header .subtitle {
font-size: 12px;
opacity: 0.6;
font-weight: 400;
letter-spacing: 0.3px;
}
/* ============ Tabs ============ */
.tabs {
display: flex;
background: var(--white);
border-bottom: 1px solid var(--gray-100);
padding: 0 32px;
position: sticky;
top: 0;
z-index: 100;
}
.tab {
padding: 14px 28px;
cursor: pointer;
color: var(--gray-500);
font-size: 14px;
font-weight: 500;
position: relative;
transition: color var(--transition);
letter-spacing: 0.3px;
}
.tab::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 3px;
background: var(--blue);
border-radius: 3px 3px 0 0;
transition: width var(--transition);
}
.tab:hover { color: var(--navy); }
.tab.active {
color: var(--blue);
font-weight: 600;
}
.tab.active::after { width: 60%; }
/* ============ Content ============ */
.content { padding: 24px 32px; max-width: 1440px; margin: 0 auto; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ============ Toolbar ============ */
.toolbar { margin-bottom: 16px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.toolbar input, .toolbar select {
padding: 8px 14px;
border: 1px solid var(--gray-200);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--gray-900);
background: var(--white);
transition: border-color var(--transition);
}
.toolbar input:focus, .toolbar select:focus {
border-color: var(--blue);
outline: none;
box-shadow: 0 0 0 3px var(--blue-light);
}
.toolbar .spacer { flex: 1; }
#status { color: var(--gray-500); font-size: 13px; }
/* ============ Table ============ */
.table-wrap {
overflow-x: auto;
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-100);
}
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td {
border-bottom: 1px solid var(--gray-100);
padding: 10px 14px;
text-align: right;
white-space: nowrap;
}
th {
background: var(--gray-50);
color: var(--gray-700);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
position: sticky;
top: 0;
cursor: pointer;
user-select: none;
}
th:hover { background: #E8ECF2; }
th .sort-arrow { margin-left: 4px; font-size: 10px; color: var(--gray-500); }
td:first-child, td:nth-child(2), th:first-child, th:nth-child(2) { text-align: left; }
tr:hover { background: var(--blue-light); }
.pos { color: var(--red); font-weight: 600; }
.neg { color: var(--green); font-weight: 600; }
/* ============ Form Grid ============ */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
background: var(--white);
padding: 20px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-100);
margin-bottom: 16px;
}
.form-item { display: flex; flex-direction: column; gap: 5px; }
.form-item label {
font-size: 12px;
color: var(--gray-500);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.form-item input, .form-item select {
padding: 8px 12px;
border: 1px solid var(--gray-200);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--gray-900);
transition: border-color var(--transition);
}
.form-item input:focus, .form-item select:focus {
border-color: var(--blue);
outline: none;
box-shadow: 0 0 0 3px var(--blue-light);
}
/* ============ Metrics Cards ============ */
.metrics {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.metric-card {
background: var(--white);
padding: 18px 16px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-100);
text-align: center;
transition: box-shadow var(--transition), transform var(--transition);
}
.metric-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.metric-card .label {
font-size: 11px;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.8px;
font-weight: 500;
margin-bottom: 6px;
}
.metric-card .value {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
}
.metric-card .value.pos { color: var(--red); }
.metric-card .value.neg { color: var(--green); }
/* ============ Chart ============ */
.chart-wrap {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-100);
padding: 20px;
margin-bottom: 16px;
}
.chart-wrap canvas { max-height: 420px; }
/* ============ Data Status ============ */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.status-card {
background: var(--white);
padding: 20px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-100);
}
.status-card .label {
font-size: 11px;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.8px;
font-weight: 500;
margin-bottom: 6px;
}
.status-card .value {
font-size: 20px;
font-weight: 700;
color: var(--navy);
}
/* ============ Collapsible Sections ============ */
.section-card {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-100);
margin-bottom: 16px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: var(--gray-50);
border-bottom: 1px solid var(--gray-100);
cursor: pointer;
user-select: none;
}
.section-header:hover { background: #E8ECF2; }
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--navy);
letter-spacing: 0.3px;
}
.section-toggle {
background: transparent;
border: none;
color: var(--gray-500);
font-size: 18px;
font-weight: 600;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.section-toggle:hover { background: var(--gray-200); color: var(--navy); }
.section-body {
padding: 20px;
transition: max-height 0.25s ease, padding 0.25s ease, opacity 0.2s ease;
max-height: 2000px;
opacity: 1;
}
.section-card.collapsed .section-body {
max-height: 0;
padding: 0 20px;
opacity: 0;
overflow: hidden;
}
.section-card.collapsed .section-toggle { transform: rotate(0deg); }
.section-toggle { transition: transform 0.2s ease; transform: rotate(0deg); }
/* ============ Feedback ============ */
.loading {
color: var(--gray-500);
text-align: center;
padding: 48px;
font-size: 14px;
}
.error {
color: var(--red);
background: var(--red-light);
border: 1px solid #F5A3B0;
padding: 12px 18px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-size: 13px;
}
/* ============ Task Status Badges ============ */
.task-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.task-badge.pending { background: #FFF3CD; color: #856404; }
.task-badge.running { background: #D6E4FF; color: #1E5FBB; }
.task-badge.success { background: #D4EDDA; color: #155724; }
.task-badge.failed { background: #F8D7DA; color: #721C24; }
.task-badge.cancelled { background: #E2E3E5; color: #383D41; }
.task-badge.running::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #1E5FBB;
margin-right: 5px;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.progress-bar {
display: inline-block;
width: 80px;
height: 6px;
background: var(--gray-200);
border-radius: 3px;
vertical-align: middle;
margin: 0 6px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--blue);
border-radius: 3px;
transition: width 0.3s ease;
}
.btn-cancel {
padding: 4px 10px;
border: 1px solid var(--red);
background: transparent;
color: var(--red);
border-radius: var(--radius-sm);
font-size: 12px;
cursor: pointer;
transition: all var(--transition);
}
.btn-cancel:hover { background: var(--red); color: var(--white); }
.btn-cancel:disabled { opacity: 0.4; cursor: not-allowed; }
/* ============ Responsive ============ */
@media (max-width: 768px) {
.header { padding: 16px 20px; }
.content { padding: 12px 16px; }
.tabs { padding: 0 16px; }
.form-grid { grid-template-columns: repeat(2, 1fr); }
.metrics { grid-template-columns: repeat(2, 1fr); }
.tab { padding: 12px 16px; font-size: 13px; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>可转债轮动分析系统</h1>
<div class="subtitle">实时行情 · 策略回测 · 数据管理</div>
</div>
<div class="header-user" id="header-user">
<a href="/auth.html" id="login-link">登录 / 注册</a>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="market">实时行情</div>
<div class="tab" data-tab="backtest">策略回测</div>
<div class="tab" data-tab="data">数据管理</div>
</div>
<!-- 实时行情 -->
<div class="content">
<div id="tab-market" class="tab-content active">
<div class="toolbar">
<input type="text" id="search" placeholder="搜索代码/名称/正股...">
<select id="sortBy">
<option value="PremiumRate">按溢价率</option>
<option value="Price">按价格</option>
<option value="ChangePct">按涨跌</option>
<option value="ConvertValue">按转股价值</option>
</select>
<select id="sortDir">
<option value="asc">升序</option>
<option value="desc">降序</option>
</select>
<button class="btn-primary" id="refresh">刷新数据</button>
<span id="status"></span>
</div>
<div id="market-error" style="display:none"></div>
<div class="table-wrap">
<table id="bond-table">
<thead>
<tr>
<th data-sort="Code">代码</th>
<th data-sort="Name">名称</th>
<th data-sort="Price">价格</th>
<th data-sort="ChangePct">涨跌%</th>
<th data-sort="PremiumRate">溢价率%</th>
<th data-sort="ConvertValue">转股价值</th>
<th data-sort="ConvertPrice">转股价</th>
<th data-sort="PureBondVal">纯债价值</th>
<th data-sort="StockName">正股</th>
<th data-sort="StockChangePct">正股涨跌%</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- 策略回测 -->
<div id="tab-backtest" class="tab-content">
<div class="section-card" data-section="params">
<div class="section-header">
<span class="section-title">策略参数</span>
<button type="button" class="section-toggle" aria-label="折叠">−</button>
</div>
<div class="section-body">
<div class="form-grid">
<div class="form-item">
<label>策略</label>
<select id="bt-strategy">
<option value="lowprice">低价轮动</option>
<option value="doublelow">双低轮动</option>
<option value="premium">溢价率轮动</option>
</select>
</div>
<div class="form-item">
<label>起始日期</label>
<input type="date" id="bt-start" value="2020-01-01">
</div>
<div class="form-item">
<label>结束日期</label>
<input type="date" id="bt-end" value="2025-12-31">
</div>
<div class="form-item">
<label>初始资金</label>
<input type="number" id="bt-cash" value="100000" step="10000">
</div>
<div class="form-item">
<label>持仓数量</label>
<input type="number" id="bt-topn" value="10" min="1" max="50">
</div>
<div class="form-item">
<label>调仓周期(交易日)</label>
<input type="number" id="bt-rebalance" value="21" min="1" max="252">
</div>
<div class="form-item">
<label>价格下限</label>
<input type="number" id="bt-minprice" value="80" step="1">
</div>
<div class="form-item">
<label>价格上限</label>
<input type="number" id="bt-maxprice" value="130" step="1">
</div>
<div class="form-item" id="bt-premium-group">
<label>溢价率上限</label>
<input type="number" id="bt-maxpremium" value="30" step="1">
</div>
<div class="form-item" style="justify-content:flex-end">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--gray-900);text-transform:none;letter-spacing:0">
<input type="checkbox" id="bt-fundamental" style="width:16px;height:16px;cursor:pointer">
基本面过滤(排除ST/高负债/亏损)
</label>
</div>
</div>
</div>
</div>
<div class="section-card" data-section="bonds">
<div class="section-header">
<span class="section-title">转债选择</span>
<button type="button" class="section-toggle" aria-label="折叠">−</button>
</div>
<div class="section-body">
<div class="toolbar" style="margin-top:0">
<button class="btn-default" id="bt-select-all">全选</button>
<button class="btn-default" id="bt-deselect-all">全不选</button>
<input type="text" id="bt-bond-search" placeholder="搜索转债代码..." style="width:160px">
<span id="bt-bond-count" style="color:#888;font-size:13px"></span>
</div>
<div class="table-wrap" style="max-height:200px;overflow-y:auto;margin-bottom:0">
<table id="bt-bond-table">
<thead><tr><th style="width:40px"><input type="checkbox" id="bt-check-all"></th><th>代码</th><th>名称</th><th>K线数</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="toolbar" style="margin-top:16px">
<button class="btn-primary" id="bt-run">开始回测</button>
<span id="bt-status"></span>
</div>
<div id="bt-error" style="display:none"></div>
<div class="section-card" id="bt-result-section" data-section="result" style="display:none">
<div class="section-header">
<span class="section-title">回测结果</span>
<button type="button" class="section-toggle" aria-label="折叠">−</button>
</div>
<div class="section-body">
<div class="metrics" id="bt-metrics"></div>
<div class="chart-wrap" style="margin-bottom:0">
<canvas id="bt-chart"></canvas>
</div>
</div>
</div>
</div>
<!-- 数据管理 -->
<div id="tab-data" class="tab-content">
<div class="toolbar">
<button class="btn-primary" id="data-refresh">刷新状态</button>
<span id="data-status-text"></span>
</div>
<div id="data-error" style="display:none"></div>
<div class="status-grid" id="data-cards"></div>
<!-- 任务控制 -->
<div class="section-card" style="margin-top:16px" data-section="task-control">
<div class="section-header">
<span class="section-title">数据更新任务</span>
<button type="button" class="section-toggle" aria-label="折叠">−</button>
</div>
<div class="section-body">
<div class="toolbar" style="margin-top:0">
<button class="btn-primary" id="task-full">全量更新</button>
<button class="btn-primary" id="task-incremental">增量更新</button>
<input type="date" id="task-since" title="增量起始日期(留空自动取最后K线日期)">
<input type="number" id="task-limit" placeholder="限制数量" min="0" style="width:120px" title="限制处理数量(0=全部)">
<span id="task-create-status"></span>
</div>
<div style="font-size:12px;color:var(--gray-500);margin-top:4px;line-height:1.6">
<b>全量更新</b>:拉取所有转债的历史K线(已入库的自动跳过)<br>
<b>增量更新</b>:仅同步已入库转债的最新数据(自上次入库日期起)
</div>
</div>
</div>
<!-- 任务列表 -->
<div class="section-card" data-section="task-list">
<div class="section-header">
<span class="section-title">任务列表</span>
<button type="button" class="section-toggle" aria-label="折叠">−</button>
</div>
<div class="section-body">
<div class="toolbar" style="margin-top:0">
<button class="btn-default" id="task-refresh">刷新任务</button>
<span id="task-list-status" style="font-size:13px;color:var(--gray-500)"></span>
</div>
<div class="table-wrap" style="max-height:480px;overflow-y:auto;margin-bottom:0">
<table id="task-table">
<thead>
<tr>
<th>任务ID</th>
<th>类型</th>
<th>状态</th>
<th>进度</th>
<th>成功</th>
<th>失败</th>
<th>开始时间</th>
<th>完成时间</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<link rel="stylesheet" href="/style.css">
<script>
// ============ Toast 通知 ============
function showToast(msg, type = 'info', duration = 3000) {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'toast ' + type;
const icons = { success: '✓', error: '✕', info: 'i' };
toast.innerHTML = `<span style="font-weight:700;font-size:15px">${icons[type] || ''}</span><span>${msg}</span>`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'toastOut 0.25s ease-in forwards';
setTimeout(() => toast.remove(), 250);
}, duration);
}
// ============ 登录状态 ============
(function initAuth() {
const token = localStorage.getItem('auth_token');
const username = localStorage.getItem('auth_username');
const email = localStorage.getItem('auth_email');
if (!token) {
window.location.href = '/auth.html';
return;
}
const container = document.getElementById('header-user');
const displayName = username || email || '用户';
const initial = displayName.charAt(0).toUpperCase();
container.innerHTML = `<div class="avatar">${initial}</div><span class="user-name">${displayName}</span><a href="#" id="logout-link">退出</a>`;
document.getElementById('logout-link').addEventListener('click', async e => {
e.preventDefault();
try { await fetch('/api/auth/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); } catch (_) {}
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_username');
localStorage.removeItem('auth_email');
document.cookie = 'auth_token=; path=/; max-age=0';
showToast('已退出登录', 'info');
setTimeout(() => { window.location.href = '/auth.html'; }, 300);
});
})();
// ============ 统一 API 请求封装:自动注入 token,401 自动跳转登录 ============
async function apiFetch(url, options = {}) {
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/auth.html';
throw new Error('未登录');
}
const headers = { ...(options.headers || {}) };
headers['Authorization'] = 'Bearer ' + token;
const resp = await fetch(url, { ...options, headers });
if (resp.status === 401) {
// token 失效,清空并跳转登录
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_username');
localStorage.removeItem('auth_email');
document.cookie = 'auth_token=; path=/; max-age=0';
showToast('登录已失效,请重新登录', 'error');
setTimeout(() => { window.location.href = '/auth.html'; }, 500);
throw new Error('登录已失效');
}
return resp;
}
// ============ 标签页切换 ============
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
if (tab.dataset.tab === 'data') { loadDataStatus(); loadTasks(); }
});
});
// ============ 实时行情 ============
let allBonds = [];
const statusEl = document.getElementById('status');
const tbody = document.querySelector('#bond-table tbody');
function fmt(v, digits = 2) {
return v === null || v === undefined ? '-' : Number(v).toFixed(digits);
}
function pctClass(v) {
return v > 0 ? 'pos' : (v < 0 ? 'neg' : '');
}
function renderTable(bonds) {
if (bonds.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px;color:#888">暂无数据</td></tr>';
return;
}
tbody.innerHTML = bonds.map(b => `
<tr>
<td>${b.Code}</td><td>${b.Name}</td>
<td>${fmt(b.Price)}</td>
<td class="${pctClass(b.ChangePct)}">${fmt(b.ChangePct)}</td>
<td class="${pctClass(b.PremiumRate)}">${fmt(b.PremiumRate)}</td>
<td>${fmt(b.ConvertValue)}</td>
<td>${fmt(b.ConvertPrice)}</td>
<td>${fmt(b.PureBondVal)}</td>
<td>${b.StockName}</td>
<td class="${pctClass(b.StockChangePct)}">${fmt(b.StockChangePct)}</td>
</tr>`).join('');
}
function sortBonds() {
const key = document.getElementById('sortBy').value;
const dir = document.getElementById('sortDir').value === 'asc' ? 1 : -1;
const search = document.getElementById('search').value.toLowerCase();
let filtered = allBonds;
if (search) {
filtered = allBonds.filter(b =>
b.Code.toLowerCase().includes(search) ||
b.Name.toLowerCase().includes(search) ||
(b.StockName || '').toLowerCase().includes(search)
);
}
filtered.sort((a, b) => {
const va = a[key] || 0, vb = b[key] || 0;
return va > vb ? dir : va < vb ? -dir : 0;
});
renderTable(filtered);
statusEl.textContent = `共 ${filtered.length} 只`;
}
async function loadBonds() {
statusEl.textContent = '加载中...';
document.getElementById('market-error').style.display = 'none';
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:24px;color:#888">加载中...</td></tr>';
try {
const resp = await apiFetch('/api/bonds');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
allBonds = await resp.json();
if (allBonds.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:24px;color:#888">暂无数据,请先通过数据入库获取可转债信息</td></tr>';
statusEl.textContent = '暂无数据';
return;
}
sortBonds();
} catch (e) {
document.getElementById('market-error').style.display = 'block';
document.getElementById('market-error').innerHTML = '<div class="error">加载失败: ' + e.message + '</div>';
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:24px;color:#888">加载失败,请稍后重试</td></tr>';
statusEl.textContent = '加载失败';
}
}
document.getElementById('refresh').addEventListener('click', loadBonds);
document.getElementById('search').addEventListener('input', sortBonds);
document.getElementById('sortBy').addEventListener('change', sortBonds);
document.getElementById('sortDir').addEventListener('change', sortBonds);
// 表头点击排序
document.querySelectorAll('#bond-table th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
document.getElementById('sortBy').value = key;
const dirEl = document.getElementById('sortDir');
dirEl.value = dirEl.value === 'asc' ? 'desc' : 'asc';
sortBonds();
});
});
// ============ 策略回测 ============
let btChart = null;
let dbBonds = []; // 数据库中的转债列表
// 加载数据库中的转债列表
async function loadDBBonds() {
document.getElementById('bt-bond-count').textContent = '加载中...';
try {
const resp = await apiFetch('/api/db/bonds');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
dbBonds = await resp.json();
renderBondList();
} catch (e) {
document.getElementById('bt-bond-count').textContent = '加载失败';
}
}
function getSelectedCodes() {
const checks = document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]');
return Array.from(checks).filter(c => c.checked).map(c => c.value);
}
function renderBondList(filter = '') {
const tbody = document.querySelector('#bt-bond-table tbody');
const filtered = filter ? dbBonds.filter(b => b.Code.includes(filter)) : dbBonds;
tbody.innerHTML = filtered.map(b => `
<tr>
<td><input type="checkbox" value="${b.Code}" checked></td>
<td>${b.Code}</td>
<td>${b.Name || '-'}</td>
<td>${b.KlineCount}</td>
</tr>`).join('');
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 ${getSelectedCodes().length})`;
updateCheckAll();
}
function updateCheckAll() {
const checks = document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]');
const all = document.getElementById('bt-check-all');
if (checks.length === 0) { all.checked = false; return; }
all.checked = Array.from(checks).every(c => c.checked);
}
// 切换回测标签页时加载转债列表
document.querySelector('.tab[data-tab="backtest"]').addEventListener('click', () => {
if (dbBonds.length === 0) loadDBBonds();
});
document.getElementById('bt-check-all').addEventListener('change', function() {
document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]').forEach(c => c.checked = this.checked);
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 ${getSelectedCodes().length})`;
});
document.getElementById('bt-select-all').addEventListener('click', () => {
document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]').forEach(c => c.checked = true);
document.getElementById('bt-check-all').checked = true;
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 ${getSelectedCodes().length})`;
});
document.getElementById('bt-deselect-all').addEventListener('click', () => {
document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]').forEach(c => c.checked = false);
document.getElementById('bt-check-all').checked = false;
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 0)`;
});
document.getElementById('bt-bond-search').addEventListener('input', function() {
renderBondList(this.value.trim());
});
// 监听表格内 checkbox 变化
document.querySelector('#bt-bond-table tbody').addEventListener('change', function(e) {
if (e.target.type === 'checkbox') {
updateCheckAll();
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 ${getSelectedCodes().length})`;
}
});
document.getElementById('bt-strategy').addEventListener('change', function() {
document.getElementById('bt-premium-group').style.display =
this.value === 'doublelow' ? '' : 'none';
if (this.value === 'premium') {
document.getElementById('bt-minprice').value = '90';
document.getElementById('bt-maxprice').value = '150';
} else {
document.getElementById('bt-minprice').value = '80';
document.getElementById('bt-maxprice').value = '130';
}
});
document.getElementById('bt-run').addEventListener('click', async () => {
const btn = document.getElementById('bt-run');
const status = document.getElementById('bt-status');
const errEl = document.getElementById('bt-error');
const resultSection = document.getElementById('bt-result-section');
btn.disabled = true;
btn.textContent = '回测中...';
status.textContent = '回测中...';
status.style.color = 'var(--blue)';
errEl.style.display = 'none';
resultSection.style.display = 'none';
try {
const selectedCodes = getSelectedCodes();
if (selectedCodes.length === 0 && dbBonds.length > 0) {
throw new Error('请至少选择一只可转债进行回测');
}
const allSelected = dbBonds.length > 0 && selectedCodes.length === dbBonds.length;
const body = {
strategy: document.getElementById('bt-strategy').value,
startDate: document.getElementById('bt-start').value,
endDate: document.getElementById('bt-end').value,
topN: +document.getElementById('bt-topn').value,
rebalance: +document.getElementById('bt-rebalance').value,
minPrice: +document.getElementById('bt-minprice').value,
maxPrice: +document.getElementById('bt-maxprice').value,
maxPremium: +document.getElementById('bt-maxpremium').value,
cash: +document.getElementById('bt-cash').value,
fundamental: document.getElementById('bt-fundamental').checked,
codes: allSelected ? [] : selectedCodes,
};
const resp = await apiFetch('/api/backtest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'HTTP ' + resp.status);
}
const data = await resp.json();
showBacktestResult(data);
status.textContent = '✓ 回测完成';
status.style.color = 'var(--green)';
// 滚动到结果区域
setTimeout(() => {
resultSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
} catch (e) {
errEl.style.display = 'block';
errEl.innerHTML = '<div class="error">回测失败: ' + e.message + '</div>';
status.textContent = '× 回测失败';
status.style.color = 'var(--red)';
} finally {
btn.disabled = false;
btn.textContent = '开始回测';
}
});
// 策略回测区域折叠/展开
document.querySelectorAll('#tab-backtest .section-header').forEach(header => {
header.addEventListener('click', e => {
if (e.target.closest('.section-toggle')) {
e.stopPropagation();
}
const card = header.closest('.section-card');
const collapsed = card.classList.toggle('collapsed');
header.querySelector('.section-toggle').textContent = collapsed ? '+' : '−';
header.querySelector('.section-toggle').setAttribute('aria-label', collapsed ? '展开' : '折叠');
});
});
function showBacktestResult(data) {
const resultSection = document.getElementById('bt-result-section');
resultSection.style.display = 'block';
resultSection.classList.remove('collapsed');
const m = data.metrics;
const bm = data.benchMetrics;
const excess = m.TotalReturn - bm.TotalReturn;
document.getElementById('bt-metrics').innerHTML = `
<div class="metric-card"><div class="label">策略</div><div class="value" style="font-size:16px">${data.strategy}</div></div>
<div class="metric-card"><div class="label">回测区间</div><div class="value" style="font-size:14px">${data.start} ~ ${data.end}</div></div>
<div class="metric-card"><div class="label">累计收益</div><div class="value ${m.TotalReturn >= 0 ? 'pos' : 'neg'}">${(m.TotalReturn * 100).toFixed(2)}%</div></div>
<div class="metric-card"><div class="label">年化收益</div><div class="value ${m.AnnualReturn >= 0 ? 'pos' : 'neg'}">${(m.AnnualReturn * 100).toFixed(2)}%</div></div>
<div class="metric-card"><div class="label">最大回撤</div><div class="value neg">${(m.MaxDrawdown * 100).toFixed(2)}%</div></div>
<div class="metric-card"><div class="label">夏普比率</div><div class="value">${m.Sharpe.toFixed(2)}</div></div>
<div class="metric-card"><div class="label">波动率</div><div class="value">${(m.Volatility * 100).toFixed(2)}%</div></div>
<div class="metric-card"><div class="label">胜率</div><div class="value">${(m.WinRatio * 100).toFixed(1)}%</div></div>
<div class="metric-card"><div class="label">超额收益 vs 基准</div><div class="value ${excess >= 0 ? 'pos' : 'neg'}">${(excess * 100).toFixed(2)}%</div></div>
<div class="metric-card"><div class="label">基准年化</div><div class="value ${bm.AnnualReturn >= 0 ? 'pos' : 'neg'}">${(bm.AnnualReturn * 100).toFixed(2)}%</div></div>
`;
// 绘制净值曲线
const labels = data.curve.map(p => p.Date);
const stratValues = data.curve.map(p => p.Value);
const benchMap = {};
data.benchCurve.forEach(p => { benchMap[p.Date] = p.Value; });
const benchValues = data.curve.map(p => benchMap[p.Date] || null);
if (btChart) btChart.destroy();
const ctx = document.getElementById('bt-chart').getContext('2d');
btChart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: data.strategy,
data: stratValues,
borderColor: '#1E5FBB',
backgroundColor: 'rgba(30,95,187,0.06)',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.15,
},
{
label: '等权全转债基准',
data: benchValues,
borderColor: '#7C8499',
borderWidth: 1.5,
pointRadius: 0,
borderDash: [6, 3],
fill: false,
tension: 0.15,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' },
plugins: {
title: {
display: true,
text: '净值曲线对比',
font: { size: 14, family: '-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif' },
color: '#1A1F2E',
padding: { bottom: 16 },
},
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
padding: 24,
font: { size: 12, family: '-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif' },
color: '#4A5168',
},
},
tooltip: {
backgroundColor: 'rgba(13,27,42,0.92)',
titleFont: { size: 13, family: '-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif' },
bodyFont: { size: 12 },
padding: 12,
cornerRadius: 6,
callbacks: { label: ctx => ctx.dataset.label + ': ' + parseFloat(ctx.raw).toFixed(4) },
},
},
scales: {
x: {
grid: { color: '#E8EAF0', drawBorder: false },
ticks: { color: '#7C8499', maxTicksLimit: 16, maxRotation: 0, font: { size: 11 } },
},
y: {
grid: { color: '#E8EAF0', drawBorder: false },
ticks: { color: '#7C8499', font: { size: 11 }, callback: v => v.toFixed(2) },
},
},
},
});
}
// ============ 数据管理 ============
async function loadDataStatus() {
const statusText = document.getElementById('data-status-text');
const cards = document.getElementById('data-cards');
const errEl = document.getElementById('data-error');
statusText.textContent = '加载中...';
errEl.style.display = 'none';
try {
const resp = await apiFetch('/api/status');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
cards.innerHTML = `
<div class="status-card">
<div class="label">📁 数据库路径</div>
<div class="value" style="font-size:12px;word-break:break-all;font-weight:500">${data.dbPath || '-'}</div>
</div>
<div class="status-card">
<div class="label">📊 转债数量</div>
<div class="value" style="color:var(--blue)">${data.bondCount || 0}</div>
<div style="font-size:11px;color:var(--gray-500);margin-top:4px">只可转债</div>
</div>
<div class="status-card">
<div class="label">📈 K线总数</div>
<div class="value" style="color:var(--green)">${(data.klineCount || 0).toLocaleString()}</div>
<div style="font-size:11px;color:var(--gray-500);margin-top:4px">条日K线数据</div>
</div>
<div class="status-card">
<div class="label">📅 数据区间</div>
<div class="value" style="font-size:14px;font-weight:500">${data.dateRange || '-'}</div>
<div style="font-size:11px;color:var(--gray-500);margin-top:4px">交易日范围</div>
</div>
`;
statusText.textContent = '✓ 已加载';
statusText.style.color = 'var(--green)';
} catch (e) {
errEl.style.display = 'block';
errEl.innerHTML = '<div class="error">加载失败: ' + e.message + '(请确保已执行数据入库)</div>';
statusText.textContent = '× 加载失败';
statusText.style.color = 'var(--red)';
}
}
document.getElementById('data-refresh').addEventListener('click', loadDataStatus);
// ============ 数据更新任务 ============
let taskPollTimer = null;
// 任务类型中文映射
const taskTypeLabel = { full: '全量更新', incremental: '增量更新' };
// 任务状态中文映射
const taskStatusLabel = {
pending: '等待中', running: '运行中', success: '成功', failed: '失败', cancelled: '已取消'
};
// 创建任务
async function createTask(type) {
const statusEl = document.getElementById('task-create-status');
const btnId = type === 'full' ? 'task-full' : 'task-incremental';
const btn = document.getElementById(btnId);
const since = document.getElementById('task-since').value;
const limit = +document.getElementById('task-limit').value || 0;
btn.disabled = true;
statusEl.textContent = '提交中...';
statusEl.style.color = 'var(--blue)';
try {
const body = { type, since, limit };
const resp = await apiFetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'HTTP ' + resp.status);
}
const task = await resp.json();
showToast(`${taskTypeLabel[type]}任务已创建`, 'success');
statusEl.textContent = '';
// 立即刷新任务列表并启动轮询
await loadTasks();
startTaskPolling();
// 切换到任务列表区域
document.querySelector('[data-section="task-list"] .section-header').scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (e) {
showToast('创建任务失败: ' + e.message, 'error');
statusEl.textContent = '× ' + e.message;
statusEl.style.color = 'var(--red)';
} finally {
btn.disabled = false;
}
}
// 加载任务列表
async function loadTasks() {
const statusEl = document.getElementById('task-list-status');
const tbody = document.querySelector('#task-table tbody');
statusEl.textContent = '加载中...';
try {
const resp = await apiFetch('/api/tasks?limit=50');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const tasks = await resp.json();
renderTasks(tasks || []);
const running = (tasks || []).filter(t => t.status === 'running' || t.status === 'pending').length;
statusEl.textContent = `共 ${tasks.length} 条${running > 0 ? `(${running} 个运行中)` : ''}`;
// 有运行中任务时继续轮询,否则停止
if (running > 0) {
startTaskPolling();
} else {
stopTaskPolling();
}
} catch (e) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center;padding:20px;color:var(--red)">加载失败: ${e.message}</td></tr>`;
statusEl.textContent = '加载失败';
}
}
// 渲染任务列表
function renderTasks(tasks) {
const tbody = document.querySelector('#task-table tbody');
if (tasks.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:24px;color:#888">暂无任务记录</td></tr>';
return;
}
tbody.innerHTML = tasks.map(t => {
const pct = t.total > 0 ? Math.round(t.done / t.total * 100) : 0;
const isActive = t.status === 'running' || t.status === 'pending';
const statusText = taskStatusLabel[t.status] || t.status;
const shortId = t.id.substring(0, 8);
const started = formatTime(t.startedAt);
const finished = t.finishedAt ? formatTime(t.finishedAt) : '-';
const failInfo = t.failed > 0 && t.failedCodes && t.failedCodes.length > 0
? ` title="失败代码: ${t.failedCodes.slice(0, 10).join(', ')}${t.failedCodes.length > 10 ? '...' : ''}"`
: '';
const errorInfo = t.error ? ` title="${t.error.replace(/"/g, '&quot;')}"` : '';
return `<tr>
<td style="font-family:monospace;font-size:12px" title="${t.id}">${shortId}</td>
<td>${taskTypeLabel[t.type] || t.type}</td>
<td><span class="task-badge ${t.status}"${errorInfo}>${statusText}</span></td>
<td>
<span style="font-size:12px">${t.done}/${t.total}</span>
<span class="progress-bar"><span class="progress-bar-fill" style="width:${pct}%"></span></span>
<span style="font-size:11px;color:var(--gray-500)">${pct}%</span>
</td>
<td style="color:var(--green);font-weight:600">${t.success}</td>
<td style="color:var(--red);font-weight:600"${failInfo}>${t.failed}</td>
<td style="font-size:12px">${started}</td>
<td style="font-size:12px">${finished}</td>
<td>${isActive
? `<button class="btn-cancel" onclick="cancelTask('${t.id}')">取消</button>`
: '<span style="color:var(--gray-500);font-size:12px">-</span>'}</td>
</tr>`;
}).join('');
}
// 取消任务
async function cancelTask(id) {
if (!confirm('确定取消该任务?')) return;
try {
const resp = await apiFetch(`/api/tasks/${id}/cancel`, { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'HTTP ' + resp.status);
}
showToast('任务取消请求已发送', 'info');
await loadTasks();
} catch (e) {
showToast('取消失败: ' + e.message, 'error');
}
}
// 暴露到全局供 inline onclick 调用
window.cancelTask = cancelTask;
// 格式化时间:RFC3339 → 可读格式
function formatTime(s) {
if (!s) return '-';
const d = new Date(s);
if (isNaN(d)) return s;
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// 轮询:运行中任务时每 3 秒刷新一次
function startTaskPolling() {
if (taskPollTimer) return; // 已在轮询
taskPollTimer = setInterval(loadTasks, 3000);
}
function stopTaskPolling() {
if (taskPollTimer) {
clearInterval(taskPollTimer);
taskPollTimer = null;
}
}
document.getElementById('task-full').addEventListener('click', () => createTask('full'));
document.getElementById('task-incremental').addEventListener('click', () => createTask('incremental'));
document.getElementById('task-refresh').addEventListener('click', loadTasks);
// 数据管理Tab的折叠/展开
document.querySelectorAll('#tab-data .section-header').forEach(header => {
header.addEventListener('click', e => {
if (e.target.closest('.section-toggle')) e.stopPropagation();
const card = header.closest('.section-card');
const collapsed = card.classList.toggle('collapsed');
header.querySelector('.section-toggle').textContent = collapsed ? '+' : '−';
});
});
// 初始化
loadBonds();
</script>
</body>
</html>