Initial commit: 可转债轮动分析系统

- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
2026-08-17 20:23:22 +08:00
commit e28bfec957
45 changed files with 7889 additions and 0 deletions

963
web/index.html Normal file
View File

@@ -0,0 +1,963 @@
<!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;
}
/* ============ 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>
</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();
});
});
// ============ 实时行情 ============
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);
// 初始化
loadBonds();
</script>
</body>
</html>