- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
963 lines
37 KiB
HTML
963 lines
37 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>可转债轮动分析系统</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> |