Files
stock-analysis/web/index.html
WarrenZhou e28bfec957 Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
2026-08-17 20:23:22 +08:00

963 lines
37 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;
}
/* ============ 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>