Files
MoFin/static/index.html
T
2026-08-26 12:34:39 +08:00

3157 lines
184 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>MoFin · 知微情报</title>
<script src="/tailwind.js"></script>
<script src="/chart.min.js"></script>
<style>
/* System font stack — no Google Fonts dependency */
* { font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif; }
body { background: #0f0f13; color: #e2e8f0; }
.tab-btn { transition: all 0.15s; }
.tab-btn.active { background: #1e293b; color: #60a5fa; border-color: #60a5fa; }
.princ-btn { transition: all 0.15s; color: #8b949e; }
.princ-btn.active { background: #1e293b; color: #60a5fa; }
.card { background: #1a1a24; border: 1px solid #2a2a3a; border-radius: 12px; }
.badge-up { color: #22c55e; }
.badge-down { color: #ef4444; }
.pnl-badge { font-weight: 600; }
.stock-row:hover { background: #22222e; cursor: pointer; }
.loading { opacity: 0.5; pointer-events: none; }
.modal-overlay { background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); }
.spec-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 50%;
font-size: 11px; font-weight: 700; line-height: 1;
cursor: pointer; transition: background 0.15s; user-select: none;
}
.spec-btn.help { color: #58a6ff; background: rgba(88,166,255,0.1); }
.spec-btn.help:hover { background: rgba(88,166,255,0.25); }
.spec-btn.ai { color: #d29922; background: rgba(210,153,34,0.1); }
.spec-btn.ai:hover { background: rgba(210,153,34,0.25); }
.spec-btns { display: inline-flex; gap: 4px; }
.spec-btn.colinfo { color: #58a6ff; background: rgba(88,166,255,0.1); }
.spec-btn.colinfo:hover { background: rgba(88,166,255,0.25); }
.spec-th-help { margin-left: 3px; display: inline-flex; gap: 3px; vertical-align: middle; }
.spec-th-col { display: flex; flex-direction: column; align-items: center; line-height: 1.1; }
.spec-th-label { white-space: nowrap; }
.spec-th-btns { display: inline-flex; gap: 3px; margin-top: 1px; }
.spec-th-btns .spec-btn { width: 15px; height: 15px; font-size: 10px; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f0f13; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
@keyframes pulse-warn {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.pulse-warn { animation: pulse-warn 1.2s ease-in-out infinite; }
#alertFloat {
position: fixed; top: 12px; right: 12px; z-index: 9999;
max-width: 380px; min-width: 260px;
background: rgba(26,26,36,0.95); border: 1px solid #7f1d1d;
border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.6);
font-size: 12px; color: #e2e8f0; overflow: hidden;
transition: all 0.25s ease;
}
#alertFloat.collapsed { min-width: 0; background: transparent; border-color: transparent; box-shadow: none; }
#alertFloat .alert-head {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; cursor: pointer; user-select: none;
background: rgba(127,29,29,0.35); border-bottom: 1px solid rgba(127,29,29,0.4);
}
#alertFloat.collapsed .alert-head { background: #7f1d1d; border-bottom: none; border-radius: 12px; }
#alertFloat .alert-title { font-weight: 600; font-size: 12px; display: flex; align-items: center; gap: 6px; }
#alertFloat .alert-count {
background: #ef4444; color: white; border-radius: 999px;
padding: 1px 8px; font-size: 11px; font-weight: 700;
}
#alertFloat .alert-toggle { color: #fca5a5; font-size: 14px; }
#alertFloat .alert-body { max-height: 320px; overflow-y: auto; padding: 6px 0; }
#alertFloat .alert-item {
padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.06);
line-height: 1.5;
}
#alertFloat .alert-item:last-child { border-bottom: none; }
#alertFloat .alert-item .a-src { color: #fbbf24; font-size: 11px; font-weight: 600; }
#alertFloat .alert-item .a-title { color: #fecaca; font-weight: 600; }
#alertFloat .alert-item .a-detail { color: #94a3b8; font-size: 11px; }
#alertFloat .alert-item.critical { border-left: 3px solid #ef4444; }
#alertFloat .alert-item.error { border-left: 3px solid #f97316; }
#alertFloat .alert-item.warning { border-left: 3px solid #eab308; }
@keyframes alert-pop { 0% { opacity: 0; transform: translateY(-8px); } 100% { opacity: 1; transform: translateY(0); } }
#alertFloat.new-alert { animation: alert-pop 0.3s ease; }
</style>
</head>
<body class="min-h-screen">
<div class="max-w-7xl mx-auto px-4 py-4">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<span class="text-2xl">📊</span>
<h1 class="text-xl font-bold text-white tracking-tight">MoFin</h1>
<span class="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded-full">知微</span>
</div>
<div class="flex items-center gap-2 text-xs text-slate-500">
<span id="updateTime"></span>
<button onclick="refreshAll()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition"></button>
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 mb-6 bg-slate-900/50 rounded-xl p-1 border border-slate-800/50 overflow-x-auto" id="tabBar">
<button class="tab-btn active px-4 py-2 text-sm rounded-lg" data-tab="overview">📈 概览</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="holdings">💼 持仓</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="watchlist">⭐ 自选</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg blink-tab" data-tab="watch" style="color:#f97316;border-color:#f97316">👁️ 盯盘</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="prompts">📝 提示词</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="market">🌐 市场</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="health">🏥 健康</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="principles">📐 开发原则</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="research">📋 研究</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="docs">📚 文档</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="broadcast">📡 Broadcast</button>
<a href="/upload" class="tab-btn px-4 py-2 text-sm rounded-lg" style="text-decoration:none;color:#fbbf24;border-color:#fbbf24">📸 上传</a>
</div>
<!-- Tab Content -->
<div id="tab-overview" class="tab-content"></div>
<div id="tab-holdings" class="tab-content hidden"></div>
<div id="tab-watchlist" class="tab-content hidden"></div>
<div id="tab-prompts" class="tab-content hidden"></div>
<div id="tab-market" class="tab-content hidden"></div>
<div id="tab-health" class="tab-content hidden"></div>
<div id="tab-research" class="tab-content hidden"></div>
<div id="tab-docs" class="tab-content hidden"></div>
<div id="tab-broadcast" class="tab-content hidden"><div class="flex items-center justify-between mb-3"><h2 class="text-lg font-bold text-slate-200">📡 Broadcast</h2><a href="/broadcast.html" class="text-sm text-blue-400 hover:text-blue-300">📋 History</a></div><div id="broadcast-list" class="space-y-2"><div class="text-slate-500 text-sm">Loading...</div></div></div>
<div id="tab-principles" class="tab-content hidden">
<div class="flex gap-1 mb-4 bg-slate-900/50 rounded-xl p-1 border border-slate-800/50" id="princBar">
<button class="princ-btn active px-4 py-1.5 text-sm rounded-lg" data-princ="spec">G 规范</button>
<button class="princ-btn px-4 py-1.5 text-sm rounded-lg" data-princ="tests">K 测试</button>
<button class="princ-btn px-4 py-1.5 text-sm rounded-lg" data-princ="health">F 健康</button>
<button class="princ-btn px-4 py-1.5 text-sm rounded-lg" data-princ="prd">H 需求</button>
</div>
<div id="princ-spec" class="princ-pane"></div>
<div id="princ-tests" class="princ-pane hidden"></div>
<div id="princ-health" class="princ-pane hidden"></div>
<div id="princ-prd" class="princ-pane hidden"></div>
</div>
<div id="tab-watch" class="tab-content hidden">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-slate-200">👁️ 盯盘 · 全部策略</h2>
<span class="text-xs text-slate-500"><span id="watchRefreshTime"></span> <span id="watchCount" class="text-yellow-400 font-mono">0</span></span>
</div>
<!-- 筛选/排序控制条 -->
<div class="mb-3 p-2 rounded-lg bg-slate-800/40 border border-slate-700/50 text-xs space-y-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-slate-500 mr-1">信号:</span>
<span id="watchSigChips"></span>
<button onclick="watchSigAll(true)" class="px-1.5 py-0.5 rounded text-slate-400 hover:text-white border border-slate-600">全选</button>
<button onclick="watchSigAll(false)" class="px-1.5 py-0.5 rounded text-slate-400 hover:text-white border border-slate-600">清空</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-slate-500">排序:</span>
<select id="watchSortMode" onchange="renderWatch()" class="bg-slate-900 border border-slate-600 rounded px-1.5 py-0.5 text-slate-300">
<option value="default">默认(分组)</option>
<option value="rr_desc">RR 从高到低</option>
<option value="rr_asc">RR 从低到高</option>
<option value="chg_desc">涨幅从高到低</option>
<option value="chg_asc">跌幅从高到低</option>
</select>
<span class="text-slate-500 ml-2">RR ≥</span>
<input id="watchMinRR" type="number" step="0.1" min="0" placeholder="0" onchange="renderWatch()" class="w-16 bg-slate-900 border border-slate-600 rounded px-1.5 py-0.5 text-slate-300">
<button onclick="watchResetFilters()" class="px-2 py-0.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 ml-1">重置</button>
</div>
</div>
<div id="watchContent" class="card overflow-hidden"></div>
</div>
</div>
<!-- 浮动异常区(右上角,任何板块可见,2026-08-12 老莫指示) -->
<div id="alertFloat" class="collapsed" style="display:none" onclick="toggleAlertFloat(event)">
<div class="alert-head">
<span class="alert-title">⚠️ 异常 <span id="alertCount" class="alert-count" style="display:none">0</span></span>
<span class="alert-toggle"></span>
</div>
<div id="alertBody" class="alert-body" style="display:none"></div>
</div>
<!-- Strategy History Modal -->
<div id="strategyModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closeStrategyModal()">
<div class="bg-[#1a1a24] border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[85vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
<div class="flex justify-between items-start mb-4">
<div>
<h2 id="strategyModalTitle" class="text-lg font-bold text-white"></h2>
<p id="strategyModalMeta" class="text-xs text-slate-500 mt-1"></p>
</div>
<button onclick="closeStrategyModal()" class="text-slate-500 hover:text-white text-xl">&times;</button>
</div>
<div id="strategyModalBody" class="space-y-3"></div>
</div>
</div>
<!-- Spec Modal -->
<div id="specModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closeSpecModal()">
<div class="bg-[#161b22] border border-[#30363d] rounded-2xl w-full max-w-[700px] max-h-[80vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
<div class="flex justify-between items-start mb-4">
<div>
<h2 id="specModalTitle" class="text-lg font-bold text-[#c9d1d9]"></h2>
<p id="specModalSubtitle" class="text-xs text-[#8b949e] mt-1"></p>
</div>
<button onclick="closeSpecModal()" class="text-[#8b949e] hover:text-white text-xl">&times;</button>
</div>
<div id="specModalBody" class="text-sm text-[#c9d1d9] leading-relaxed"></div>
</div>
</div>
<!-- Stock Detail Modal -->
<div id="stockModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closeStock()">
<div class="bg-[#1a1a24] border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[85vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
<div class="flex justify-between items-start mb-4">
<div>
<h2 id="modalTitle" class="text-lg font-bold text-white"></h2>
<p id="modalMeta" class="text-xs text-slate-500 mt-1"></p>
</div>
<button onclick="closeStock()" class="text-slate-500 hover:text-white text-xl">&times;</button>
</div>
<div id="modalInfo" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"></div>
<div id="modalStrategy" class="mb-4"></div>
<div id="modalChart" class="h-48 mb-4 bg-slate-900 rounded-xl p-2"></div>
<div id="modalAnalysis" class="mb-4"></div>
<div>
<h3 class="text-sm font-semibold text-slate-300 mb-2">📜 操作建议历史</h3>
<div id="modalHistory" class="space-y-2"></div>
</div>
</div>
</div>
<!-- 提示词内容弹窗(独立,避免与stockModal冲突) -->
<div id="promptModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closePromptModal()">
<div class="bg-[#1a1a24] border border-slate-700 rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
<div class="flex justify-between items-start mb-4">
<div>
<h2 id="promptModalTitle" class="text-lg font-bold text-white"></h2>
</div>
<button onclick="closePromptModal()" class="text-slate-500 hover:text-white text-xl">&times;</button>
</div>
<div id="promptModalBody" class="text-sm text-slate-300 leading-relaxed"></div>
</div>
</div>
<script>
// ── State ──
let portfolioData = { holdings: [] };
let watchlistData = { stocks: [] };
let reportsData = [];
// ── Tab Switching ──
// 事件委托:点击 tabBar 内的按钮自动切换
document.addEventListener('DOMContentLoaded', function() {
const tabBar = document.getElementById('tabBar');
if (tabBar) {
tabBar.addEventListener('click', function(e) {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
if (btn.tagName === 'A') return; // 上传链接保持原行为
const name = btn.dataset.tab;
if (!name) return;
// 切走 health 时清理定时器
if (name !== 'health' && healthRefreshInterval) {
clearInterval(healthRefreshInterval);
healthRefreshInterval = null;
const he = document.getElementById('tab-health');
if (he) delete he.dataset.rendered;
}
if (name === 'health' && !healthRefreshInterval) {
if (!healthRefreshInterval) healthRefreshInterval = setInterval(refreshHealth, 10000);
}
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + name).classList.remove('hidden');
btn.classList.add('active');
renderTab(name);
});
}
refreshAll();
setInterval(refreshAll, 120000);
});
function switchTab(name) {
// 切走 health 时清理定时器
if (name !== 'health' && healthRefreshInterval) {
clearInterval(healthRefreshInterval);
healthRefreshInterval = null;
const he = document.getElementById('tab-health');
if (he) delete he.dataset.rendered;
}
// 进入 health 时确保定时器运行
if (name === 'health' && !healthRefreshInterval) {
healthRefreshInterval = setInterval(refreshHealth, 10000);
}
const btn = document.querySelector(`[data-tab="${name}"]`);
if (btn) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + name).classList.remove('hidden');
btn.classList.add('active');
renderTab(name);
}
}
function renderTab(name) {
if (name === 'overview') renderOverview();
if (name === 'broadcast') loadBroadcast();
else if (name === 'holdings') renderHoldings();
else if (name === 'watchlist') renderWatchlist();
else if (name === 'evaluation') renderEvaluation();
else if (name === 'prompts') renderPrompts();
else if (name === 'market') renderMarket();
else if (name === 'health') renderHealth();
else if (name === 'principles') renderPrinciples();
else if (name === 'watch') renderWatch();
else if (name === 'research') renderResearch();
else if (name === 'docs') renderDocs();
}
// ── Data Fetching ──
async function fetchJSON(url) {
try {
const r = await fetch(url);
return await r.json();
} catch(e) { return {}; }
}
async function refreshAll() {
document.getElementById('tab-overview').classList.add('loading');
const [overview, portfolio, watchlist, reports, watchData] = await Promise.all([
fetchJSON('/api/overview'),
fetchJSON('/api/portfolio'),
fetchJSON('/api/watchlist'),
fetchJSON('/api/reports'),
fetchJSON('/api/watch'),
]);
portfolioData = portfolio;
watchlistData = watchlist;
reportsData = Array.isArray(reports) ? reports : [];
renderAlerts(overview && overview.alerts ? overview.alerts : []);
// 右上角时间显示:来源时间说明
let timeParts = ['⏱页面 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'})];
if (watchData && watchData.data_time) {
const dt = watchData.data_time.slice(5,16);
timeParts.unshift('📡价格 ' + dt);
// 开盘期间检测 price_monitor 是否存(数据>5分钟=可能挂了)
const now = new Date();
const h = now.getHours(), m = now.getMinutes(), d = now.getDay();
const isMarketHours = d >= 1 && d <= 5 && ((h === 9 && m >= 30) || (h >= 10 && h < 15) || (h === 15 && m === 0));
if (isMarketHours) {
const dataDate = new Date(watchData.data_time);
const ageMin = (now - dataDate) / 60000;
if (ageMin > 5) {
timeParts[0] = '🚨价格 ' + dt + '' + Math.round(ageMin) + '分钟未更新)';
}
}
}
document.getElementById('updateTime').innerHTML = timeParts.join(' | ');
// 如果有告警、整行变红闪烁
if (timeParts[0].startsWith('🚨')) {
const el = document.getElementById('updateTime');
el.style.color = '#e53935';
el.style.fontWeight = 'bold';
el.className = 'pulse-warn';
} else {
document.getElementById('updateTime').style.color = '';
document.getElementById('updateTime').style.fontWeight = '';
document.getElementById('updateTime').className = '';
}
document.getElementById('tab-overview').classList.remove('loading');
renderTab(document.querySelector('.tab-btn.active')?.dataset?.tab || 'overview');
}
// ── Overview Tab ──
function renderOverview() {
const el = document.getElementById('tab-overview');
fetchJSON('/api/overview').then(d => {
const h = d.top_movers || [];
const alertHtml = h.length ? h.map(s => `
<div class="flex items-center justify-between p-2 bg-slate-800/50 rounded-lg">
<span class="text-sm">${s.name||s.code}</span>
<span class="text-sm font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">
${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(2)}%
</span>
</div>
`).join('') : '<div class="text-xs text-slate-500">暂无大幅异动</div>';
// ── 温区自适应横幅(2026-08-13 概览页置顶)──
let regimeHtml = '';
try {
const rw = d.regime_weights || null;
if (rw && rw.state) {
const regIcon = {trend_up:'📈', choppy:'〰️', trend_down:'📉'}[rw.state] || '🌐';
const regName = {trend_up:'趋势市', choppy:'震荡市', trend_down:'下跌市'}[rw.state] || rw.state;
const regColor = rw.state==='trend_up' ? 'text-emerald-400' : rw.state==='choppy' ? 'text-amber-400' : 'text-red-400';
const bandIcon = {panic:'🔴', fear:'🟠', neutral:'🟡', greed:'🟢', euphoria:'🔵'}[rw.temp_band] || '⚪';
const bandName = {panic:'恐慌', fear:'偏冷', neutral:'中性', greed:'偏热', euphoria:'亢奋'}[rw.temp_band] || rw.temp_band;
const activeList = (rw.active || []).slice(0, 6);
regimeHtml = '<div class="bg-indigo-900/25 border border-indigo-500/40 rounded-xl p-3 mb-4">' +
'<div class="flex items-center justify-between flex-wrap gap-2">' +
'<div class="flex items-center gap-2"><span class="text-lg">🌡️</span>' +
'<span class="font-bold ' + regColor + '">' + regIcon + ' ' + regName + '</span>' +
'<span class="text-xs text-slate-400">(平滑' + (rw.state_date||'') + ')</span></div>' +
'<div class="flex items-center gap-3 text-xs">' +
'<span>温度: <b>' + bandIcon + ' ' + bandName + '</b>' + (rw.temp_rsi ? ' <span class="text-slate-500">RSI=' + rw.temp_rsi + '</span>' : '') + '</span>' +
'</div>' +
'</div>' +
(activeList.length ? '<div class="flex flex-wrap gap-1.5 mt-2">' + activeList.map(n => {
const w = rw.weights && rw.weights[n];
const wr = w && w.best_win_rate != null ? ' ' + w.best_win_rate + '%' : '';
return '<span class="text-[11px] bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 rounded px-2 py-0.5"><span class="font-mono font-bold">' + n + '</span>' + wr + '</span>';
}).join('') + '</div>' : '') +
'<div class="text-[11px] text-slate-500 mt-1.5">策略按当前温区自动激活(strategy_weights.json),选股/买卖/重评已温区感知</div>' +
'</div>';
// ── 港股温区横幅(2026-08-14 港股接入,与A股分别呈现)──
try {
const hk = rw.markets && rw.markets.hk;
if (hk && hk.state) {
const hkRegIcon = {trend_up:'📈', choppy:'〰️', trend_down:'📉'}[hk.state] || '🌐';
const hkRegName = {trend_up:'趋势市', choppy:'震荡市', trend_down:'下跌市'}[hk.state] || hk.state;
const hkRegColor = hk.state==='trend_up' ? 'text-emerald-400' : hk.state==='choppy' ? 'text-amber-400' : 'text-red-400';
const hkActive = (hk.active || []).slice(0, 6);
regimeHtml += '<div class="bg-sky-900/25 border border-sky-500/40 rounded-xl p-3 mb-4">' +
'<div class="flex items-center justify-between flex-wrap gap-2">' +
'<div class="flex items-center gap-2"><span class="text-lg">🇭🇰</span>' +
'<span class="font-bold ' + hkRegColor + '">' + hkRegIcon + ' ' + hkRegName + '</span>' +
'<span class="text-xs text-slate-400">(港股·平滑' + (hk.state_date||'') + ')</span></div>' +
'<div class="flex items-center gap-3 text-xs">' +
'<span>温度: <b>' + (hk.temp_band||'?') + '</b></span>' +
'</div>' +
'</div>' +
(hkActive.length ? '<div class="flex flex-wrap gap-1.5 mt-2">' + hkActive.map(n => {
return '<span class="text-[11px] bg-sky-500/15 text-sky-300 border border-sky-500/30 rounded px-2 py-0.5"><span class="font-mono font-bold">' + n + '</span></span>';
}).join('') + '</div>' : '<div class="text-[11px] text-slate-500 mt-2">当前温区无港股激活策略</div>') +
'<div class="text-[11px] text-slate-500 mt-1.5">港股策略按港股温区独立调度(hk_strategies),与A股分别呈现</div>' +
'</div>';
}
} catch(e) {}
}
} catch(e) {}
el.innerHTML = regimeHtml + `
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
${statCard('总资产', formatMoney(d.total_assets), '💰')}
${statCard('股票市值', formatMoney(d.stock_value), '📈')}
${statCard('仓位', (d.position_pct||0).toFixed(1)+'%', '📊')}
${statCard('总盈亏', formatMoney(d.total_pnl, true), (d.total_pnl||0)>=0?'🟢':'🔴')}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="card p-4">
<h3 class="text-sm font-semibold mb-3">⚡ 大幅异动 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('portfolio','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('portfolio','ai')">§</span></span></h3>
<div class="space-y-2">${alertHtml}</div>
</div>
<div class="card p-4">
<h3 class="text-sm font-semibold mb-3">📋 最新动态</h3>
<div id="latestReports" class="text-xs space-y-2">加载中...</div>
</div>
</div>
`;
// Load latest 3 reports
if (reportsData.length) {
document.getElementById('latestReports').innerHTML = reportsData.slice(0,3).map(r =>
`<div class="flex justify-between p-2 bg-slate-800/30 rounded-lg">
<span>${r.title||r.id}</span>
<span class="text-slate-500">${r.created_at?.slice(5,16)||''}</span>
</div>`
).join('');
}
});
}
function statCard(label, value, icon) {
return `<div class="card p-4">
<div class="text-xs text-slate-500 mb-1">${icon} ${label}</div>
<div class="text-xl font-bold font-mono text-white">${value}</div>
</div>`;
}
function formatMoney(v, signed=false) {
if (!v && v!==0) return '—';
const n = Number(v).toFixed(2);
if (signed) return n>=0 ? '+¥'+n : '-¥'+Math.abs(n).toFixed(2);
return '¥'+n.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
// ── Holdings Tab ──
function renderHoldings() {
const el = document.getElementById('tab-holdings');
const holdings = portfolioData.holdings || [];
el.innerHTML = `
<div class="card overflow-hidden">
<div class="p-3 border-b border-slate-800/50 flex justify-between items-center">
<span class="text-sm font-semibold">持仓 · ${holdings.length} 只</span>
<span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('portfolio','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('portfolio','ai')">§</span></span>
<span class="text-xs text-slate-500">点击查看详情</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead><tr class="text-slate-500 border-b border-slate-800/50">
<th class="text-left p-3">股票</th><th class="text-right p-3">仓位%</th>
<th class="text-right p-3">成本</th><th class="text-right p-3">现价</th>
<th class="text-right p-3">涨跌%</th><th class="text-right p-3">盈亏</th>
<th class="text-left p-3">操作建议</th>
</tr></thead>
<tbody>${holdings.map(s => {
const pnl = ((s.price||0) - (s.cost||0)) * (s.shares||0);
return `<tr class="stock-row border-b border-slate-800/30" onclick="openStock('${s.code}')">
<td class="p-3"><div class="font-medium text-white">${s.name}</div><div class="text-slate-500">${s.code}</div></td>
<td class="p-3 text-right font-mono">${(s.position_pct||0).toFixed(2)}</td>
<td class="p-3 text-right font-mono">${(s.cost||0).toFixed(2)}</td>
<td class="p-3 text-right font-mono">${(s.price||0).toFixed(2)}</td>
<td class="p-3 text-right font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">
${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(2)}
</td>
<td class="p-3 text-right font-mono ${pnl>=0?'badge-up':'badge-down'}">
${pnl>=0?'+':''}${pnl.toFixed(0)}
</td>
<td class="p-3">${renderRec(s)}</td>
</tr>`;
}).join('')}</tbody>
</table>
</div>
</div>
`;
}
function renderRec(s) {
const a = s.analysis || {};
const parts = [];
if (a.stop_loss) parts.push(`<span class="text-red-400">止损${a.stop_loss}</span>`);
if (a.take_profit) parts.push(`<span class="text-green-400">止盈${a.take_profit}</span>`);
if (a.suggestion) parts.push(`<span class="text-blue-400">${a.suggestion}</span>`);
if (a.buy_zone_low && a.buy_zone_high) parts.push(`<span class="text-yellow-400">补仓${a.buy_zone_low}~${a.buy_zone_high}</span>`);
if (a.reason) parts.push(`<span class="text-slate-400 text-[11px]">${a.reason}</span>`);
return parts.length ? parts.join(' · ') : `<span class="text-slate-500">待分析</span>`;
}
// ── Watchlist Tab ──
function showFullAnalysis(code) {
const text = window._fullAnalysisMap[code] || '暂无完整分析';
const modal = document.createElement('div');
modal.id = 'analysisModal';
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center';
modal.innerHTML = `<div style="background:#1e293b;border:1px solid #334155;border-radius:12px;max-width:700px;width:90%;max-height:80vh;overflow-y:auto;padding:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<span style="font-size:16px;font-weight:bold;color:#e2e8f0">📋 12维分析</span>
<span onclick="this.closest('#analysisModal').remove()" style="cursor:pointer;font-size:24px;color:#94a3b8">&times;</span>
</div>
<pre style="font-size:12px;color:#cbd5e1;line-height:1.6;white-space:pre-wrap;font-family:inherit;margin:0">${text}</pre>
</div>`;
modal.addEventListener('click', function(e) { if (e.target === this) this.remove(); });
document.body.appendChild(modal);
}
function renderWatchlist() {
const el = document.getElementById('tab-watchlist');
const stocks = watchlistData.stocks || [];
// 构建 code→full_analysis 映射
window._fullAnalysisMap = {};
stocks.forEach(s => { window._fullAnalysisMap[s.code] = s.full_analysis || ''; });
el.innerHTML = `
<div id="watchlist-candidates" class="mb-4"></div>
<div class="card overflow-hidden">
<div class="p-3 border-b border-slate-800/50 flex justify-between items-center">
<span class="text-sm font-semibold">自选 · ${stocks.length} 只</span>
<span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('watchlist','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('watchlist','ai')">§</span></span>
<span class="text-xs text-slate-500">按推荐价值排序</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead><tr class="text-slate-500 border-b border-slate-800/50">
<th class="text-left p-3">股票</th><th class="text-right p-3">现价</th>
<th class="text-right p-3">涨跌%</th><th class="text-right p-3">买入区间</th>
<th class="text-right p-3">RR</th><th class="text-right p-3">信号</th><th class="text-right p-3">仓位</th>
</tr></thead>
<tbody>${stocks.map(s => {
const el = s.entry_low||0, eh = s.entry_high||0;
const mid = (el > 0 && eh > el) ? (' <b class="text-amber-300">' + ((el+eh)/2).toFixed(2) + '</b> ') : '~';
const buyZone = el + mid + eh;
const rr = s.rr_ratio || 0;
const sl = s.stop_loss || 0;
const tp = s.take_profit || 0;
const signal = s.timing_signal || '';
const action = (s.action||'');
const tech = s.tech_snapshot || '';
const sector = s.sector_context || '';
const price = s.price || 0;
const chg = s.change_pct || 0;
const priceDisplay = price ? price.toFixed(2) : '—';
// 构建完整策略显示
let detailLines = [];
if (action) detailLines.push(action);
if (tech) detailLines.push('📊 ' + tech);
if (sector) detailLines.push('🏭 ' + sector);
const detailText = detailLines.join(' | ');
// 完整分析(点击展开)
const fullAnalysis = s.full_analysis || '';
const detailDisplay = fullAnalysis ?
detailText + '<br><span class="text-blue-400 cursor-pointer" onclick="showFullAnalysis(\'' + s.code + '\')">📋 查看完整12维分析</span>' :
detailText;
return `
<tr class="border-b border-slate-800/30 hover:bg-slate-800/20 cursor-pointer" onclick="openStock('${s.code}')">
<td class="p-3"><div class="font-medium text-white">${s.name}</div><div class="text-slate-500 text-[10px]">${s.code}</div></td>
<td class="p-3 text-right font-mono text-sm">${priceDisplay}</td>
<td class="p-3 text-right font-mono text-sm ${chg>=0?'text-green-400':'text-red-400'}">${chg>=0?'+':''}${chg.toFixed(2)}</td>
<td class="p-3 text-right font-mono text-xs text-slate-300">${buyZone}<br><span class="text-slate-500">损${sl}${tp}</span></td>
<td class="p-3 text-right font-mono text-sm ${rr>=1.5?'text-green-400':rr>0?'text-yellow-400':'text-slate-500'}">${rr>0?rr.toFixed(2):'—'}${(s.rr_high>0)?'<br><span class="text-[10px] text-slate-500">'+(s.rr_low||0).toFixed(2)+'~'+s.rr_high.toFixed(2)+'</span>':''}</td>
<td class="p-3 text-right"><span class="text-xs px-2 py-1 rounded ${signal.includes('买入')?'bg-green-900/50 text-green-300':signal.includes('关注')?'bg-yellow-900/50 text-yellow-300':'bg-slate-800 text-slate-400'}">${signal||'—'}</span></td>
<td class="p-3 text-right text-xs text-slate-400">${s.position_advice || '—'}</td>
</tr>
<tr class="border-b border-slate-800/20 hover:bg-slate-800/10 cursor-pointer" onclick="openStock('${s.code}')">
<td colspan="7" class="p-2 pl-3 text-xs text-slate-400 leading-relaxed">${detailDisplay || '—'}</td>
</tr>`;
}).join('')}</tbody>
</table>
</div>
</div>
`;
loadWatchlistCandidates();
}
// ── 候选池(2026-08-22 从市场Tab迁入:候选→提拔→自选是同一管道)──
async function loadWatchlistCandidates() {
const box = document.getElementById('watchlist-candidates');
if (!box) return;
try {
const cands = await fetchJSON('/api/candidates') || [];
if (!cands.length) { box.innerHTML = ''; return; }
box.innerHTML = `
<div class="card p-4">
<h3 class="text-sm font-semibold mb-2">🎯 主力建仓候选 (${cands.length})</h3>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead><tr class="text-slate-400">
<th class="text-left p-1">代码</th><th class="text-left p-1">名称</th>
<th class="text-center p-1">S2</th><th class="text-center p-1">S3</th>
<th class="text-center p-1">S4</th><th class="text-center p-1">S5</th>
<th class="text-center p-1">终评</th><th class="text-left p-1">理由</th>
<th class="text-left p-1">状态</th>
</tr></thead>
<tbody>${cands.slice(0,15).map(c => {
const s2 = c.pass_s2 ? '✅' : c.score_2nd ? '❌' : '⏳';
const s3 = c.pass_s3 ? '✅' : c.score_3rd ? '❌' : '⏳';
const s4 = c.pass_s4 ? '✅' : c.score_4th ? '❌' : '⏳';
const s5 = c.pass_s5 ? '✅' : c.score_5th ? '❌' : '⏳';
const status = c.promoted ? '⭐已入自选' : '📋候选';
const log = c.log ? JSON.parse(c.log) : [];
const logSummary = log.slice(-2).map(function(l){ return 'S' + l.stage + ':' + (l.passed ? '✅' : '❌'); }).join(' ');
return '<tr class="border-b border-slate-800/50 hover:bg-slate-800/20">' +
'<td class="p-1 font-mono">' + c.code + '</td>' +
'<td class="p-1">' + c.name + '</td>' +
'<td class="p-1 text-center">' + s2 + '</td>' +
'<td class="p-1 text-center">' + s3 + '</td>' +
'<td class="p-1 text-center">' + s4 + '</td>' +
'<td class="p-1 text-center">' + s5 + '</td>' +
'<td class="p-1 text-center font-bold">' + (c.score_final||'-') + '</td>' +
'<td class="p-1 text-slate-400 max-w-[200px] truncate">' + (c.reason||'').slice(0,50) + '</td>' +
'<td class="p-1"><span class="' + (c.promoted ? 'text-yellow-400' : 'text-slate-400') + '">' + status + '</span>' +
'<span class="text-slate-600 block text-[10px]">' + logSummary + '</span></td>' +
'</tr>';
}).join('')}</tbody>
</table>
</div>
</div>`;
} catch(e) { box.innerHTML = ''; }
}
// ── Market Tab ──
let marketRefreshInterval = null;
function refreshMarketData() {
const el = document.getElementById('tab-market');
if (!el || el.classList.contains('hidden')) return;
Promise.all([
fetchJSON('/api/market'),
]).then(([mkt]) => {
const sectors = mkt.sectors || [];
const regimes = mkt.regimes || [];
const REG_ICON = {trend_up:'📈', choppy:'〰️', trend_down:'📉'};
const REG_NAME = {trend_up:'趋势市', choppy:'震荡市', trend_down:'下跌市'};
const regColor = s => s==='trend_up' ? 'text-emerald-400' : s==='choppy' ? 'text-amber-400' : 'text-red-400';
const MKT_NAME = {a:'A股', hk:'港股'};
const MKT_ORDER = {a:0, hk:1};
regimes.sort((x,y) => (MKT_ORDER[x.market] ?? 9) - (MKT_ORDER[y.market] ?? 9));
const moodName = {bull:'偏多', bear:'偏空', neutral:'中性'}[mkt.mood] || '';
el.innerHTML = `
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold">🌐 市场全景 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('market','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('market','ai')">§</span></span></span>
<span class="text-xs text-slate-500">${mkt.timestamp||''}${moodName?' · '+moodName:''}${mkt.up_ratio!=null?' · 上涨占比 '+mkt.up_ratio+'%':''}</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
${regimes.length ? regimes.map(r => `
<div class="card p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-lg">${REG_ICON[r.regime]||'🌐'}</span>
<span class="text-sm font-semibold">${MKT_NAME[r.market]||r.market}</span>
<span class="text-sm font-bold ${regColor(r.regime)}">${REG_NAME[r.regime]||r.regime}</span>
<span class="text-xs text-slate-500 ml-auto">${r.date||''}</span>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div class="flex justify-between"><span class="text-slate-500">指数收盘</span><span class="font-mono">${r.close!=null?r.close:'—'}</span></div>
<div class="flex justify-between"><span class="text-slate-500">站上MA20</span><span class="font-mono ${r.above_ma20?'text-emerald-400':'text-red-400'}">${r.above_ma20?'是':'否'}</span></div>
<div class="flex justify-between"><span class="text-slate-500">MA20斜率</span><span class="font-mono">${r.ma20_slope!=null?r.ma20_slope.toFixed(3):'—'}</span></div>
<div class="flex justify-between"><span class="text-slate-500">ROC</span><span class="font-mono ${(r.roc||0)>=0?'text-emerald-400':'text-red-400'}">${r.roc!=null?r.roc.toFixed(2):'—'}</span></div>
<div class="flex justify-between"><span class="text-slate-500">ADX</span><span class="font-mono">${r.adx!=null?r.adx.toFixed(1):'—'}</span></div>
</div>
</div>`).join('') : '<div class="card p-4 text-xs text-slate-500 lg:col-span-2">暂无温区数据(market_thermometer_daily 每日生成)</div>'}
</div>
<div class="card p-4 mb-4">
<h3 class="text-sm font-semibold mb-2">🏭 行业热点</h3>
${sectors.length ? sectors.slice(0,8).map(s => `
<div class="flex justify-between items-center p-1.5 ${(s.change_pct||0)>=0?'':'bg-slate-800/20'} rounded mb-0.5">
<span class="text-xs">${s.name}<span class="text-slate-500 ml-1">${s.lead_stock||''}</span></span>
<span class="text-xs font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(1)}%</span>
</div>
`).join('') : '<div class="text-xs text-slate-500">暂无</div>'}
</div>
<div class="card p-4">
<h3 class="text-sm font-semibold mb-2">📋 全部板块排行(${sectors.length}</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1 max-h-96 overflow-y-auto">
${sectors.map(s => `
<div class="flex justify-between p-1 bg-slate-800/20 rounded text-xs">
<span>${s.name}</span>
<span class="font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(1)}%</span>
</div>
`).join('')}
</div>
</div>
`;
}).catch(e => {
el.innerHTML = `<div class="text-red-400 text-sm">加载失败: ${e.message}</div>`;
});
}
// 市场页自动刷新(30秒)
function renderMarket() {
if (marketRefreshInterval) clearInterval(marketRefreshInterval);
refreshMarketData();
marketRefreshInterval = setInterval(refreshMarketData, 30000);
}
// ── Evaluation Tab ──
let evalTimer = null;
let evalBusy = false;
function renderEvaluation() {
if (evalBusy) return;
evalBusy = true;
const el = document.getElementById('tab-evaluation');
if (evalTimer) { clearInterval(evalTimer); evalTimer = null; }
fetchJSON('/api/tracking').then(data => {
const tracks = data.tracks || [];
const stats = data.stats || {};
const statusBadge = s => {
const m = {active:'🟡进行中',hit_tp:'🟢止盈',hit_sl:'🔴止损',expired:'⚫已过期',manual_close:'🔵手动平仓'};
return m[s] || s;
};
el.innerHTML = `
<div class="flex items-center gap-2 mb-4">
<span class="text-sm font-semibold">📊 策略追踪评估</span>
<span class="text-xs bg-blue-900/50 text-blue-300 px-2 py-0.5 rounded-full">${stats.total||0} 条记录</span>
<span class="text-xs text-green-400">止盈 ${stats.hit_tp||0}</span>
<span class="text-xs text-red-400">止损 ${stats.hit_sl||0}</span>
<span class="text-xs text-yellow-400">进行中 ${stats.active||0}</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead><tr class="text-slate-500 border-b border-slate-800/50">
<th class="text-left p-2">股票</th>
<th class="text-left p-2">推荐时间</th>
<th class="text-right p-2">评分</th>
<th class="text-right p-2">RR</th>
<th class="text-right p-2">买入区(中值)</th>
<th class="text-right p-2">止损/止盈</th>
<th class="text-right p-2">推荐价</th>
<th class="text-right p-2">推荐仓位</th>
<th class="text-right p-2">理论盈亏</th>
<th class="text-right p-2">实际盈亏</th>
<th class="text-left p-2">状态</th>
</tr></thead>
<tbody>${tracks.length === 0 ? '<tr><td colspan="12" class="p-4 text-center text-slate-500">暂无追踪记录</td></tr>' : tracks.map(t => {
const ccy = (t.code||'').startsWith('0') ? 'HK$' : '¥';
const fmt = v => v ? ccy + Number(v).toFixed(2) : '—';
const mid = t.entry_mid || 0;
const sl = t.stop_loss || 0;
const tp = t.take_profit || 0;
const cp = t.current_price || 0;
const hasActual = !!(t.actual_action);
const rowBg = t.status === 'hit_tp' ? 'bg-green-900/10' : t.status === 'hit_sl' ? 'bg-red-900/10' : hasActual ? 'bg-blue-900/5' : '';
// ── 理论盈亏 ──
const tPnl = t.theoretical_pnl != null ? '<span class="' + (t.theoretical_pnl >= 0 ? 'text-red-400' : 'text-green-400') + '">' + (t.theoretical_pnl >= 0 ? '+' : '') + t.theoretical_pnl + '%</span>' : '—';
// ── 实际盈亏 ──
let actualCell = '—';
if (hasActual) {
const fp = t.floating_pnl != null ? (t.floating_pnl >= 0 ? '+' : '') + t.floating_pnl + '%' : '';
const fa = t.floating_amount != null ? ccy + (t.floating_amount >= 0 ? '+' : '') + Number(t.floating_amount).toFixed(0) : '';
actualCell = '<span class="' + (t.floating_pnl >= 0 ? 'text-red-400' : 'text-green-400') + '">' + fp + '</span><br><span class="text-slate-500" style="font-size:10px">' + fa + '</span>';
}
// ── 推荐时间 ──
let timeCell = (t.tracked_at||'').slice(0,16);
if (hasActual && t.actual_action) {
const m = t.actual_action.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/);
if (m) timeCell += '<br><span class="text-blue-400">实操 ' + m[1] + '</span>';
}
// ── 买入区:低→高排列(策略区下/中/上 + 实操价)──
let zonePrices = [];
if (t.entry_low) zonePrices.push({v:t.entry_low, label:'下', cls:'text-slate-300'});
if (mid) zonePrices.push({v:mid, label:'中', cls:'text-amber-300'});
if (t.entry_high) zonePrices.push({v:t.entry_high, label:'上', cls:'text-slate-300'});
if (hasActual && t.actual_entry) zonePrices.push({v:t.actual_entry, label:'实', cls:'text-blue-400'});
zonePrices.sort((a,b) => a.v - b.v);
let zoneCell = zonePrices.map(p => '<span class="' + p.cls + '">' + fmt(p.v) + '</span>').join('<br>');
// ── 止损/止盈:低→高排列 + 当前价 ──
let sltpPrices = [];
if (sl) sltpPrices.push({v:sl, label:'损', cls:'text-red-400'});
if (cp) sltpPrices.push({v:cp, label:'现', cls:'text-white'});
if (tp) sltpPrices.push({v:tp, label:'盈', cls:'text-green-400'});
sltpPrices.sort((a,b) => a.v - b.v);
let sltpCell = sltpPrices.map(p => '<span class="' + p.cls + '">' + p.label + fmt(p.v) + '</span>').join('<br>');
// ── 仓位 ──
let posCell = t.position_advice ? String(t.position_advice).replace(/(系统按.*/,'') : '—';
if (hasActual && t.actual_shares) posCell += '<br><span class="text-blue-400">实操 ' + t.actual_shares + '股</span>';
return '<tr class="border-b border-slate-800/30 ' + rowBg + '">' +
'<td class="p-2"><span class="font-medium text-white">' + (t.name||'') + '</span><br><span class="text-slate-500">' + t.code + '</span></td>' +
'<td class="p-2 text-slate-400">' + timeCell + '</td>' +
'<td class="p-2 text-right font-mono ' + (t.rec_score>=70?'text-green-400':'text-amber-300') + '">' + (t.rec_score||'—') + '</td>' +
'<td class="p-2 text-right font-mono">' + (t.rr_ratio ? t.rr_ratio.toFixed(2) : '—') + '</td>' +
'<td class="p-2 text-right font-mono text-xs">' + zoneCell + '</td>' +
'<td class="p-2 text-right font-mono text-xs">' + sltpCell + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + fmt(t.price_at_track) + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + posCell + '</td>' +
'<td class="p-2 text-right font-mono">' + tPnl + '</td>' +
'<td class="p-2 text-right font-mono">' + actualCell + '</td>' +
'<td class="p-2">' + statusBadge(t.status) + (t.close_reason ? '<br><span class="text-slate-500">' + t.close_reason + '</span>' : '') + '</td>' +
'</tr>';
}).join('')}</tbody>
</table>
</div>`;
if (!evalTimer) evalTimer = setInterval(renderEvaluation, 30000);
evalBusy = false;
}).catch(() => { evalBusy = false; });
}
function triggerEval() {
const btn = document.querySelector('#tab-evaluation button');
btn.textContent = '⏳ 评估中...';
btn.disabled = true;
fetch('/api/evaluation/trigger', {method:'POST'}).then(r => r.json()).then(d => {
btn.textContent = '🔄 重新评估';
btn.disabled = false;
renderEvaluation();
});
}
// ── Stock Detail Modal ──
async function openStock(code) {
const el = document.getElementById('stockModal');
el.classList.remove('hidden');
el.classList.add('flex');
document.getElementById('modalTitle').textContent = '加载中...';
const [stockData] = await Promise.all([
fetchJSON(`/api/stock/${code}`),
]);
// Also find in portfolio
const inPortfolio = (portfolioData.holdings||[]).find(s => s.code === code);
const name = inPortfolio?.name || stockData.name || code;
document.getElementById('modalTitle').textContent = name;
document.getElementById('modalMeta').textContent = `${code} · ${inPortfolio?.sector||stockData.sector||''}`;
document.getElementById('modalInfo').innerHTML = inPortfolio ? `
<div><div class="text-xs text-slate-500">成本</div><div class="text-sm font-mono">${(inPortfolio.cost||0).toFixed(2)}</div></div>
<div><div class="text-xs text-slate-500">现价</div><div class="text-sm font-mono">${(inPortfolio.price||0).toFixed(2)}</div></div>
<div><div class="text-xs text-slate-500">仓位</div><div class="text-sm font-mono">${(inPortfolio.position_pct||0).toFixed(2)}%</div></div>
<div><div class="text-xs text-slate-500">盈亏</div><div class="text-sm font-mono ${((inPortfolio.price||0)-(inPortfolio.cost||0))>=0?'badge-up':'badge-down'}">
${formatMoney(((inPortfolio.price||0)-(inPortfolio.cost||0))*(inPortfolio.shares||0), true)}
</div></div>
` : `<div class="col-span-full text-xs text-slate-500">暂无持仓数据</div>`;
// ── 当前策略卡(12维参数 + 三值RR + 操作建议)──
const sd = stockData || {};
const hasStrategy = sd.action || sd.entry_low || sd.timing_signal;
if (hasStrategy) {
const rrMid = sd.rr_ratio || 0, rrLo = sd.rr_low || 0, rrHi = sd.rr_high || 0;
const rrTxt = rrMid ? `${rrMid.toFixed(2)}${(rrHi>0)?` <span class="text-slate-500 text-[11px]">(下沿${rrLo.toFixed(2)} / 上沿${rrHi.toFixed(2)})</span>`:''}` : '—';
document.getElementById('modalStrategy').innerHTML = `
<h3 class="text-sm font-semibold text-slate-300 mb-2">🎯 当前策略 <span class="text-xs text-slate-500 font-normal">${(sd.reassessed_at||'').slice(0,16)}</span></h3>
<div class="bg-slate-800/40 rounded-lg p-3 space-y-1.5 text-xs">
<div class="flex flex-wrap gap-x-4 gap-y-1 font-mono">
${sd.timing_signal?`<span>信号 <b class="${sd.timing_signal.includes('买入')?'text-green-400':sd.timing_signal.includes('卖出')?'text-red-400':'text-yellow-400'}">${sd.timing_signal}</b></span>`:''}
${sd.entry_low?`<span class="text-slate-300">区间 ${sd.entry_low} <b class="text-amber-300">${((sd.entry_low+sd.entry_high)/2).toFixed(2)}</b> ${sd.entry_high}</span>`:''}
${sd.stop_loss?`<span class="text-red-400">损 ${sd.stop_loss}</span>`:''}
${sd.take_profit?`<span class="text-green-400">盈 ${sd.take_profit}</span>`:''}
<span class="${rrMid>=1.5?'text-green-400':'text-yellow-400'}">RR ${rrTxt}</span>
${sd.position_advice?`<span class="text-blue-400">仓位 ${sd.position_advice}</span>`:''}
</div>
${sd.action?`<div class="text-slate-200 leading-relaxed border-t border-slate-700/50 pt-1.5">${sd.action}</div>`:''}
</div>`;
} else {
document.getElementById('modalStrategy').innerHTML = '';
}
// ── 12维完整分析(折叠)──
if (sd.full_analysis) {
document.getElementById('modalAnalysis').innerHTML = `
<details class="bg-slate-800/30 rounded-lg">
<summary class="text-sm font-semibold text-slate-300 p-3 cursor-pointer select-none">📋 12维完整分析(点击展开)</summary>
<pre class="text-xs text-slate-300 leading-relaxed whitespace-pre-wrap p-3 pt-0 font-sans">${sd.full_analysis}</pre>
</details>`;
} else {
document.getElementById('modalAnalysis').innerHTML = '';
}
// Render recommendation history
const history = stockData.history || [];
document.getElementById('modalHistory').innerHTML = history.length ? history.map(h => `
<div class="flex items-start gap-2 p-2 bg-slate-800/30 rounded-lg">
<span class="text-xs text-slate-500 w-14 flex-shrink-0">${h.time?.slice(5,16)||''}</span>
<span class="text-xs font-mono w-14 flex-shrink-0">¥${h.price||'—'}</span>
<span class="text-xs flex-1">${h.recommendation||''}${h.reason?' — '+h.reason:''}</span>
</div>
`).join('') : '<div class="text-xs text-slate-500">暂无操作建议记录</div>';
// Mini chart (mock data if no real data)
const chartCanvas = document.getElementById('modalChart');
chartCanvas.innerHTML = '<canvas id="stockChart"></canvas>';
const ctx = document.getElementById('stockChart').getContext('2d');
if (window._stockChart) { try { window._stockChart.destroy(); } catch(e) {} window._stockChart = null; }
if (stockData.price_history && stockData.price_history.length > 1) {
window._stockChart = new Chart(ctx, {
type: 'line',
data: {
labels: stockData.price_history.map(p => p.date),
datasets: [{
label: '价格',
data: stockData.price_history.map(p => p.price),
borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.1)',
fill: true, tension: 0.3, pointRadius: 2,
}]
},
options: { responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { display: false }, y: { ticks: { font: {size:10} } } }
}
});
} else {
chartCanvas.innerHTML = '<div class="flex items-center justify-center h-full text-xs text-slate-500">暂无价格历史数据</div>';
}
}
function closeStock() {
document.getElementById('stockModal').classList.add('hidden');
}
// ── Prompts Tab ──
function renderPrompts() {
const el = document.getElementById('tab-prompts');
el.innerHTML = '<div class="text-center text-slate-500 py-8">加载中...</div>';
Promise.all([
fetch('/api/prompts').then(r => r.json()),
fetch('/api/prompts/effectiveness').then(r => r.json()),
]).then(([data, effectiveness]) => {
const prompts = data.prompts || [];
const categories = data.categories || {};
let html = '<div class="space-y-4">';
html += '<div class="flex items-center justify-between mb-2"><span class="text-sm font-semibold">📝 提示词管理 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp(\'prompts\',\'human\')">?</span><span class="spec-btn ai" onclick="showModuleHelp(\'prompts\',\'ai\')">§</span></span></span></div>';
// 分类筛选按钮
const catSet = {};
prompts.forEach(p => { catSet[p.category] = true; });
html += '<div class="flex flex-wrap gap-2 mb-4">';
html += '<button class="px-3 py-1 text-xs rounded-full bg-slate-700 text-white" onclick="filterPromptTable(\'\')">全部</button>';
Object.keys(catSet).sort().forEach(cat => {
html += `<button class="px-3 py-1 text-xs rounded-full bg-slate-800 text-slate-300 hover:bg-slate-700" onclick="filterPromptTable('${cat}')">${categories[cat]||cat}</button>`;
});
html += '<button class="px-3 py-1 text-xs rounded-full bg-amber-900/50 text-amber-400 ml-auto" onclick="loadPromptReport()">📊 版本有效性报告</button>';
html += '</div>';
// 提示词列表
if (prompts.length === 0) {
html += '<div class="card p-6 text-center">' +
'<div class="text-slate-400 mb-2">⚠️ 提示词注册表未初始化</div>' +
'<div class="text-xs text-slate-500">请在服务器运行 prompt_manager/init_registry.py 初始化注册表。</div>' +
'<div class="text-xs text-slate-600 mt-2">路径: /home/hmo/MoFin/prompt_manager/init_registry.py</div>' +
'</div>';
} else {
html += '<div class="card overflow-hidden"><table class="w-full text-sm">';
html += '<thead><tr class="text-left text-slate-400 border-b border-slate-700/50"><th class="p-3 font-medium">提示词</th><th class="p-3 font-medium">分类</th><th class="p-3 font-medium">当前版本</th><th class="p-3 font-medium">版本数</th><th class="p-3 font-medium">关联策略</th><th class="p-3 font-medium">成功率</th><th class="p-3 font-medium">操作</th></tr></thead><tbody>';
prompts.forEach(p => {
const verCount = (p.versions||[]).length;
const curVer = p.current_version || '-';
const eff = effectiveness[`${p.id}@${curVer}`] || {};
const sr = eff.success_rate !== undefined ? eff.success_rate + '%' : '-';
const total = eff.total || 0;
html += `<tr class="border-b border-slate-800/50 hover:bg-slate-800/30 prompt-row" data-category="${p.category}">
<td class="p-3"><strong>${p.name}</strong><br><small class="text-slate-500">${p.id}</small></td>
<td class="p-3"><span class="px-2 py-0.5 text-xs rounded-full bg-slate-700 text-slate-300">${categories[p.category]||p.category}</span></td>
<td class="p-3"><span class="px-2 py-0.5 text-xs rounded-full bg-blue-900/50 text-blue-400">${curVer}</span></td>
<td class="p-3">${verCount}</td>
<td class="p-3">${total}</td>
<td class="p-3">${sr}</td>
<td class="p-3">
<button class="px-2 py-1 text-xs bg-blue-900/50 text-blue-400 hover:bg-blue-800/50 rounded" onclick="showPromptVersions('${p.id}')">📋 版本</button>
</td>
</tr>`;
});
html += '</tbody></table></div>';
}
html += '</div>';
el.innerHTML = html;
// 保存数据供版本弹窗使用
window._promptData = data;
}).catch(err => {
el.innerHTML = '<div class="card p-6 text-center">' +
'<div class="text-amber-400 mb-2">⚠️ 提示词注册表加载失败</div>' +
'<div class="text-xs text-slate-400">错误: ' + (err.message || 'unknown') + '</div>' +
'<div class="text-xs text-slate-500 mt-2">请确认服务器已运行 prompt_manager/init_registry.py 初始化注册表。</div>' +
'</div>';
});
}
function filterPromptTable(category) {
document.querySelectorAll('.prompt-row').forEach(row => {
row.style.display = (!category || row.dataset.category === category) ? '' : 'none';
});
}
function showPromptVersions(promptId) {
fetch(`/api/prompts/${promptId}`)
.then(r => r.json())
.then(data => {
const prompt = data.prompt || {};
const history = data.version_history || [];
const versions = prompt.versions || [];
let html = `<p class="text-xs text-slate-400 mb-3">${prompt.description || ''}</p>`;
html += '<div class="overflow-x-auto"><table class="w-full text-sm">';
html += '<thead><tr class="text-left text-slate-400 border-b border-slate-700">';
html += '<th class="p-2">版本</th><th class="p-2">标签</th><th class="p-2">状态</th><th class="p-2">时间</th><th class="p-2">变更说明</th></tr></thead><tbody>';
// 按版本号排序
const sorted = [...history].sort((a, b) => a.version.localeCompare(b.version, undefined, {numeric: true}));
sorted.forEach(h => {
const statusText = h.is_current ? '当前' : h.status;
const statusClass = h.is_current ? 'text-green-400' : h.status==='deprecated' ? 'text-amber-400' : 'text-slate-500';
// 找到该版本的完整内容
const verData = versions.find(v => v.version === h.version);
const contentPreview = verData?.content ? verData.content.slice(0, 80) + '...' : '';
html += `<tr class="border-b border-slate-800/50 hover:bg-slate-700/50 cursor-pointer" onclick="showVersionDetail('${promptId}', '${h.version}')">
<td class="p-2 font-mono">${h.version}</td>
<td class="p-2">${h.label}</td>
<td class="p-2 ${statusClass}">${statusText}</td>
<td class="p-2 text-xs text-slate-500">${h.created_at}</td>
<td class="p-2 text-xs text-slate-400">${h.changelog}</td>
</tr>`;
});
html += '</tbody></table></div>';
html += '<p class="text-xs text-slate-500 mt-2">💡 点击任意行查看该版本的完整内容</p>';
openPromptModal(prompt.name + ' · 版本历史', html);
// 缓存数据,供 showVersionDetail 使用
window._promptVersionsCache = { promptId, versions };
});
}
function showVersionDetail(promptId, version) {
const cache = window._promptVersionsCache;
const versions = cache?.promptId === promptId ? cache.versions : [];
// 直接从API拿(保证最新)
fetch(`/api/prompts/${promptId}`)
.then(r => r.json())
.then(data => {
const prompt = data.prompt || {};
const v = (prompt.versions || []).find(x => x.version === version);
if (!v) return;
const isCurrent = v.version === prompt.current_version;
const statusLabel = isCurrent ? '当前版本' : v.status;
const statusColor = isCurrent ? 'bg-blue-900/50 text-blue-400' : 'bg-slate-700 text-slate-400';
let html = `<div class="mb-3 flex items-center gap-2 flex-wrap">
<span class="font-mono font-bold text-lg">${v.version}</span>
<span class="text-sm text-slate-400">${v.label}</span>
<span class="px-2 py-0.5 text-xs rounded-full ${statusColor}">${statusLabel}</span>
<span class="text-xs text-slate-500 ml-auto">${v.created_at}</span>
</div>`;
if (v.changelog) {
html += `<div class="mb-3 text-xs text-slate-400 bg-slate-800/50 px-3 py-2 rounded-lg">🔄 ${v.changelog}</div>`;
}
html += `<pre class="text-xs bg-slate-900 p-4 rounded-lg overflow-x-auto whitespace-pre-wrap leading-relaxed">${v.content || '(无内容)'}</pre>`;
// 底部导航
const allVersions = prompt.versions || [];
const sorted = [...allVersions].sort((a, b) => a.version.localeCompare(b.version, undefined, {numeric: true}));
const idx = sorted.findIndex(x => x.version === version);
html += '<div class="flex justify-between mt-4 pt-3 border-t border-slate-700/50">';
if (idx > 0) {
html += `<button class="text-xs text-blue-400 hover:text-blue-300" onclick="showVersionDetail('${promptId}', '${sorted[idx-1].version}')">← ${sorted[idx-1].version}</button>`;
} else {
html += '<span></span>';
}
html += `<button class="text-xs text-slate-400 hover:text-slate-300" onclick="showPromptVersions('${promptId}')">↑ 返回版本列表</button>`;
if (idx < sorted.length - 1) {
html += `<button class="text-xs text-blue-400 hover:text-blue-300" onclick="showVersionDetail('${promptId}', '${sorted[idx+1].version}')">${sorted[idx+1].version} →</button>`;
} else {
html += '<span></span>';
}
html += '</div>';
openPromptModal(prompt.name + ' · ' + v.version + ' ' + v.label, html);
});
}
function loadPromptReport() {
fetch('/api/prompts/report')
.then(r => r.json())
.then(data => {
openPromptModal('📊 提示词版本有效性报告',
`<pre class="text-xs text-slate-300 whitespace-pre-wrap">${data.report||'暂无数据'}</pre>`);
});
}
function openPromptModal(title, bodyHtml) {
document.getElementById('promptModalTitle').textContent = title;
document.getElementById('promptModalBody').innerHTML = bodyHtml;
document.getElementById('promptModal').classList.remove('hidden');
document.getElementById('promptModal').classList.add('flex');
}
function closePromptModal() {
document.getElementById('promptModal').classList.add('hidden');
document.getElementById('promptModal').classList.remove('flex');
}
// ── Spec System ──
let _specCache = {};
function closeSpecModal() {
const el = document.getElementById('specModal');
if (el) { el.classList.add('hidden'); el.classList.remove('flex'); }
}
function showColumnHelp(colKey) {
// 显示策略表某列的含义说明(数据源:specs/strategy.json human_help.columns[colKey]
const cacheKey = 'strategy:human';
const cached = _specCache[cacheKey];
const modal = document.getElementById('specModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
document.getElementById('specModalTitle').textContent = '加载中...';
document.getElementById('specModalSubtitle').textContent = '';
document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">正在获取列说明...</div>';
const render = function(spec) {
const cols = (spec && spec.human_help && spec.human_help.columns) || {};
const col = cols[colKey];
if (!col) {
document.getElementById('specModalBody').innerHTML = `<div class="text-red-400">未找到列说明: ${colKey}</div>`;
return;
}
document.getElementById('specModalTitle').textContent = col.label || colKey;
document.getElementById('specModalSubtitle').textContent = '列说明 · 这列是干嘛的';
let html = '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">这列是什么</div><p class="text-[#c9d1d9]">' + (col.desc || '—') + '</p></div>';
if (col.see) html += '<div class="mb-2"><div class="text-xs font-semibold text-[#3fb950] mb-1">怎么读 / 注意</div><p class="text-[#8b949e]">' + col.see + '</p></div>';
html += '<div class="text-[10px] text-slate-600 mt-4">来源:specs/strategy.json → human_help.columns.' + colKey + 'SSOT</div>';
document.getElementById('specModalBody').innerHTML = html;
};
if (cached) { render(cached); }
else {
fetchJSON('/api/module-spec/strategy').then(spec => {
if (spec && typeof spec === 'object') { _specCache[cacheKey] = spec; render(spec); }
else document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">无规范数据</div>';
});
}
}
function showModuleHelp(module, type) {
const cacheKey = module + ':' + type;
const cached = _specCache[cacheKey];
const modal = document.getElementById('specModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
// Show loading
document.getElementById('specModalTitle').textContent = '加载中...';
document.getElementById('specModalSubtitle').textContent = '';
document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">正在获取规范...</div>';
const url = `/api/module-spec/${module}`;
const doRender = function(spec) {
if (spec.error) {
document.getElementById('specModalBody').innerHTML = `<div class="text-red-400">获取失败: ${spec.error}</div>`;
return;
}
_specCache[cacheKey] = spec;
_renderSpecModal(spec, type);
};
if (cached) {
doRender(cached);
} else {
fetchJSON(url).then(spec => {
if (spec && typeof spec === 'object') {
_specCache[cacheKey] = spec;
doRender(spec);
} else {
document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">无规范数据</div>';
}
});
}
}
function _renderSpecModal(spec, type) {
const titleEl = document.getElementById('specModalTitle');
const subEl = document.getElementById('specModalSubtitle');
const bodyEl = document.getElementById('specModalBody');
let html = '';
if (type === 'human') {
const h = spec.human_help || {};
titleEl.textContent = h.title || spec.module || module;
subEl.textContent = 'Human Help · 使用指南';
if (h.description && h.description.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">说明</div><p class="text-[#c9d1d9]">' + h.description.join('<br>') + '</p></div>';
}
if (h.usage && h.usage.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#3fb950] mb-1">用法</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
h.usage.forEach(u => { html += '<li>' + u + '</li>'; });
html += '</ul></div>';
}
if (h.troubleshooting && h.troubleshooting.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#d29922] mb-1">故障排除</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
h.troubleshooting.forEach(t => { html += '<li>' + t + '</li>'; });
html += '</ul></div>';
}
} else {
const a = spec.ai_spec || {};
titleEl.textContent = spec.module || module;
subEl.textContent = 'AI Spec · 模块规范';
if (a.apis && a.apis.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">APIs</div>';
a.apis.forEach(api => {
const method = api.method || 'GET';
const color = method === 'GET' ? 'text-[#3fb950]' : method === 'POST' ? 'text-[#d29922]' : method === 'DELETE' ? 'text-[#f85149]' : 'text-[#58a6ff]';
html += '<div class="bg-[#0d1117] rounded p-2 mb-1 text-xs"><span class="font-mono font-bold ' + color + '">' + method + '</span> <span class="font-mono text-[#c9d1d9]">' + (api.path || '') + '</span>';
if (api.description) html += '<div class="text-[#8b949e] mt-0.5">' + api.description + '</div>';
html += '</div>';
});
html += '</div>';
}
if (a.dependencies && a.dependencies.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#3fb950] mb-1">依赖</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
a.dependencies.forEach(d => { html += '<li>' + d + '</li>'; });
html += '</ul></div>';
}
if (a.constraints && a.constraints.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#d29922] mb-1">约束</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
a.constraints.forEach(c => { html += '<li>' + c + '</li>'; });
html += '</ul></div>';
}
if (a.must_not && a.must_not.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#f85149] mb-1">禁止</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
a.must_not.forEach(m => { html += '<li>' + m + '</li>'; });
html += '</ul></div>';
}
if (a.related_files && a.related_files.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">相关文件</div><ul class="list-disc list-inside text-xs font-mono text-[#8b949e] space-y-1">';
a.related_files.forEach(f => { html += '<li>' + f + '</li>'; });
html += '</ul></div>';
}
}
if (!html) {
html = '<div class="text-slate-500">该模块暂无规范数据</div>';
}
bodyEl.innerHTML = html;
}
// ── Health Tab (native) ──
let healthRefreshInterval = null;
async function renderHealth() {
const el = document.getElementById('tab-health');
el.innerHTML = '<div class="text-slate-500 py-8">加载中...</div>';
try {
const [services, monitor] = await Promise.all([
fetchJSON('/api/services'),
fetchJSON('/api/monitor')
]);
// XMPP 通道健康(独立 fetch,不阻塞主流程)
let xmpp = null;
try { xmpp = await fetchJSON('/api/xmpp/health'); } catch(e) {}
// 最近 XMPP 对话日志(bot 挂钩 log_xmpp 后的真实聊天记录)
let xmppMsgs = [];
try { const md = await fetchJSON('/api/xmpp/messages'); xmppMsgs = md.messages || []; } catch(e) {}
const svcs = services.services || [];
const summary = services.summary || { ok: 0, total: 0 };
const tasks = monitor.tasks || [];
// Summary cards — 包含 XMPP 状态
let html = '<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#3fb950] font-mono" id="hSvcOk">' + (summary.ok || 0) + '</div><div class="text-xs text-slate-500">正常服务</div></div>';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-white font-mono" id="hSvcTotal">' + (summary.total || 0) + '</div><div class="text-xs text-slate-500">总服务数</div></div>';
const xmppStatus = xmpp ? xmpp.status : 'unknown';
const xmppColor = xmppStatus === 'ok' ? '#3fb950' : xmppStatus === 'degraded' ? '#d29922' : '#f85149';
const xmppLabel = xmppStatus === 'ok' ? 'XMPP 正常' : xmppStatus === 'degraded' ? 'XMPP 降级' : xmppStatus === 'critical' ? 'XMPP 断联' : 'XMPP 无数据';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold font-mono" id="hXmppAge" style="color:' + xmppColor + '">' + (xmpp ? (xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec/60) + 'm' : '--') : '--') + '</div><div class="text-xs text-slate-500" id="hXmppLabel">' + xmppLabel + '</div></div>';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#d29922] font-mono" id="hXmppErr">' + (xmpp ? xmpp.error_rate_1h + '%' : '--') + '</div><div class="text-xs text-slate-500">XMPP 错误率(1h)</div></div>';
html += '</div>';
// Services by layer
const layers = {};
svcs.forEach(s => {
const layer = s.layer || 'other';
if (!layers[layer]) layers[layer] = [];
layers[layer].push(s);
});
html += '<div class="card overflow-hidden mb-4"><table class="w-full text-xs"><thead><tr class="text-slate-500 border-b border-slate-800/50"><th class="text-left p-3">层级</th><th class="text-left p-3">服务</th><th class="text-left p-3">端口</th><th class="text-left p-3">状态</th></tr></thead><tbody>';
Object.keys(layers).sort().forEach(layer => {
layers[layer].forEach((s, i) => {
const ok = s.health?.ok;
const statusIcon = ok ? '🟢' : (ok === false ? '🔴' : '🟡');
const statusColor = ok ? 'text-[#3fb950]' : (ok === false ? 'text-[#f85149]' : 'text-[#d29922]');
const label = s.label || s.name || s.type || '';
const port = s.port || '-';
if (i === 0) {
html += '<tr class="border-b border-slate-800/30"><td class="p-3 font-semibold text-slate-300" rowspan="' + layers[layer].length + '">' + layer + '</td><td class="p-3">' + s.name + ' <span class="text-slate-500">' + label + '</span></td><td class="p-3 font-mono">' + port + '</td><td class="p-3 ' + statusColor + '" id="hSvc_' + s.name + '">' + statusIcon + (ok ? ' 正常' : (ok === false ? ' 异常' : ' 未知')) + '</td></tr>';
} else {
html += '<tr class="border-b border-slate-800/30"><td class="p-3">' + s.name + ' <span class="text-slate-500">' + label + '</span></td><td class="p-3 font-mono">' + port + '</td><td class="p-3 ' + statusColor + '" id="hSvc_' + s.name + '">' + statusIcon + (ok ? ' 正常' : (ok === false ? ' 异常' : ' 未知')) + '</td></tr>';
}
});
});
html += '</tbody></table></div>';
// Task status
if (tasks.length) {
html += '<div class="card overflow-hidden"><table class="w-full text-xs"><thead><tr class="text-slate-500 border-b border-slate-800/50"><th class="text-left p-3">任务</th><th class="text-left p-3">状态</th><th class="text-left p-3">详情</th></tr></thead><tbody>';
tasks.forEach(t => {
const st = t.status || '';
const sc = st === 'ok' || st === 'running' ? 'text-[#3fb950]' : st === 'error' ? 'text-[#f85149]' : st === 'pending' ? 'text-[#d29922]' : 'text-slate-500';
html += '<tr class="border-b border-slate-800/30"><td class="p-3">' + (t.name || '') + '</td><td class="p-3 ' + sc + '">' + st + '</td><td class="p-3 text-slate-500">' + (t.detail || '') + '</td></tr>';
});
html += '</tbody></table></div>';
}
html += '<div class="text-xs text-slate-600 mt-2" id="hUpdated">更新于 ' + (monitor.generated_at || new Date().toLocaleTimeString()) + '</div>';
// XMPP 通道详情
if (xmpp) {
html += '<div class="card p-4 mt-4"><h3 class="text-sm font-semibold text-[#58a6ff] mb-2">📡 XMPP 通道</h3>';
html += '<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">';
html += '<div><span class="text-slate-500">最后消息</span><br><span class="font-mono" id="hXmppDetail_age">' + (xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec) + '秒前' : '无数据') + '</span></div>';
html += '<div><span class="text-slate-500">1h错误率</span><br><span class="font-mono" id="hXmppDetail_err">' + xmpp.error_rate_1h + '%</span></div>';
html += '<div><span class="text-slate-500">ejabberd</span><br><span class="font-mono" id="hXmppDetail_eja">' + (xmpp.ejabberd || '?') + '</span></div>';
html += '<div><span class="text-slate-500">状态</span><br><span class="font-mono" id="hXmppDetail_st">' + xmpp.status + '</span></div>';
html += '</div>';
// Bot 活动
const ba = xmpp.bot_activity || {};
html += '<div class="grid grid-cols-3 gap-2 text-xs mt-3 pt-3 border-t border-slate-800/50">';
html += '<div><span class="text-slate-500">📩 入站</span><br><span class="font-mono text-[#58a6ff]" id="hBot_in">' + (ba.inbound || 0) + '</span></div>';
html += '<div><span class="text-slate-500">📤 出站</span><br><span class="font-mono ' + (ba.outbound ? 'text-[#3fb950]' : (ba.inbound ? 'text-[#f85149]' : 'text-slate-500')) + '" id="hBot_out">' + (ba.outbound || 0) + '</span></div>';
html += '<div><span class="text-slate-500">⚠️ 错误</span><br><span class="font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500') + '" id="hBot_err">' + (ba.errors || 0) + '</span></div>';
html += '</div>';
if (ba.last_error) {
const errAge = ba.last_error_age_sec >= 0 ? Math.round(ba.last_error_age_sec/60) + '分钟前' : '';
if (ba.last_error_resolved) {
html += '<div class="text-xs text-slate-500 mt-2 p-2 bg-slate-800/30 rounded" id="hBot_lastErr">⚠️ 历史错误(' + errAge + ',已恢复): ' + ba.last_error + '</div>';
} else {
html += '<div class="text-xs text-[#f85149] mt-2 p-2 bg-red-900/20 rounded" id="hBot_lastErr">⚠️ ' + errAge + ': ' + ba.last_error + '</div>';
}
}
// Hermes Gateway + LLM Provider
const gw = xmpp.gateways || {};
const llm = xmpp.llm_provider || {};
html += '<div class="grid grid-cols-2 gap-2 text-xs mt-3 pt-3 border-t border-slate-800/50">';
html += '<div><span class="text-slate-500">🔌 Hermes Gateway</span><br>';
Object.entries(gw).forEach(([k,v]) => {
html += '<span class="font-mono ' + (v.alive ? 'text-[#3fb950]' : 'text-[#f85149]') + '">' + k + ':' + v.port + (v.alive ? ' ✅' : ' ❌') + '</span> ';
});
html += '</div>';
html += '<div><span class="text-slate-500">🧠 LLM Provider</span><br><span class="font-mono ' + (llm.status === 'ok' ? 'text-[#3fb950]' : 'text-[#f85149]') + '" id="hLlmStatus">' + (llm.status || '?') + '</span>';
if (llm.error) html += '<br><span class="text-[#f85149]" id="hLlmErr">' + llm.error + '</span>';
html += '</div></div>';
// ── 最近 XMPP 对话日志 ──
html += '<div class="mt-3 pt-3 border-t border-slate-800/50">';
html += '<div class="text-xs text-slate-500 mb-2">💬 最近对话(' + xmppMsgs.length + ' 条)</div>';
if (xmppMsgs.length === 0) {
html += '<div class="text-xs text-slate-600 p-2">暂无记录 — bot 重启后对话开始采集</div>';
} else {
html += '<div class="space-y-1 max-h-64 overflow-y-auto" id="hXmppMsgs">';
xmppMsgs.slice(0, 10).forEach(m => {
const isIn = m.direction === 'in';
const arrow = isIn ? '📩' : '📤';
const dirColor = isIn ? 'text-[#58a6ff]' : 'text-[#3fb950]';
const statusMark = m.status === 'ok' ? '' : ' <span class="text-[#f85149]">[' + m.status + ']</span>';
const who = isIn ? (m.from || '').split('@')[0] : '知微';
const t = (m.timestamp || '').slice(11, 16);
const preview = (m.body_preview || '').replace(/</g, '&lt;').slice(0, 80);
html += '<div class="text-xs p-1.5 rounded bg-slate-800/40 flex gap-2"><span class="' + dirColor + ' shrink-0">' + arrow + ' ' + who + '</span><span class="text-slate-400 flex-1 break-all">' + preview + statusMark + '</span><span class="text-slate-600 shrink-0 font-mono">' + t + '</span></div>';
});
html += '</div>';
}
html += '</div>';
html += '</div>';
}
// ── 原有健康监控(功能树/Cron/数据实体/数据流)—— 恢复自 mofin_health.html ──
html += '<div class="card p-4 mt-4">';
html += '<div class="text-sm font-semibold text-slate-300 mb-2">📊 系统健康监控(功能树 · Cron · 数据实体 · 数据流)</div>';
html += '<iframe src="/mofin_health.html" style="width:100%;height:80vh;border:none;background:#0d1117;border-radius:8px;"></iframe>';
html += '</div>';
el.innerHTML = html;
el.dataset.rendered = '1'; // 标记已首渲
} catch(e) {
el.innerHTML = '<div class="text-red-400">加载健康状态失败: ' + e.message + '</div>';
}
}
// 增量刷新健康数据(不重建 DOM,无闪烁)
async function refreshHealth() {
const el = document.getElementById('tab-health');
if (!el || el.classList.contains('hidden') || !el.dataset.rendered) return;
try {
const [services, xmpp, msgData] = await Promise.all([
fetchJSON('/api/services'),
fetchJSON('/api/xmpp/health'),
fetchJSON('/api/xmpp/messages').catch(() => null)
]);
const svcs = (services.services || []);
const sum = services.summary || {};
// 更新卡片数字
const okEl = document.getElementById('hSvcOk');
const totalEl = document.getElementById('hSvcTotal');
const ageEl = document.getElementById('hXmppAge');
const errEl = document.getElementById('hXmppErr');
const labelEl = document.getElementById('hXmppLabel');
if (okEl) okEl.textContent = sum.ok || 0;
if (totalEl) totalEl.textContent = sum.total || 0;
if (ageEl && xmpp) ageEl.textContent = xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec/60) + 'm' : '--';
if (errEl && xmpp) errEl.textContent = xmpp.error_rate_1h + '%';
// 更新服务行状态
svcs.forEach(s => {
const cell = document.getElementById('hSvc_' + s.name);
if (cell) {
const ok = s.health?.ok;
cell.textContent = (ok ? '🟢' : (ok === false ? '🔴' : '🟡')) + (ok ? ' 正常' : (ok === false ? ' 异常' : ' 未知'));
cell.className = 'p-3 ' + (ok ? 'text-[#3fb950]' : (ok === false ? 'text-[#f85149]' : 'text-[#d29922]'));
}
});
// 更新 XMPP 详情 + Bot 活动
if (xmpp) {
const d_age = document.getElementById('hXmppDetail_age');
const d_err = document.getElementById('hXmppDetail_err');
const d_eja = document.getElementById('hXmppDetail_eja');
const d_st = document.getElementById('hXmppDetail_st');
if (d_age) d_age.textContent = xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec) + '秒前' : '无数据';
if (d_err) d_err.textContent = xmpp.error_rate_1h + '%';
if (d_eja) d_eja.textContent = xmpp.ejabberd || '?';
if (d_st) d_st.textContent = xmpp.status;
if (ageEl && xmpp) ageEl.style.color = xmpp.last_message_age_sec > 600 ? '#f85149' : '#3fb950';
if (labelEl && xmpp) {
const labels = {ok:'XMPP 正常', degraded:'XMPP 降级', critical:'XMPP 断联'};
labelEl.textContent = labels[xmpp.status] || 'XMPP 无数据';
}
// Bot 活动
const ba = xmpp.bot_activity || {};
const bi = document.getElementById('hBot_in'), bo = document.getElementById('hBot_out');
const be = document.getElementById('hBot_err'), bl = document.getElementById('hBot_lastErr');
if (bi) bi.textContent = ba.inbound || 0;
if (bo) { bo.textContent = ba.outbound || 0; bo.className = 'font-mono ' + (ba.outbound ? 'text-[#3fb950]' : (ba.inbound ? 'text-[#f85149]' : 'text-slate-500')); }
if (be) { be.textContent = ba.errors || 0; be.className = 'font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500'); }
if (bl) {
const errAge = ba.last_error_age_sec >= 0 ? Math.round(ba.last_error_age_sec/60) + '分钟前' : '';
if (ba.last_error && ba.last_error_resolved) {
bl.textContent = '⚠️ 历史错误(' + errAge + ',已恢复): ' + ba.last_error;
bl.className = 'text-xs text-slate-500 mt-2 p-2 bg-slate-800/30 rounded';
} else if (ba.last_error) {
bl.textContent = '⚠️ ' + errAge + ': ' + ba.last_error;
bl.className = 'text-xs text-[#f85149] mt-2 p-2 bg-red-900/20 rounded';
} else {
bl.textContent = '';
}
}
// LLM Provider
const llm = xmpp.llm_provider || {};
const ls = document.getElementById('hLlmStatus'), le = document.getElementById('hLlmErr');
if (ls) { ls.textContent = llm.status || '?'; ls.className = 'font-mono ' + (llm.status === 'ok' ? 'text-[#3fb950]' : 'text-[#f85149]'); }
if (le) le.textContent = llm.error || '';
// 最近对话列表
const msgBox = document.getElementById('hXmppMsgs');
if (msgBox && msgData && msgData.messages) {
let mh = '';
msgData.messages.slice(0, 10).forEach(m => {
const isIn = m.direction === 'in';
const arrow = isIn ? '📩' : '📤';
const dirColor = isIn ? 'text-[#58a6ff]' : 'text-[#3fb950]';
const statusMark = m.status === 'ok' ? '' : ' <span class="text-[#f85149]">[' + m.status + ']</span>';
const who = isIn ? (m.from || '').split('@')[0] : '知微';
const t = (m.timestamp || '').slice(11, 16);
const preview = (m.body_preview || '').replace(/</g, '&lt;').slice(0, 80);
mh += '<div class="text-xs p-1.5 rounded bg-slate-800/40 flex gap-2"><span class="' + dirColor + ' shrink-0">' + arrow + ' ' + who + '</span><span class="text-slate-400 flex-1 break-all">' + preview + statusMark + '</span><span class="text-slate-600 shrink-0 font-mono">' + t + '</span></div>';
});
msgBox.innerHTML = mh;
}
}
// 更新时间戳
const upd = document.getElementById('hUpdated');
if (upd) upd.textContent = '更新于 ' + new Date().toLocaleTimeString();
} catch(e) { /* 静默失败,保留旧数据 */ }
}
// ── 盯盘 ──
let watchTimer = null;
// 信号徽标颜色
function sigBadgeClass(sig) {
if (!sig) return 'bg-slate-800 text-slate-400';
const buy = ['买入','可买入','可加仓'];
const sell = ['卖出','止盈'];
if (buy.some(k => sig.includes(k))) return 'bg-green-900/50 text-green-300';
if (sell.some(k => sig.includes(k))) return 'bg-red-900/50 text-red-300';
return 'bg-yellow-900/50 text-yellow-300';
}
// 格式化策略摘要(2行截断)
function fmtStrategy(s) {
const action = s.action || '';
const advice = s.position_advice || '';
const parts = [];
if (action) parts.push(action);
if (advice) parts.push(advice);
if (parts.length === 0) return '—';
return parts.join(' · ');
}
// 格式化完整策略(modal 内用)
function fmtFullStrategy(s) {
const lines = [];
if (s.action) lines.push('操作: ' + s.action);
if (s.position_advice) lines.push('仓位建议: ' + s.position_advice);
if (s.entry_low || s.entry_high) {
const el2 = s.entry_low||0, eh2 = s.entry_high||0;
const mid2 = (el2 > 0 && eh2 > el2) ? ' 中值' + ((el2+eh2)/2).toFixed(2) : '';
lines.push('买入区间: ' + (s.entry_low||'—') + '~' + (s.entry_high||'—') + mid2);
}
if (s.stop_loss || s.take_profit) lines.push('止损: ' + (s.stop_loss||'—') + ' / 止盈: ' + (s.take_profit||'—'));
if (s.rr_ratio) lines.push('RR: ' + s.rr_ratio.toFixed(2));
if (s.timing_signal) lines.push('信号: ' + s.timing_signal);
if (s.full_analysis) lines.push('\n分析: ' + s.full_analysis);
return lines.join('\n');
}
// ── 盯盘筛选/排序状态 ──
const WATCH_SIGNALS = ['买入','可买入','可加仓','卖出','止盈','关注','观望','持有','弱势持有','信号不充分'];
let watchSigFilter = new Set(WATCH_SIGNALS); // 默认全选
let watchChipsInit = false;
function watchRenderChips() {
const host = document.getElementById('watchSigChips');
if (!host) return;
host.innerHTML = WATCH_SIGNALS.map(sig => {
const on = watchSigFilter.has(sig);
const cls = on
? 'bg-amber-500/25 text-amber-300 border-amber-500/50'
: 'bg-slate-800/50 text-slate-500 border-slate-700';
return `<button onclick="watchToggleSig('${sig}')" class="px-1.5 py-0.5 rounded border ${cls} transition">${sig}</button>`;
}).join('');
}
function watchToggleSig(sig) {
if (watchSigFilter.has(sig)) watchSigFilter.delete(sig);
else watchSigFilter.add(sig);
watchRenderChips();
renderWatch();
}
function watchSigAll(all) {
watchSigFilter = all ? new Set(WATCH_SIGNALS) : new Set();
watchRenderChips();
renderWatch();
}
function watchResetFilters() {
watchSigFilter = new Set(WATCH_SIGNALS);
document.getElementById('watchSortMode').value = 'default';
document.getElementById('watchMinRR').value = '';
watchRenderChips();
renderWatch();
}
let watchRendering = false;
async function renderWatch() {
if (watchRendering) return;
watchRendering = true;
const el = document.getElementById('watchContent');
if (!el) { watchRendering = false; return; }
try {
if (watchTimer) clearInterval(watchTimer);
if (!watchChipsInit) { watchRenderChips(); watchChipsInit = true; }
const data = await fetchJSON('/api/watch');
let stocks = data.stocks || [];
// ── 筛选:信号多选 + RR下限 ──
const minRR = parseFloat(document.getElementById('watchMinRR')?.value) || 0;
stocks = stocks.filter(s => {
const sig = s.timing_signal || '信号不充分';
if (!watchSigFilter.has(sig)) return false;
if (minRR > 0 && (s.rr_ratio || 0) < minRR) return false;
return true;
});
// ── 排序:默认=服务端分组序;覆盖模式按选定键全组内排序 ──
const sortMode = document.getElementById('watchSortMode')?.value || 'default';
const sorters = {
rr_desc: (a, b) => (b.rr_ratio || 0) - (a.rr_ratio || 0),
rr_asc: (a, b) => (a.rr_ratio || 0) - (b.rr_ratio || 0),
chg_desc: (a, b) => (b.change_pct || 0) - (a.change_pct || 0),
chg_asc: (a, b) => (a.change_pct || 0) - (b.change_pct || 0),
};
if (sorters[sortMode]) stocks = [...stocks].sort(sorters[sortMode]);
document.getElementById('watchCount').textContent = stocks.length;
document.getElementById('watchRefreshTime').textContent = new Date().toLocaleTimeString();
if (stocks.length === 0) {
el.innerHTML = '<div class="p-6 text-center text-slate-500 text-sm">当前筛选条件下暂无个股(调整筛选或点重置)</div>';
return;
}
// 分组渲染(推荐操作区域独立于持仓/自选,置顶且与XMPP推荐同步)
const groups = [
{ label: '🔥 推荐操作', g: 0 },
{ label: '💼 持仓策略', g: 1 },
{ label: '⭐ 自选策略', g: 2 },
];
let html = '<div class="overflow-x-auto"><table class="w-full text-xs">';
html += '<thead><tr class="text-slate-500 border-b border-slate-800/50">' +
'<th class="text-left p-2">股票</th>' +
'<th class="text-right p-2">现价</th>' +
'<th class="text-right p-2">涨跌%</th>' +
'<th class="text-right p-2">评分</th>' +
'<th class="text-left p-2">信号</th>' +
'<th class="text-right p-2">买入区间</th>' +
'<th class="text-right p-2">止损/止盈</th>' +
'<th class="text-right p-2">RR</th>' +
'<th class="text-right p-2">仓位</th>' +
'<th class="text-left p-2">当前操作策略</th>' +
'<th class="text-center p-2">操作策略</th>' +
'</tr></thead><tbody>';
for (const grp of groups) {
const items = stocks.filter(s => s.sort_group === grp.g);
if (items.length === 0) continue;
// 分组标题行(推荐操作区域独立视觉)
const hdrCls = grp.g === 0
? 'border-y border-amber-500/50 bg-amber-900/25'
: 'border-b border-slate-700';
html += '<tr class="' + hdrCls + '">' +
'<td colspan="10" class="p-2 font-semibold ' + (grp.g === 0 ? 'text-amber-300' : 'text-slate-300') + '">' + grp.label + ' (' + items.length + ')</td>' +
'</tr>';
for (const s of items) {
const p = s.price || 0;
const cp = s.change_pct || 0;
const sig = s.timing_signal || '';
const isRec = s.sort_group === 0; // 推荐行
const sigCls = sigBadgeClass(sig);
const chgColor = cp >= 0 ? 'text-red-400' : 'text-green-400';
const el_ = s.entry_low || 0;
const eh_ = s.entry_high || 0;
const sl_ = s.stop_loss || 0;
const tp_ = s.take_profit || 0;
const rr = s.rr_ratio || 0;
const score = s.rec_score || 0;
const scoreColor = score >= 70 ? 'text-green-400' : score >= 50 ? 'text-amber-300' : 'text-slate-400';
const shares = s.shares || 0;
const posPct = s.position_pct || 0;
const buyZone = (el_ && eh_)
? el_.toFixed(2) + ' <b class="text-amber-300">' + ((el_+eh_)/2).toFixed(2) + '</b> ' + eh_.toFixed(2)
: '—';
const sltp = (sl_ || tp_) ? '损' + (sl_||'—') + '/盈' + (tp_||'—') : '—';
const posDisplay = (isRec && s.suggested_position_pct)
? '建议' + s.suggested_position_pct + '%'
: (posPct ? posPct.toFixed(1) + '%' : '—');
// 当前操作策略摘要
const strat = fmtStrategy(s);
// 推荐行高亮 + 可执行/排队徽章
const rowCls = isRec ? 'bg-amber-900/15 border-l-2 border-l-amber-400' : 'border-b border-slate-800/30 hover:bg-slate-800/20';
let recBadge = '';
if (isRec) {
if (s.rec_exec === true) {
recBadge = '<span class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400 ml-1">💰可执行</span>';
} else if (s.rec_exec === false) {
recBadge = '<span class="text-xs px-1.5 py-0.5 rounded bg-slate-500/20 text-slate-400 ml-1">⏳排队</span>';
} else {
recBadge = '<span class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-1">🔥推荐</span>';
}
}
// 推荐行策略文本加粗醒目
const stratCls = isRec ? 'font-semibold text-amber-200' : 'text-slate-300';
html += '<tr class="' + rowCls + '" onclick="openStock(\'' + s.code + '\')" style="cursor:pointer">' +
'<td class="p-2"><span class="font-medium text-white">' + s.name + '</span><br><span class="text-slate-500">' + s.code + '</span>' + recBadge + '</td>' +
'<td class="p-2 text-right font-mono">' + (p ? p.toFixed(2) : '—') + '</td>' +
'<td class="p-2 text-right font-mono ' + chgColor + '">' + (cp >= 0 ? '+' : '') + (cp ? cp.toFixed(2) : '—') + '%</td>' +
'<td class="p-2 text-right font-mono font-bold ' + scoreColor + '">' + (score || '—') + '</td>' +
'<td class="p-2 text-left"><span class="text-xs px-2 py-1 rounded ' + sigCls + '">' + (sig || '—') + '</span></td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + buyZone + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + sltp + '</td>' +
'<td class="p-2 text-right font-mono ' + (rr >= 1.5 ? 'text-green-400' : rr > 0 ? 'text-yellow-400' : 'text-slate-500') + '">' + (rr ? rr.toFixed(2) : '—') + ((s.rr_high>0)?'<br><span class="text-[10px] text-slate-500">'+(s.rr_low||0).toFixed(2)+'~'+s.rr_high.toFixed(2)+'</span>':'') + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + posDisplay + '</td>' +
'<td class="p-2 ' + stratCls + ' max-w-[280px] break-all">' + strat + '</td>' +
'<td class="p-2 text-center"><button class="text-xs px-2 py-1 rounded bg-blue-900/50 text-blue-400 hover:bg-blue-800/50 transition" onclick="event.stopPropagation();openStrategyHistory(\'' + s.code + '\')">📋 历史</button></td>' +
'</tr>';
}
}
html += '</tbody></table></div>';
// 更新时间 + 推荐预算
html += '<div class="text-xs text-slate-600 mt-1">' + (data.updated_at ? '数据: ' + data.updated_at : '') + '</div>';
if (data.budget_pct !== undefined) {
html += '<div class="text-xs text-amber-500/70 mt-1">💰 现金 ' + (data.cash/10000).toFixed(1) + '万 / 总资产 ' + (data.total_assets/10000).toFixed(1) + '万 — 推荐操作预算 ' + data.budget_pct + '%(已用 ' + data.rec_used_pct + '%</div>';
}
el.innerHTML = html;
// 自动刷新:每60秒
watchTimer = setInterval(renderWatch, 60000);
} catch(e) {
el.innerHTML = '<div class="p-6 text-red-400 text-sm">加载失败: ' + e.message + '</div>';
watchTimer = setInterval(renderWatch, 60000);
} finally {
watchRendering = false;
}
}
// ── 操作策略历史 Modal ──
async function openStrategyHistory(code) {
const modal = document.getElementById('strategyModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
document.getElementById('strategyModalTitle').textContent = '加载中...';
document.getElementById('strategyModalMeta').textContent = '';
document.getElementById('strategyModalBody').innerHTML = '<div class="text-slate-500">正在获取策略历史...</div>';
try {
const d = await fetchJSON('/api/strategy_history/' + code + '?limit=3');
const history = d.history || [];
if (history.length === 0) {
document.getElementById('strategyModalTitle').textContent = '操作策略历史';
document.getElementById('strategyModalMeta').textContent = code + ' · 无历史记录';
document.getElementById('strategyModalBody').innerHTML = '<div class="text-slate-500 text-center py-8">暂无策略历史记录</div>';
return;
}
// 找出股票名称
const name = history[0].name || code;
document.getElementById('strategyModalTitle').textContent = name + ' · ' + code;
document.getElementById('strategyModalMeta').textContent = '最近 ' + history.length + ' 条策略记录';
let body = '';
// 最新一条默认展开,其余可折叠
for (let i = 0; i < history.length; i++) {
const h = history[i];
const isLatest = i === 0;
const collapsed = !isLatest ? ' hidden' : '';
const toggleId = 'strat-' + i;
// 策略头信息
const headInfo = [];
if (h.snapshotted_at) headInfo.push('快照: ' + h.snapshotted_at.slice(0, 16));
if (h.reassessed_at) headInfo.push('重评: ' + h.reassessed_at.slice(0, 16));
if (h.version) headInfo.push('版本: ' + h.version);
if (h.is_current) headInfo.push('⚡ 当前策略');
const action = h.action || '';
const timing = h.timing_signal || '';
const sigCls = sigBadgeClass(timing);
body += '<div class="card p-4">' +
'<div class="flex justify-between items-start mb-2">' +
'<div>' +
'<span class="font-semibold text-white">' + (action || '—') + '</span>' +
'<span class="text-xs px-2 py-0.5 rounded ml-2 ' + sigCls + '">' + (timing || '—') + '</span>' +
'<div class="text-xs text-slate-500 mt-1">' + headInfo.join(' · ') + '</div>' +
'</div>' +
(isLatest ? '' : '<button class="text-xs text-blue-400 hover:text-blue-300" onclick="toggleStrat(\'' + toggleId + '\')">展开</button>') +
'</div>';
// 策略参数
const params = [];
if (h.entry_low || h.entry_high) {
const el3 = h.entry_low||0, eh3 = h.entry_high||0;
const mid3 = (el3 > 0 && eh3 > el3) ? ' 中值' + ((el3+eh3)/2).toFixed(2) : '';
params.push('买入区间: ' + (h.entry_low||'—') + '~' + (h.entry_high||'—') + mid3);
}
if (h.stop_loss) params.push('止损: ¥' + h.stop_loss);
if (h.take_profit) params.push('止盈: ¥' + h.take_profit);
if (h.rr_ratio) params.push('RR: ' + h.rr_ratio.toFixed(2));
if (h.position_advice) params.push('仓位: ' + h.position_advice);
if (params.length > 0) {
body += '<div class="text-xs text-slate-300 mb-2">' + params.join(' · ') + '</div>';
}
// 完整分析(折叠)
const fa = h.full_analysis || '';
if (fa) {
body += '<details class="text-xs">' +
'<summary class="text-slate-400 cursor-pointer hover:text-blue-400">📋 完整分析</summary>' +
'<pre class="mt-1 text-slate-300 whitespace-pre-wrap bg-slate-900/50 rounded p-2 max-h-48 overflow-y-auto font-sans leading-relaxed">' + fa.replace(/</g, '&lt;') + '</pre>' +
'</details>';
}
body += '</div>';
}
document.getElementById('strategyModalBody').innerHTML = body;
} catch(e) {
document.getElementById('strategyModalTitle').textContent = '操作策略历史';
document.getElementById('strategyModalMeta').textContent = code;
document.getElementById('strategyModalBody').innerHTML = '<div class="text-red-400">加载失败: ' + e.message + '</div>';
}
}
function closeStrategyModal() {
document.getElementById('strategyModal').classList.add('hidden');
document.getElementById('strategyModal').classList.remove('flex');
}
function toggleStrat(id) {
const el = document.getElementById(id);
if (el) {
el.classList.toggle('hidden');
}
}
// ── 开发原则 Tab(仿 AgentsMeeting: G规范/K测试/F健康/H需求)──
let _princLoaded = {};
function renderPrinciples() {
// 绑定子tab按钮
document.querySelectorAll('.princ-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.princ-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.princ-pane').forEach(p => p.classList.add('hidden'));
btn.classList.add('active');
const id = btn.dataset.princ;
document.getElementById('princ-' + id).classList.remove('hidden');
_loadPrinc(id);
};
});
// 默认加载当前激活的子tab
const active = document.querySelector('.princ-btn.active');
_loadPrinc(active ? active.dataset.princ : 'spec');
}
function _loadPrinc(id) {
if (id === 'spec') return _renderPrincSpec();
if (id === 'tests') return _renderPrincTests();
if (id === 'health') return _renderPrincHealth();
if (id === 'prd') return _renderPrincPrd();
}
async function _renderPrincSpec() {
const el = document.getElementById('princ-spec');
el.innerHTML = '<div class="text-slate-500 py-4">加载中...</div>';
try {
const d = await fetchJSON('/api/spec');
if (!d.ok) { el.innerHTML = '<div class="text-red-400 py-4">' + (d.error || '加载失败') + '</div>'; return; }
let h = '<div class="card p-5">' + mdRender(d.content) + '</div>';
try {
const dh = await fetchJSON('/api/spec/history');
if (dh.ok && dh.log && dh.log.length) {
h += '<div class="card p-4 mt-3"><div class="text-sm font-semibold text-slate-300 cursor-pointer" onclick="var e=document.getElementById(\'specHist\');e.classList.toggle(\'hidden\')">历史版本 <span class="text-slate-500 text-xs">(' + dh.count + ' commits)</span></div><div id="specHist" class="hidden mt-2 text-xs text-slate-500 font-mono space-y-1">';
dh.log.forEach(l => { h += '<div>' + l.replace(/</g, '&lt;') + '</div>'; });
h += '</div></div>';
}
} catch(e) {}
el.innerHTML = h;
} catch(e) { el.innerHTML = '<div class="text-red-400 py-4">加载失败: ' + e.message + '</div>'; }
}
async function _renderPrincTests() {
const el = document.getElementById('princ-tests');
el.innerHTML = '<div class="text-slate-500 py-4">加载中...</div>';
try {
const d = await fetchJSON('/api/tests');
if (!d.ok) { el.innerHTML = '<div class="text-red-400 py-4">' + (d.error || '加载失败') + '</div>'; return; }
const s = d.summary || {};
let h = '<div class="grid grid-cols-3 gap-3 mb-4">';
h += '<div class="card p-3 text-center"><div class="text-2xl font-bold text-white font-mono">' + (s.total||0) + '</div><div class="text-xs text-slate-500">Total</div></div>';
h += '<div class="card p-3 text-center"><div class="text-2xl font-bold text-[#3fb950] font-mono">' + (s.passed||0) + '</div><div class="text-xs text-slate-500">Passed</div></div>';
h += '<div class="card p-3 text-center"><div class="text-2xl font-bold text-[#f85149] font-mono">' + (s.failed||0) + '</div><div class="text-xs text-slate-500">Failed</div></div>';
h += '</div>';
const fails = (d.tests||[]).filter(t => !t.ok);
if (fails.length) {
h += '<div class="text-sm font-semibold text-[#f85149] mb-2">Failed (' + fails.length + ')</div>';
fails.forEach(t => {
h += '<div class="card p-3 mb-2" style="border-color:#f85149"><span class="text-xs px-1.5 py-0.5 rounded bg-[#f85149] text-white font-bold">FAIL</span> <strong class="text-sm">' + t.name.replace(/</g,'&lt;') + '</strong><div class="text-xs text-slate-500 mt-1">' + (t.detail||'').replace(/</g,'&lt;') + '</div></div>';
});
}
const passes = (d.tests||[]).filter(t => t.ok);
h += '<div class="text-sm font-semibold text-[#3fb950] mb-2 mt-3">Passed (' + passes.length + ')</div>';
passes.forEach(t => {
h += '<div class="card p-2 mb-1"><span class="text-xs px-1.5 py-0.5 rounded bg-[#3fb950] text-white font-bold">PASS</span> <span class="text-sm">' + t.name.replace(/</g,'&lt;') + '</span> <span class="text-xs text-slate-500">' + (t.detail||'').replace(/</g,'&lt;') + '</span></div>';
});
h += '<div class="text-xs text-slate-500 mt-3">数据源: agents_health_check (cron */5min) · 最后运行: ' + (d.time||'?') + '</div>';
el.innerHTML = h;
} catch(e) { el.innerHTML = '<div class="text-red-400 py-4">加载失败: ' + e.message + '</div>'; }
}
function _renderPrincHealth() {
const el = document.getElementById('princ-health');
if (_princLoaded.health) return;
el.innerHTML = '<div class="card p-4"><div class="text-sm text-slate-400 mb-2">完整健康监控与 🏥 健康 Tab 同源。快捷入口:<a href="javascript:switchTab(\'health\')" class="text-[#58a6ff]">🏥 健康 Tab</a></div>' +
'<iframe src="/mofin_health.html" style="width:100%;height:80vh;border:none;background:#0d1117;border-radius:8px;"></iframe></div>';
_princLoaded.health = true;
}
async function _renderPrincPrd() {
const el = document.getElementById('princ-prd');
el.innerHTML = '<div class="text-slate-500 py-4">加载中...</div>';
try {
const d = await fetchJSON('/api/prd');
if (!d.ok) { el.innerHTML = '<div class="card p-6 text-center text-slate-500">' + (d.error || 'prd.md 尚未建立') + '</div>'; return; }
el.innerHTML = '<div class="card p-5">' + mdRender(d.content) + '</div>';
} catch(e) { el.innerHTML = '<div class="text-red-400 py-4">加载失败: ' + e.message + '</div>'; }
}
// markdown 渲染(移植自 AgentsMeeting dashboard
function mdRender(md) {
if (!md) return '';
let h = '';
const blocks = md.split('\n\n');
for (const raw of blocks) {
const block = raw.trim();
if (!block) continue;
if (block.startsWith('```')) {
const code = block.replace(/^```[\s\S]*?\n/, '').replace(/```$/, '').trim();
h += '<pre class="font-mono text-xs" style="background:#0d1117;padding:12px;border-radius:6px;overflow-x:auto;border:1px solid #30363d">' + code.replace(/</g,'&lt;') + '</pre>';
continue;
}
if (block.startsWith('#### ')) { h += '<h4 class="text-[#58a6ff] font-semibold mt-4 mb-1 text-sm">' + inMd(block.slice(5).replace(/</g,'&lt;')) + '</h4>'; continue; }
if (block.startsWith('### ')) { h += '<h3 class="text-[#58a6ff] font-semibold mt-4 mb-1">' + inMd(block.slice(4).replace(/</g,'&lt;')) + '</h3>'; continue; }
if (block.startsWith('## ')) { h += '<h2 class="text-[#58a6ff] font-semibold mt-5 mb-2 text-lg border-b border-slate-800 pb-1">' + inMd(block.slice(3).replace(/</g,'&lt;')) + '</h2>'; continue; }
if (block.startsWith('# ')) { h += '<h1 class="text-[#58a6ff] font-bold mt-5 mb-2 text-xl">' + inMd(block.slice(2).replace(/</g,'&lt;')) + '</h1>'; continue; }
if (block.startsWith('> ')) { h += '<blockquote class="border-l-2 border-[#58a6ff] pl-3 py-1 my-2 text-slate-500 text-sm">' + inMd(block.slice(2).replace(/</g,'&lt;')) + '</blockquote>'; continue; }
if (block.startsWith('---') || block.startsWith('***')) { h += '<hr class="border-slate-800 my-3">'; continue; }
if (block.startsWith('- ') || block.startsWith('* ')) {
const items = block.split('\n');
h += '<ul class="list-disc pl-5 my-1 text-sm space-y-0.5">';
items.forEach(li => { const t = li.replace(/^[-*]\s*/, '').trim(); if (t) h += '<li>' + inMd(t.replace(/</g,'&lt;')) + '</li>'; });
h += '</ul>';
continue;
}
if (/^\d+\.\s/.test(block)) {
const items = block.split('\n');
h += '<ol class="list-decimal pl-5 my-1 text-sm space-y-0.5">';
items.forEach(li => { const t = li.replace(/^\d+\.\s*/, '').trim(); if (t) h += '<li>' + inMd(t.replace(/</g,'&lt;')) + '</li>'; });
h += '</ol>';
continue;
}
if (block.indexOf('|') >= 0 && block.indexOf('\n|') >= 0) {
const rows = block.split('\n');
let inTable = false;
rows.forEach(row => {
if (row.indexOf('---') >= 0) return;
const cells = row.split('|').filter(c => c.trim());
if (cells.length < 2) return;
if (!inTable) {
h += '<table class="my-2 text-xs w-full"><tr>' + cells.map(c => '<th class="px-2 py-1 border border-slate-700 text-slate-500 bg-slate-900">' + inMd(c.trim().replace(/</g,'&lt;')) + '</th>').join('') + '</tr>';
inTable = true;
} else {
h += '<tr>' + cells.map(c => '<td class="px-2 py-1 border border-slate-800">' + inMd(c.trim().replace(/</g,'&lt;')) + '</td>').join('') + '</tr>';
}
});
if (inTable) h += '</table>';
continue;
}
h += '<p class="text-sm my-1.5 leading-relaxed text-slate-300">' + inMd(block.replace(/</g,'&lt;')) + '</p>';
}
return h;
}
function inMd(t) {
return t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code class="bg-slate-900 px-1 rounded text-xs font-mono">$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-[#58a6ff]" target="_blank">$1</a>');
}
// ── 📚 文档 Tab2026-08-11 重构为左右布局:左侧栏目录 + 右侧内容)──
let _docsState = { currentPath: null, tree: null };
async function renderDocs() {
const el = document.getElementById('tab-docs');
if (!el) return;
el.innerHTML =
'<div class="flex gap-4" style="min-height:600px">' +
// 左侧栏:目录树
'<div class="w-64 flex-shrink-0 border-r border-slate-800 pr-3 overflow-y-auto" style="max-height:75vh">' +
'<div class="text-xs font-semibold text-slate-400 mb-2 uppercase tracking-wide sticky top-0 bg-slate-950 py-1">📚 文档目录</div>' +
'<div id="docs-tree" class="space-y-1 text-sm"></div>' +
'</div>' +
// 右侧:内容区
'<div class="flex-1 min-w-0 overflow-y-auto" style="max-height:75vh">' +
'<div id="docs-content" class="text-sm"></div>' +
'</div>' +
'</div>';
// 首次加载:渲染目录树
await _renderDocsTree();
// 默认显示版本变更(运营者最关心的)
if (!_docsState.currentPath) {
await _docsRead('VERSIONS.md');
}
}
async function _renderDocsTree() {
const tree = document.getElementById('docs-tree');
const data = await fetchJSON('/api/docs/index');
if (!data.ok) {
tree.innerHTML = '<div class="text-red-400 text-xs">' + (data.error || '加载失败') + '</div>';
return;
}
_docsState.tree = data.index;
const byCat = {};
data.index.forEach(d => {
byCat[d.category] = byCat[d.category] || [];
byCat[d.category].push(d);
});
let html = '';
for (const [cat, docs] of Object.entries(byCat)) {
html += '<div class="mb-3">' +
'<div class="text-[11px] font-semibold text-slate-500 mb-1 uppercase tracking-wide">' + cat + '</div>' +
'<div class="space-y-0.5">' +
docs.map(d =>
'<div class="cursor-pointer hover:bg-slate-800/60 px-2 py-1 rounded text-slate-300 truncate" title="' + d.path + '" onclick="_docsRead(\'' + d.path + '\')">' +
(d.core ? '⭐ ' : '') + d.title + '</div>'
).join('') +
'</div></div>';
}
tree.innerHTML = html;
// 高亮当前选中项
_docsHighlight();
}
function _docsHighlight() {
document.querySelectorAll('#docs-tree [title]').forEach(el => el.classList.remove('bg-slate-800', 'text-blue-400'));
if (_docsState.currentPath) {
document.querySelectorAll('#docs-tree [title="' + _docsState.currentPath + '"]').forEach(el => {
el.classList.add('bg-slate-800', 'text-blue-400');
});
}
}
async function _docsRead(path) {
_docsState.currentPath = path;
_docsHighlight();
const content = document.getElementById('docs-content');
if (!content) return;
content.innerHTML = '<div class="text-slate-500 text-xs">加载中...</div>';
const data = await fetchJSON('/api/docs/read?path=' + encodeURIComponent(path));
if (!data.ok) {
content.innerHTML = '<div class="text-red-400">' + (data.error || '加载失败') + '</div>';
return;
}
// 版本变更用特殊排版(日期+标题+正文分段)
if (path === 'VERSIONS.md') {
content.innerHTML = _renderVersionsHTML(data.content);
} else {
content.innerHTML =
'<div class="mb-2 text-xs text-slate-500 border-b border-slate-800 pb-2">📄 ' + path + '</div>' +
'<div class="bg-slate-900/50 border border-slate-800 rounded-xl p-5">' + mdRender(data.content) + '</div>';
}
}
function _renderVersionsHTML(md) {
// 解析 VERSIONS.md:按 ## 分段,每段一个版本卡片
const parts = md.split(/^##\s+/m).filter(p => p.trim() && !p.startsWith('阅读指南') && !p.startsWith('维护说明'));
const cards = parts.map(p => {
const lines = p.split('\n');
const title = lines[0].trim();
const body = lines.slice(1).join('\n').trim();
return '<div class="mb-4 border border-slate-800 rounded-xl p-4 bg-slate-900/50">' +
'<div class="font-semibold text-slate-100 mb-2 text-base">' + title + '</div>' +
'<div class="text-slate-300 leading-relaxed text-[13px]">' + mdRender(body) + '</div>' +
'</div>';
}).join('');
return '<div class="mb-2 text-xs text-slate-500 border-b border-slate-800 pb-2">📜 版本变更记录(给运营者的详细说明)</div>' + cards;
}
function renderResearch() {
const el = document.getElementById('tab-research');
if (!el) return;
// 2026-08-18 局部刷新(老莫):已渲染过骨架则不再整页重建(保留滚动/展开),只刷新数据
if (window._researchRendered) {
loadStrategyList();
loadEvoLog();
return;
}
window._researchRendered = true;
el.innerHTML =
'<div class="flex gap-2 items-center mb-3">' +
'<span class="text-sm font-semibold text-slate-300">📋 策略研究</span>' +
'<span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp(\'strategy\',\'human\')">?</span><span class="spec-btn ai" onclick="showModuleHelp(\'strategy\',\'ai\')">§</span></span>' +
'<span class="text-xs text-slate-500">按市场×温区分组 · 每表格=该市该温区所有可用策略 · 🟢=当前市激活 · 温度=当前温区</span>' +
'</div>' +
'<div class="flex gap-2 items-center mb-3">' +
'<button onclick="switchRsub(\'strategy\')" id="rsubtab-strategy" class="px-3 py-1 rounded text-sm bg-emerald-600/40 border border-emerald-600/60">📋 策略</button>' +
'<button onclick="switchRsub(\'evo\')" id="rsubtab-evo" class="px-3 py-1 rounded text-sm bg-slate-800 border border-slate-700">📊 策略评估</button>' +
'<span class="text-xs text-slate-500">策略效果评估(到期策略自动评估)</span>' +
'</div>' +
'<div id="rsub-strategy">' +
'<div class="flex gap-2 items-center mb-3 flex-wrap">' +
'<h2 class="text-lg font-bold">📋 策略研究 — 版本迭代对比</h2>' +
'<select id="btPeriod" onchange="saveBtPeriod()" class="bg-slate-800 text-sm rounded-lg px-2 py-1 border border-slate-700">' +
'<option value="1m">近 1 个月</option><option value="6m">近 6 个月</option>' +
'<option value="1y">近 1 年</option><option value="2y">近 2 年</option><option value="5y">近 5 年</option><option value="10y">近 10 年</option></select>' +
'<select id="btMarket" class="bg-slate-800 text-sm rounded-lg px-2 py-1 border border-slate-700" onchange="loadStrategyList()">' +
'<option value="all">全部市场</option><option value="a">A股</option><option value="hk">港股</option></select>' +
'<span class="text-xs text-slate-500">每版策略基于上一版归因分析迭代 · 点击行展开明细</span></div>' +
'<div id="load-hint" class="hidden text-amber-300 text-sm font-semibold py-3 px-3 mb-1 rounded border border-amber-500/40 bg-amber-500/10 animate-pulse"></div>' +
'<div id="strategyList"><div class="text-slate-500 text-sm">加载中...</div></div>' +
'<div id="strategyDetail" class="mt-4"></div>' +
'</div>' +
'<div id="rsub-evo" class="hidden">' +
'<div class="text-xs text-slate-500 mb-2">📊 策略评估 — 到期策略自动评估(买入区/止损/止盈/时间准确性)</div>' +
'<div id="effectiveness-list"><div class="text-slate-500 text-sm">加载中...</div></div>' +
'</div>';
// 首次渲染即加载策略列表(无子Tab切换)。
// 2026-08-15 修复:不能在此直接 loadStrategyList()——此时 btPeriod.value 还是初始默认 '1m'
// 与下方恢复 savedPt='2y' 后的调用并发竞态,1m 响应后到会覆盖 2y 渲染(周期错乱+综合分被小样本置信打崩)。
loadEvoLog();
// 2026-08-18 子Tab切换 + 进化日志
window.switchRsub = function(name) {
const isStr = name === 'strategy';
document.getElementById('rsub-strategy').classList.toggle('hidden', !isStr);
document.getElementById('rsub-evo').classList.toggle('hidden', isStr);
const bs = document.getElementById('rsubtab-strategy'), be = document.getElementById('rsubtab-evo');
if (bs) bs.className = 'px-3 py-1 rounded text-sm ' + (isStr ? 'bg-emerald-600/40 border border-emerald-600/60' : 'bg-slate-800 border border-slate-700');
if (be) be.className = 'px-3 py-1 rounded text-sm ' + (isStr ? 'bg-slate-800 border border-slate-700' : 'bg-emerald-600/40 border border-emerald-600/60');
if (!isStr) loadEvoLog();
};
window.loadEvoLog = function() {
const el = document.getElementById('effectiveness-list');
if (!el) return;
fetch('/api/research/effectiveness/summary').then(r => r.json()).then(data => {
if (!data || !data.length) { el.innerHTML = '<div class="text-slate-500 text-sm">暂无评估数据(策略到期后自动生成)</div>'; return; }
let html = '<table class="w-full text-xs"><thead><tr class="text-slate-500 border-b border-slate-700">' +
'<th class="text-left px-2 py-1">日期</th><th class="text-left px-2 py-1">市场/温区</th>' +
'<th class="text-left px-2 py-1">发现薄弱点</th><th class="text-left px-2 py-1">尝试</th>' +
'<th class="text-left px-2 py-1">结果</th><th class="text-left px-2 py-1">产出策略</th></tr></thead><tbody>';
rows.forEach(r => {
html += '<tr class="border-b border-slate-800/40"><td class="px-2 py-1">' + (r.log_date||'') + '</td>' +
'<td class="px-2 py-1">' + (r.market||'') + '/' + (r.weak_regime||'') + '</td>' +
'<td class="px-2 py-1 whitespace-normal">' + (r.finding||'') + '</td>' +
'<td class="px-2 py-1">' + (r.experiment||'') + '</td>' +
'<td class="px-2 py-1">' + (r.result||'') + '</td>' +
'<td class="px-2 py-1">' + (r.produced_strategy||'') + (r.produced_verified ? ' <span class="text-green-400">✅</span>' : '') + '</td></tr>';
});
el.innerHTML = html + '</tbody></table>';
}).catch(e => { el.innerHTML = '<div class="text-red-400 text-sm">加载失败: ' + e + '</div>'; });
};
// 恢复用户上次选择的周期(防自动刷新重置,2026-07-30老爸抓包)
const savedPt = window._btPeriodVal || '2y';
const ptSel = document.getElementById('btPeriod');
if (ptSel) ptSel.value = savedPt;
loadStrategyList();
}
function saveBtPeriod() {
const sel = document.getElementById('btPeriod');
if (sel) window._btPeriodVal = sel.value;
loadStrategyList();
}
async function loadStrategyList() {
window._stratScrollY = window.scrollY || 0; // 2026-08-18 触发渲染前保存滚动位置
const listEl = document.getElementById('strategyList');
// 2026-08-18 加载中提示(独立提示条,不破坏 strategyList 旧内容/滚动)
const hintEl = document.getElementById('load-hint');
if (hintEl) {
hintEl.textContent = '⏳ 加载中...';
hintEl.classList.remove('hidden');
window._hintShownAt = Date.now(); // 记录显示时刻,保证最小可见时长
}
try {
const ptMap = {'1m':'1m','6m':'6m','1y':'1y','2y':'2y','5y':'5y','10y':'10y'};
const pt = ptMap[window._btPeriodVal || document.getElementById('btPeriod')?.value || '2y'] || '2y';
const [resp, ovResp] = await Promise.all([
fetch('/api/research/strategies?period_tag=' + pt),
fetch('/api/overview').catch(() => null)
]);
const data = await resp.json();
if (data.error) { listEl.innerHTML = '<div class="text-red-400">' + data.error + '</div>'; return; }
// 2026-08-15 温区信息并入策略表(横幅取消):A股/港股温区状态+温度,供6表格当前市高亮与温度展示
const ov = ovResp ? await ovResp.json().catch(() => null) : null;
const rw = (ov && ov.regime_weights) || null;
window._regimeA = rw && rw.state ? {state: rw.state, temp_band: rw.temp_band, temp_rsi: rw.temp_rsi} : null;
const hkR = rw && rw.markets && rw.markets.hk;
window._regimeHK = hkR && hkR.state ? {state: hkR.state, temp_band: hkR.temp_band} : null;
window._regimeActive = data.regime_active || [];
window._regimeState = (rw && rw.state) || data.regime_state || 'unknown';
const mkt = document.getElementById('btMarket')?.value || 'all';
let strats = data.strategies || [];
// 市场语义:'all'=未分市场的A股回测(港股回填前跑的),'hk'=港股宇宙,'a'=显式A股
if (mkt === 'hk') strats = strats.filter(s => (s.market || 'all') === 'hk');
else if (mkt === 'a') strats = strats.filter(s => (s.market || 'all') !== 'hk');
// 2026-08-16 大盘基准(从任一策略 qualification 提取,供温区表头显示)
for (const s of strats) {
if (s.qualification) {
const anyQ = Object.values(s.qualification)[0];
if (anyQ && anyQ.bench_1y != null) {
window._bench1y = anyQ.bench_1y;
window._bench2y = anyQ.bench_2y;
window._bench10y = anyQ.bench_10y;
break;
}
}
}
renderStrategyTable(strats);
// 2026-08-18 最小显示400ms:即使fetch快也让用户看到加载提示
if (hintEl) {
const waited = Date.now() - (window._hintShownAt || 0);
if (waited < 400) {
setTimeout(() => hintEl.classList.add('hidden'), 400 - waited);
} else {
hintEl.classList.add('hidden');
}
}
} catch(e) {
listEl.innerHTML = '<div class="text-red-400">加载失败: ' + e.message + '</div>';
if (hintEl) { hintEl.textContent = '⚠️ 加载失败'; hintEl.classList.remove('hidden'); }
}
}
const ASC_DEFAULT = new Set(['max_dd']); // 回撤类:小=好,默认升序
async function loadEvoLog() {
const box = document.getElementById('effectiveness-list');
if (!box) return;
try {
const r = await fetch('/api/research/effectiveness/summary');
const d = await r.json();
let h = '<div class="flex items-center justify-between mb-2"><span class="text-base font-semibold text-emerald-400">🧬 策略评估</span><button onclick="loadEvoLog()" class="text-sm text-blue-400 hover:text-blue-300">刷新</button></div>';
// ── 基线对比 ──
if (d.baseline) {
h += '<div class="grid grid-cols-3 gap-2 mb-3"><span class="text-sm text-slate-500 col-span-3">5年回测基线:</span>';
for (const [v, b] of Object.entries(d.baseline)) {
const clr = v === 'v_next4' ? 'text-emerald-400' : 'text-slate-300';
h += '<div class="bg-slate-900/50 rounded px-2 py-1"><span class="'+clr+' font-mono font-bold text-sm">'+v+'</span><br><span class="text-slate-500 text-xs">胜率'+b.win_rate+'% 收益'+b.total_return+'% 回撤'+b.max_dd+'%</span></div>';
}
h += '</div>';
}
// ── 健康度 ──
if (d.health && d.health.length > 0) {
const hs = d.health[0]; const sc = hs.health_score;
const color = sc >= 70 ? 'text-emerald-400' : sc >= 50 ? 'text-amber-400' : 'text-red-400';
h += '<div class="mb-2 text-sm"><span class="'+color+' font-bold">健康度: '+sc+'分</span><span class="text-slate-500"> (实盘'+hs.live_trades+'笔 vs 回测'+hs.backtest_wr+'%胜率)</span>';
if (hs.live_trades === 0) h += '<span class="text-slate-600 text-xs"> [暂无实盘数据, 分数为中性]</span>';
h += '</div>';
}
// ── 进化历史 ──
if (d && d.length > 0) {
h += '<div class="text-sm text-slate-400 mb-1">🔄 迭代历史:</div>';
for (const e of d.slice(0,5)) {
h += '<div class="text-sm text-slate-300 mb-0.5">'+e.parent_version+' → '+e.child_version+' <span class="text-slate-500">'+e.change_description+'</span>'+(e.promoted?' <span class="text-emerald-400 text-xs">✅采纳</span>':'')+'</div>';
}
} else {
h += '<div class="text-sm text-slate-600 mb-1">🔄 迭代历史: 暂无 (auto_iterator 待触发)</div>';
}
// ── 教训 ──
if (d.lessons && d.lessons.length > 0) {
h += '<div class="text-sm text-slate-400 mt-2 mb-1">📝 回测教训 (基于v_next4 5y交易分析):</div>';
for (const l of d.lessons.slice(0,5)) {
h += '<div class="text-sm text-slate-300 mb-0.5">'+(l.lesson_type==='win_pattern'?'✅盈利规律':'❌亏损规律')+' '+l.lesson_text+' <span class="text-slate-600 text-xs">'+Math.round(l.confidence*100)+'%置信</span></div>';
}
}
// ── 2026-08-16 假设归纳(方向一:数据驱动的优化假设)──
if (d.hypotheses && d.hypotheses.length > 0) {
h += '<div class="text-sm text-slate-400 mt-2 mb-1">🧪 数据归纳优化假设(方向一,基于最新交易数据):</div>';
for (const hp of d.hypotheses.slice(0,6)) {
const ex = hp.excess_pp != null ? ' <span class="text-amber-400 text-xs">Δ'+hp.excess_pp+'pp</span>' : '';
h += '<div class="text-sm text-slate-300 mb-1 bg-slate-900/40 rounded px-2 py-1">' +
'<span class="font-mono font-bold text-blue-300">'+hp.strategy+'</span> ' + hp.hypothesis + ex +
'<div class="text-[10px] text-slate-500">证据: ' + (hp.evidence || '') + '</div></div>';
}
} else {
h += '<div class="text-sm text-slate-600 mt-2 mb-1">🧪 假设归纳: 暂无显著假设(交易样本不足或特征均匀)</div>';
}
// ── 2026-08-16 B组候选 + AB对照(方向二:由果及因挖掘 vs A组)──
h += '<div class="text-sm text-slate-400 mt-2 mb-1">🔬 B组挖掘 + AB对照(方向二):</div>';
// A组 = 当前激活策略(baseline),B组 = 候选
const aNames = d.baseline ? Object.keys(d.baseline) : [];
if (d.b_group && d.b_group.length > 0) {
for (const b of d.b_group.slice(0,6)) {
h += '<div class="text-sm text-slate-300 mb-1 bg-slate-900/40 rounded px-2 py-1">' +
'<div><span class="text-emerald-400 font-bold">[B组 '+b.regime.replace('trend_','')+']</span> ' + b.hypothesis +
' <span class="text-slate-500 text-xs">' + (b.trades_est||'?') + '样本 好果率' + (b.good_rate || b.ok_rate || b.big_rate || '?') + '%</span></div>' +
'<div class="text-[10px] text-slate-500">对照A组(' + b.regime.replace('trend_','') + '): ' + (aNames.join(', ') || '—') + '</div>' +
(b.status === 'verified' ? '<button onclick="mergeBGroup()" class="text-[10px] px-1.5 py-0.5 bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 mt-1">⬇️融合到A组</button>' : '') +
'</div>';
}
} else {
h += '<div class="text-sm text-slate-600 mb-1">暂无B组候选(挖掘中;通过模拟验证的候选可融合到A组)</div>';
}
// ── 2026-08-16 策略资格概览(温区适应评估)──
if (d.qual_overview && d.qual_overview.length > 0) {
h += '<div class="text-sm text-slate-400 mt-2 mb-1">📊 策略温区资格(长期/近期/当下):</div><div class="grid grid-cols-2 gap-1.5">';
for (const qo of d.qual_overview) {
const q = qo.qualification || {};
const rgTxt = Object.entries(q).map(([rg, v]) =>
'<span class="text-slate-500">'+rg.replace('trend_','')+'</span>' +
(v.long_ok?'✅':'❌')+(v.mid_ok?'✅':'❌')+(v.short_ok?'✅':'❌')).join(' ');
h += '<div class="bg-slate-900/40 rounded px-2 py-1 text-xs">' +
'<span class="font-mono font-bold text-blue-300">'+qo.strategy+'</span> ' +
'<span class="text-slate-500">'+qo.market+'</span><div class="text-[10px]">'+rgTxt+'</div></div>';
}
h += '</div>';
}
box.innerHTML = h;
} catch (e) { box.innerHTML = '<div class="text-sm text-red-400">加载失败: '+e.message+'</div>'; }
}
// 研究Tab活跃时加载评估数据
new MutationObserver(()=>{if(!document.getElementById('tab-research')?.classList?.contains('hidden'))loadEvoLog();}).observe(document.body,{attributes:true,subtree:true});
function sortBy(key) {
if (window._sortKey === key) {
window._sortDir = window._sortDir === 'desc' ? 'asc' : 'desc';
} else {
window._sortKey = key;
window._sortDir = 'desc';
}
renderStrategyTable(window._strats || []);
}
function sortStrategies(strategies, key, dir) {
const get = (s) => {
const st = s.summary_stats || {}; const pf = st.portfolio || {};
switch(key) {
case 'version': return s.version || '';
case 'name': return s.name || '';
case 'sizing_slots': return st.sizing_slots || 10;
case 'composite': return s._composite != null ? s._composite : -999;
case 'universality': return (st.universality || {}).score || 0;
case 'total_return': return pf.total_return_pct != null ? pf.total_return_pct : -999;
case 'capital_final': return pf.capital_final || 0;
case 'cagr': return pf.cagr_pct != null ? pf.cagr_pct : -999;
case 'trades': return st.total_trades || 0;
case 'hold': return st.avg_hold_days || 0;
case 'win_rate': return st.win_rate != null ? st.win_rate : -999;
case 'avg_profit': return st.avg_profit_pct != null ? st.avg_profit_pct : -999;
case 'sharpe': return st.sharpe_ratio != null ? st.sharpe_ratio : -999;
case 'profit_factor': return st.profit_factor != null ? st.profit_factor : -999;
case 'max_dd': return pf.portfolio_max_dd_pct != null ? pf.portfolio_max_dd_pct : 999;
default: return 0;
}
};
const arr = [...strategies];
arr.sort((a, b) => {
const va = get(a), vb = get(b);
let cmp = (typeof va === 'string') ? va.localeCompare(vb) : va - vb;
return dir === 'desc' ? -cmp : cmp;
});
return arr;
}
// ── 2026-08-16 手动可用性(全局,供 onclick 调用):激活只能是可用的策略 ──
async function toggleAvail(version, checked) {
try {
await fetch('/api/research/availability', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({version: version, available: checked, note: '手动切换'})
});
loadStrategyList(); // 刷新(state 可能变化)
} catch(e) { console.error('toggleAvail failed', e); }
}
// 2026-08-16 AB融合:B组verified候选 → A组实施(需老莫确认)
async function mergeBGroup() {
if (!confirm('确认将B组候选融合到A组实施?融合后可手动启用/停用。')) return;
try {
const r = await fetch('/api/evolution/merge_b_group', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
const d = await r.json();
alert('融合结果: ' + JSON.stringify(d.merged || d.error || d));
loadEvoLog();
loadStrategyList();
} catch(e) { alert('融合失败: ' + e.message); }
}
async function batchAvail(rg, available) {
const list = (window._strats || [])
.filter(s => s.qualification && s.qualification[rg] && (s.market || 'all') !== 'hk')
.map(s => s.version);
if (!list.length) return;
try {
await fetch('/api/research/availability?batch=1', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({versions: list, available: available, note: '批量' + (available ? '可用' : '不可用')})
});
loadStrategyList();
} catch(e) { console.error('batchAvail failed', e); }
}
function renderStrategyTable(strategies) {
const el = document.getElementById('strategyList');
// 2026-08-18 滚动兜底:重建前保存整页滚动位置
if (window._stratScrollY == null) window._stratScrollY = window.scrollY || 0;
if (!strategies.length) { el.innerHTML = '<div class="text-slate-500">暂无策略版本</div>'; return; }
// 先算综合分(排序要用),再排序
for (const s of strategies) {
const st = s.summary_stats || {}; const pf = st.portfolio || {};
if (st.total_trades == null) { s._composite = null; continue; }
// 综合分用 MC 全参与口径(消除集中仓挑装运气,2026-07-29 老爸指正)
const pff = st.portfolio_full || pf; // portfolio_full 优先(MC),无则回退
const ret = Math.min(pff.total_return_pct || 0, 100) / 100 * 30;
const wr = (st.win_rate || 0) / 100 * 20;
const sh = Math.min(Math.max(st.sharpe_ratio || 0, 0), 20) / 20 * 20;
const pfc = Math.min(st.profit_factor || 0, 5) / 5 * 15;
const dd = (1 - Math.min(pff.portfolio_max_dd_pct || 0, 50) / 50) * 15;
// 置信系数:样本量(<40笔递减) × 普适月份(<6月递减)——小样本/单窗口策略的可信度打折
const n = st.total_trades || 0;
const months = (st.universality || {}).months || 0;
const conf = Math.min(1, n / 40) * (months > 0 ? Math.min(1, months / 6) : 1);
// 2026-08-17 老莫:效率惩罚(信号/成交比过高=大量信号未成交)
const _posN = (st.portfolio || {}).positions_taken || 0;
const _sigN = n;
const _ratioN = _posN > 0 ? _sigN / _posN : 99;
const _effN = _ratioN <= 2 ? 1.0 : _ratioN <= 5 ? 0.9 : _ratioN <= 10 ? 0.75 : 0.5;
s._confidence = conf;
s._composite = Math.round((ret + wr + sh + pfc + dd) * conf * _effN);
}
// 保存数据用于列排序
window._strats = strategies;
if (window._sortKey) {
strategies = sortStrategies(strategies, window._sortKey, window._sortDir);
}
// 默认排序(未点列排序时):非港股在前,港股分身沉底;点列排序后以用户选择为准
if (!window._sortKey) {
strategies.sort((a, b) => {
const am = (a.market || 'all') === 'hk' ? 1 : 0;
const bm = (b.market || 'all') === 'hk' ? 1 : 0;
if (am !== bm) return am - bm;
return (a.version || '').localeCompare(b.version || '');
});
}
// 找每列最优值用于高亮
const best = { win_rate: -999, sharpe_ratio: -999, profit_factor: -999, avg_profit_pct: -999,
cagr_pct: -999, portfolio_max_dd_pct: 999 };
for (const s of strategies) {
const st = s.summary_stats || {};
const pf = st.portfolio || {};
if (st.win_rate != null) best.win_rate = Math.max(best.win_rate, st.win_rate);
if (st.sharpe_ratio != null) best.sharpe_ratio = Math.max(best.sharpe_ratio, st.sharpe_ratio);
if (st.profit_factor != null) best.profit_factor = Math.max(best.profit_factor, st.profit_factor);
if (st.avg_profit_pct != null) best.avg_profit_pct = Math.max(best.avg_profit_pct, st.avg_profit_pct);
{ const _c = (st.portfolio_full || pf).cagr_pct; if (_c != null) best.cagr_pct = Math.max(best.cagr_pct, _c); }
if (pf.portfolio_max_dd_pct != null) best.portfolio_max_dd_pct = Math.min(best.portfolio_max_dd_pct, pf.portfolio_max_dd_pct);
}
const hl = (val, bestVal, invert) => {
if (val == null) return '';
const isBest = invert ? (val === bestVal && bestVal !== 999) : (val === bestVal && bestVal !== -999);
return isBest ? 'text-green-400 font-bold' : '';
};
// 条件格式色条:计算每列 min/max,单元格背景按相对大小画色条
const CURRENT_STRATEGY = 'v_next4';
const CURRENT_FAMILY = new Set(['v_weak', 's2_panic', 'v_next4']);
// 证伪标记数据驱动(2026-08-15):从 API s.deprecated 读,替代前端硬编码
const DEPRECATED = {};
const ranges = {};
const cols = ['composite','universality_score','full_return_pct','capital_final','cagr_pct','total_trades','avg_hold_days','win_rate','avg_profit_pct','sharpe_ratio','profit_factor','portfolio_max_dd_pct'];
for (const c of cols) {
let mn = Infinity, mx = -Infinity;
for (const s of strategies) {
const st = s.summary_stats || {}; const pf = st.portfolio || {};
const v = c === 'composite' ? s._composite : (c === 'universality_score' ? (st.universality || {}).score : (c === 'full_return_pct' ? (st.portfolio_full || {}).total_return_pct : ((c in pf) ? pf[c] : st[c])));
if (v != null) { mn = Math.min(mn, v); mx = Math.max(mx, v); }
}
ranges[c] = { mn: mn === Infinity ? 0 : mn, mx: mx === -Infinity ? 1 : mx };
}
const bar = (c, v, invert) => {
if (v == null) return '';
const { mn, mx } = ranges[c];
const span = (mx - mn) || 1;
let pct = (v - mn) / span;
if (invert) pct = 1 - pct;
pct = Math.max(0.03, Math.min(1, pct));
const color = invert ? 'rgba(96,165,250,0.28)' : 'rgba(59,130,246,0.30)';
return `background:linear-gradient(to right, ${color} ${pct*100}%, transparent ${pct*100}%);`;
};
const cell = (c, v, fmt, extraCls, invert) => {
const cls = 'text-right px-2 py-1.5 font-mono ' + (extraCls || '');
const sty = bar(c, v, invert);
return `<td class="${cls}" style="${sty}">${v != null ? fmt(v) : '—'}</td>`;
};
const th = (key, label, cls, title, colKey) => {
const arrow = window._sortKey === key ? (window._sortDir === 'desc' ? '▼' : '▲') : '';
// 2026-08-17 老莫:?§按钮只保留第一个表格表头(_colBtnsOn 控制)
const showBtns = colKey && window._colBtnsOn !== false;
const helpBtns = showBtns ? `<span class="spec-btn colinfo" title="看这列是干嘛的" onclick="event.stopPropagation();showColumnHelp('${colKey}')">ⓘ</span><span class="spec-btn ai" title="看策略模块spec(接口/约束)" onclick="event.stopPropagation();showModuleHelp('strategy','ai')">§</span>` : '';
// 2026-08-17 老莫:ⓘ§按钮放第二行(spec-th-row),列名一行保持列宽,避免横向滚动条
const thInner = showBtns
? `<div class="spec-th-col"><div class="spec-th-label">${label}${arrow ? ' ' + arrow : ''}</div><div class="spec-th-btns">${helpBtns}</div></div>`
: `${label}${arrow ? ' ' + arrow : ''}`;
return `<th class="${cls || 'text-right'} px-2 py-1 cursor-pointer hover:text-slate-300 select-none align-top" onclick="sortBy('${key}')" title="${title || '点击排序'}">${thInner}</th>`;
};
// 2026-08-13 温区自适应:按"适应温区"展开——每策略每温区一行
// 当前 = 当前温区激活(后端 s.current 已改为 regime_weights.active 判断);历史 = 其余,默认折叠
const REGIME_META = {trend_up:{icon:'📈',name:'趋势市'}, choppy:{icon:'〰️',name:'震荡市'}, trend_down:{icon:'📉',name:'下跌市'}};
const renderGroup = (groupStrats, groupTitle, groupIcon, collapsed) => {
let ghtml = '<div class="overflow-x-auto"><table class="w-full text-xs whitespace-nowrap"><thead><tr class="text-slate-500 border-b border-slate-700">' +
'<th class="text-left px-2 py-1.5">适应温区</th>' +
th('version', '版本', 'text-left', '策略版本标识', 'version') + th('name', '名称', 'text-left', '策略人话名称', 'name') +
th('sizing_slots', '仓位', 'text-center') +
'<th class="text-center px-2 py-1.5">说明</th>' +
th('composite', '综合', 'text-right', '多维度综合评分', 'composite') +
th('universality', '普适', 'text-right', '跨独立年份有效性(非特例)', 'universality') +
th('full_return', '全参与', 'text-right', '全参与模拟总收益', 'full_return') +
th('capital_final', '最终资产', 'text-right', '组合模拟期末资产', 'capital_final') +
th('cagr', '年化', 'text-right', '年化收益率', 'cagr') +
th('trades', '执行数', 'text-right', '实际参与组合模拟的交易笔数;信号总数=为鼠标悬停查看', 'trades') +
th('hold', '均持仓', 'text-right', '平均持仓天数', 'hold') +
th('win_rate', '胜率', 'text-right', '盈利笔数占比', 'win_rate') +
th('avg_profit', '平均收益', 'text-right', '每笔平均收益率', 'avg_profit') + th('sharpe', '夏普', 'text-right', '每单位波动超额收益', 'sharpe') +
th('profit_factor', '盈亏比', 'text-right', '总盈利/总亏损', 'profit_factor') + th('max_dd', '资产回撤', 'text-right', '组合模拟最大回撤', 'max_dd') +
'<th class="text-center px-2 py-1.5">操作</th></tr></thead><tbody>';
for (const s of groupStrats) {
const st = s.summary_stats || {};
const pf = st.portfolio || {};
const hasResult = st.total_trades != null;
const smallSample = hasResult && st.total_trades < 40;
const isCurrent = s.current === true;
const deprecatedReason = s.deprecated; // 数据驱动(2026-08-15,从API读)
// 温区展开:有 regime_winrates 则每温区一行;无则单行("整体")
const rws = s.regime_winrates || {};
const regimes = Object.keys(rws).length ? Object.keys(rws).sort() : ['overall'];
for (const rg of regimes) {
const rw = rws[rg];
const isOverall = rg === 'overall';
const rgMeta = REGIME_META[rg] || {icon:'🌐', name: rg === 'overall' ? '整体' : rg};
const rgActive = !isOverall && isCurrent;
// ── 温区行数据:交易统计用该温区数据;组合级指标温区不适用(温区分散不成连续组合)──
const wrShow = rw && rw.win_rate != null ? rw.win_rate : (isOverall ? st.win_rate : null);
const pnlShow = rw && rw.avg_pnl != null ? rw.avg_pnl : (isOverall ? st.avg_profit_pct : null);
const tradesShow = rw && rw.trades != null ? rw.trades : (isOverall ? st.total_trades : null);
const holdShow = rw && rw.avg_hold_days != null ? rw.avg_hold_days : (isOverall ? st.avg_hold_days : null);
const sharpeShow = rw && rw.sharpe_ratio != null ? rw.sharpe_ratio : (isOverall ? st.sharpe_ratio : null);
const pfShow = rw && rw.profit_factor != null ? rw.profit_factor : (isOverall ? st.profit_factor : null);
// 组合级列:整体行用温区加权综合(s.regime_weighted2026-08-15 按温区时长占比加权各温区表现);温区行用温区级组合(rw.portfolio+ 温区级 composite + 温区级 universality
const rwPf = rw && rw.portfolio ? rw.portfolio : null;
const rwUniv = rw && rw.universality ? rw.universality : null;
// 温区加权整体综合(regime_weighted.composite);温区级 composite(用温区 portfolio 算)
let rgComposite = null;
if (rwPf) {
const rgRet = Math.min(rwPf.total_return_pct || 0, 100) / 100 * 30;
const rgWr = (rw && rw.win_rate || 0) / 100 * 20;
const rgSh = Math.min(Math.max((rwPf.sharpe_ratio || 0), 0), 20) / 20 * 20;
const rgPfc = Math.min((rwPf.profit_factor || 0), 5) / 5 * 15;
const rgDd = (1 - Math.min(rwPf.portfolio_max_dd_pct || 0, 50) / 50) * 15;
rgComposite = Math.round((rgRet + rgWr + rgSh + rgPfc + rgDd) * (s._confidence || 1));
}
const compositeShow = isOverall ? (s.regime_weighted && s.regime_weighted.composite != null ? s.regime_weighted.composite : s._composite) : rgComposite;
// 温区加权普适(用温区时长占比加权各温区月份分散度);温区级普适(rw.universality
const universalityShow = isOverall ? (st.universality || {}).score : (rwUniv ? rwUniv.score : null);
const retShow = isOverall ? (s.regime_weighted && s.regime_weighted.total_return_pct != null ? s.regime_weighted.total_return_pct : (st.portfolio_full||{}).total_return_pct) : (rwPf ? rwPf.total_return_pct : null);
const capShow = isOverall ? (st.portfolio_full||pf).capital_final : (rwPf ? rwPf.capital_final : null);
const cagrShow = isOverall ? (s.regime_weighted && s.regime_weighted.cagr_pct != null ? s.regime_weighted.cagr_pct : (st.portfolio_full||pf).cagr_pct) : (rwPf ? rwPf.cagr_pct : null);
const ddShow = isOverall ? (s.regime_weighted && s.regime_weighted.portfolio_max_dd_pct != null ? s.regime_weighted.portfolio_max_dd_pct : (st.portfolio_full||pf).portfolio_max_dd_pct) : (rwPf ? rwPf.portfolio_max_dd_pct : null);
const rowCls = 'border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer' + (rgActive ? ' bg-emerald-900/20 border-l-2 border-l-emerald-400' : '');
ghtml += '<tr class="' + rowCls + '" onclick="showStrategyDetail(\'' + s.version + '\')">' +
'<td class="px-2 py-1.5 whitespace-nowrap">' +
(isOverall ? '<span class="text-slate-500">整体</span>' :
'<span title="' + rgMeta.name + '">' + rgMeta.icon + ' ' + rgMeta.name + '</span>') +
(rw && rw.win_rate != null ? ' <span class="text-[10px] text-slate-500">胜率' + rw.win_rate + '%</span>' : '') +
(rw && rw.avg_pnl != null ? ' <span class="text-[10px] ' + (rw.avg_pnl >= 0 ? 'text-green-500/70' : 'text-red-500/70') + '">' + (rw.avg_pnl >= 0 ? '+' : '') + rw.avg_pnl + '%</span>' : '') +
(rw && rw.trades != null ? ' <span class="text-[10px] text-slate-600">' + rw.trades + '笔</span>' : '') +
'</td>' +
'<td class="px-2 py-1.5 font-mono font-bold ' + (deprecatedReason ? 'text-red-400/70 line-through' : (rgActive ? 'text-emerald-400' : 'text-blue-400')) + '">' + s.version + (rgActive ? ' <span class="text-[10px] bg-emerald-500/20 text-emerald-300 px-1 rounded">激活</span>' : '') + (s.state === 'available' && !rgActive ? ' <span class="text-[10px] bg-blue-500/20 text-blue-300 px-1 rounded" title="候选池内,当前温区未激活">可用</span>' : '') + (deprecatedReason ? ' <span class="text-[10px] bg-red-500/20 text-red-300 px-1 rounded" title="' + deprecatedReason.replace(/"/g, '&quot;') + '">已证伪</span>' : '') + ((s.market && s.market !== 'all') ? ' <span class="text-[10px] bg-cyan-500/20 text-cyan-300 px-1 rounded">' + (s.market === 'hk' ? '港' : 'A') + '</span>' : '') + '</td>' +
'<td class="px-2 py-1.5 whitespace-normal break-words" title="' + (s.hypothesis || s.summary || '').replace(/"/g, '&quot;') + '">' + (s.name || '') + (smallSample ? ' <span class="text-amber-500" title="样本<40笔">⚠️</span>' : '') + '</td>' +
'<td class="text-center px-2 py-1.5 font-mono text-cyan-300">' + (st.sizing_slots ? st.sizing_slots + '仓' : '10仓') + (st.conviction_model ? '<button onclick="event.stopPropagation();showConviction(\'' + s.version + '\')" class="text-amber-400 hover:text-amber-300" title="查看信念分级仓位规则">⚡</button>' : '') + '</td>' +
'<td class="text-center px-2 py-1.5">' + ((s.description && s.description.title) ? '<button onclick="event.stopPropagation();showDesc(\'' + s.version + '\')" class="text-blue-400 hover:text-blue-300" title="查看策略说明">📖</button>' : '<span class="text-slate-700">—</span>') + '</td>' +
cell('composite', compositeShow, v => v + (s._confidence != null && s._confidence < 1 ? '<span class="text-slate-500 text-[10px]">×' + s._confidence.toFixed(2) + '</span>' : ''), 'font-bold text-amber-300') +
cell('universality_score', universalityShow, v => v + '<span class="text-slate-500" title="普适=跨独立年份有效性: 有效年占比×覆盖因子。100分=每年都有效(非特例)">' + (isOverall ? ('/' + ((st.universality || {}).valid_years || 0) + '年有效') : (rwUniv ? ('/' + (rwUniv.valid_years || 0) + '年有效') : '?')) + '</span>') +
cell('full_return_pct', retShow, v => v + '%', 'font-bold text-amber-300') +
cell('capital_final', capShow, v => '¥' + (v/10000).toFixed(0) + '万') +
cell('cagr_pct', cagrShow, v => v + '%', hl(cagrShow, best.cagr_pct)) +
(st.portfolio && st.portfolio.positions_taken != null
? '<td class="text-right px-2 py-1.5" title="信号数 ' + (tradesShow != null ? tradesShow : '?') + ' → 实际成交 ' + st.portfolio.positions_taken + ' 笔(10等分仓位组合模拟)">' + st.portfolio.positions_taken + (tradesShow != null && tradesShow !== st.portfolio.positions_taken ? ' <span class="text-[10px] text-slate-500">/' + (tradesShow > 999 ? Math.round(tradesShow/1000) + 'k' : tradesShow) + '</span>' : '') + '</td>'
: cell('total_trades', tradesShow, v => (v > 999 ? Math.round(v/1000) + 'k' : v), '', false)) +
cell('avg_hold_days', holdShow, v => v + 'd') +
cell('win_rate', wrShow, v => v + '%', (isOverall ? hl(wrShow, best.win_rate) : (wrShow >= 50 ? 'text-green-400' : 'text-red-400'))) +
cell('avg_profit_pct', pnlShow, v => v + '%', (pnlShow || 0) >= 0 ? 'text-green-400' : 'text-red-400') +
cell('sharpe_ratio', sharpeShow, v => v, hl(sharpeShow, best.sharpe_ratio)) +
cell('profit_factor', pfShow, v => v, hl(pfShow, best.profit_factor)) +
cell('portfolio_max_dd_pct', ddShow, v => v + '%', hl(ddShow, best.portfolio_max_dd_pct, true), true) +
'<td class="text-center px-2 py-1.5" onclick="event.stopPropagation()">' +
'<button onclick="runStrategyBacktest(\'' + s.version + '\')" class="px-2 py-0.5 bg-blue-600/60 hover:bg-blue-500 rounded text-xs">▶回测</button>' +
'</td></tr>';
}
}
ghtml += '</tbody></table></div>';
return ghtml;
};
// ── 2026-08-15 按温区分组:当前温区激活区块改为温区优先(当前温区排最前高亮),
// 每个温区下列出所有在该温区有数据的策略(按该温区胜率降序)──
const REGIME_ORDER = ['trend_up', 'choppy', 'trend_down'];
// ── 2026-08-17 策略评分分级(老莫):基于资格叉数 + 综合分排名 ──
// 不合格=≥2个叉 / 待观察=1个叉 / 合格/良好/优秀=3勾+综合分区间
const GRADE_STD = {
poor: {label: '不合格', cls: 'bg-red-500/20 text-red-300', need: '叉≥2'},
watch: {label: '待观察', cls: 'bg-amber-500/20 text-amber-300', need: '叉=1'},
ok: {label: '合格', cls: 'bg-slate-500/20 text-slate-300', need: '3勾+综合60-74'},
good: {label: '良好', cls: 'bg-emerald-500/25 text-emerald-200', need: '3勾+综合75-84'},
great: {label: '优秀', cls: 'bg-emerald-400/30 text-emerald-100', need: '3勾+综合≥85'},
};
const gradeOf = (qg, comp) => {
if (!qg) return null;
const ok3 = qg.long_ok && qg.mid_ok && qg.short_ok;
const xCount = [qg.long_ok, qg.mid_ok, qg.short_ok].filter(x => !x).length;
if (!ok3) {
if (xCount >= 2) return 'poor';
if (xCount === 1) return 'watch';
return null;
}
if (comp >= 85) return 'great';
if (comp >= 75) return 'good';
return 'ok';
};
// 2026-08-18 按市场×温区判定激活(兼容 hk: 前缀)
window._isActRegime = function(s, rg) {
const cr = s.current_regimes || [];
const isHk = (s.market === 'hk') || cr.some(x => x.indexOf('hk:') === 0);
return isHk ? cr.includes('hk:' + rg) : cr.includes(rg);
};
const renderRegimeRow = (s, rg, bkKey) => {
// 2026-08-15 行级激活按策略自身(后端已按所属市场温区算好 s.current),不再按表格 isCur
const rw = (s.regime_winrates || {})[rg] || {};
const rwPf = rw.portfolio || null;
const wr = rw.win_rate != null ? rw.win_rate : null;
const pnl = rw.avg_pnl != null ? rw.avg_pnl : null;
const tr = rw.trades != null ? rw.trades : null;
const smallSample = tr != null && tr < 40;
let comp = null;
if (rwPf) {
const ret = Math.min(rwPf.total_return_pct || 0, 100) / 100 * 30;
const w = (wr || 0) / 100 * 20;
const sh = Math.min(Math.max((rwPf.sharpe_ratio || 0), 0), 20) / 20 * 20;
const pfc = Math.min((rwPf.profit_factor || 0), 5) / 5 * 15;
const dd = (1 - Math.min(rwPf.portfolio_max_dd_pct || 0, 50) / 50) * 15;
// 2026-08-15:温区行置信用温区自己样本量(regime_winrates 来自预计算表 strategy_regime_perf_by_period
// 按所选周期计算)——用整体 conf 会把温区综合分错误打折(如 1m 视角整体只有 1 笔 → 全部归零)
const rn = tr || 0;
const rm = rw.universality ? (rw.universality.months || 0) : 0;
const rconf = Math.min(1, rn / 40) * (rm > 0 ? Math.min(1, rm / 6) : 1);
// 2026-08-17 老莫:效率惩罚——信号/成交比过高(大量信号未成交=质量差)综合分打折
const _sig = tr != null ? tr : 0, _pos = (rw && rw.positions_taken) || 0;
const _ratio = _pos > 0 ? _sig / _pos : 99;
const _eff = _ratio <= 2 ? 1.0 : _ratio <= 5 ? 0.9 : _ratio <= 10 ? 0.75 : 0.5;
comp = Math.round((ret + w + sh + pfc + dd) * rconf * _eff);
}
const cagr = rwPf ? rwPf.cagr_pct : null;
const ddV = rwPf ? rwPf.portfolio_max_dd_pct : null;
const univ = rw.universality ? rw.universality.score : null;
const univM = rw.universality ? (rw.universality.months + '/' + (rw.universality.regime_total_months || '?')) : null;
const univYears = rw.universality ? ((rw.universality.valid_years || 0) + '/' + (rw.universality.years || 0) + '年') : null;
// ── 2026-08-16 行状态按【该表温区 rg】独立判定(老莫反复强调)──
const qg = (s.qualification || {})[rg];
const rLong = qg && qg.long_ok, rMid = qg && qg.mid_ok, rShort = qg && qg.short_ok;
const r3 = rLong && rMid && rShort; // 三项全过
const rNearOk = rMid || rShort; // 近期有亮点(含长期不过但近期过:❌❌✅/❌✅✅)
const rManual = s.manual_available !== false;
// ── 2026-08-17 状态与评分分离(老莫):
// 状态 = 已激活(current=true,实盘跑)/未激活(current=false,不跑)
// 评分 = 不合格(≥2叉)/待观察(1叉)/合格/良好/优秀(3勾+综合分区间)
const rGrade = gradeOf(qg, comp);
let rStateCls, rStateBadge, rRowBg;
const _rgActive = window._isActRegime(s, rg); // 该策略在当前表温区激活
if (!rManual) { rStateCls = 'text-slate-500'; rStateBadge = '<span class="text-[10px] bg-slate-500/20 text-slate-400 px-1 rounded">手动停用</span>'; rRowBg = 'opacity-50'; }
else if (_rgActive) {
rStateCls = 'text-emerald-400';
rStateBadge = '<span class="text-[10px] bg-emerald-500/40 text-emerald-100 px-1 rounded font-bold">已激活</span>';
rRowBg = 'bg-emerald-900/15';
} else {
rStateCls = 'text-blue-400';
rStateBadge = '<span class="text-[10px] bg-slate-500/20 text-slate-300 px-1 rounded">未激活</span>';
rRowBg = '';
}
const rGradeBadge = rGrade && rGrade !== 'ok' && rGrade !== 'good' && rGrade !== 'great' ? '' : '';
// 2026-08-18 按温区判定激活(而非全局 s.current)——该策略在当前表温区 rg 是否激活
const isActive = window._isActRegime(s, rg);
// 2026-08-18 展开持久化:读 localStorage 决定备选行是否 hidden(非总是折叠)
const _bkAttr = bkKey ? ' data-bk="' + bkKey + '"' + (window._bkOpen(bkKey) ? '' : ' class="hidden"') : '';
return '<tr' + (_bkAttr || '') + ' class="border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer ' + rRowBg + '" onclick="showStrategyDetail(\'' + s.version + '\')">' +
'<td class="px-2 py-1.5 font-mono font-bold ' + rStateCls + '">' + s.version + ' ' + rStateBadge + ((s.market && s.market !== 'all') ? ' <span class="text-[10px] bg-cyan-500/20 text-cyan-300 px-1 rounded">' + (s.market === 'hk' ? '港' : 'A') + '</span>' : '') + '</td>' +
'<td class="px-2 py-1.5 whitespace-normal break-words" title="' + (s.hypothesis || s.summary || '').replace(/"/g, '&quot;') + '">' + (s.name || '') + (smallSample ? ' <span class="text-amber-500" title="样本<40笔">⚠️</span>' : '') + ' ' +
(rGrade ? '<span class="text-[10px] px-1 py-0.5 rounded ' + GRADE_STD[rGrade].cls + '" title="评分: ' + GRADE_STD[rGrade].need + '">' + GRADE_STD[rGrade].label + '</span>' : '') + '</td>' +
// 2026-08-16 资格列:长期/近期/当下 三项达标徽章
'<td class="text-center px-1 py-1.5 whitespace-nowrap" onclick="event.stopPropagation()">' + (s.qualification && s.qualification[rg] ? (
'<span title="长期10y年化' + (s.qualification[rg].cagr_10y != null ? s.qualification[rg].cagr_10y + '%' : '?') + ' vs 大盘' + (s.qualification[rg].bench_10y != null ? s.qualification[rg].bench_10y + '%' : '?') + '">' + (s.qualification[rg].long_ok ? '✅' : '❌') + '</span>' +
'<span title="近期2y年化' + (s.qualification[rg].cagr_2y != null ? s.qualification[rg].cagr_2y + '%' : '?') + ' vs 大盘' + (s.qualification[rg].bench_2y != null ? s.qualification[rg].bench_2y + '%' : '?') + '">' + (s.qualification[rg].mid_ok ? '✅' : '❌') + '</span>' +
'<span title="当下1y年化' + (s.qualification[rg].cagr_1y != null ? s.qualification[rg].cagr_1y + '%' : '?') + ' vs 大盘' + (s.qualification[rg].bench_1y != null ? s.qualification[rg].bench_1y + '%' : '?') + '">' + (s.qualification[rg].short_ok ? '✅' : '❌') + '</span>'
) : '<span class="text-slate-600">—</span>') +
(s.qual_warning ? '<div class="text-[9px] ' + (s.qual_warning.indexOf('回升') >= 0 ? 'text-green-400' : 'text-amber-400') + '">' + s.qual_warning + '</div>' : '') +
'</td>' +
// 2026-08-16 可用性开关(手动把关)
'<td class="text-center px-1 py-1.5" onclick="event.stopPropagation()">' +
'<input type="checkbox" ' + (s.manual_available !== false ? 'checked' : '') +
' onclick="toggleAvail(\'' + s.version + '\', this.checked)" title="手动可用性(激活只能是可用的策略)" class="accent-emerald-500 cursor-pointer">' +
'</td>' +
cell('win_rate', wr, v => v + '%', wr != null && wr >= 50 ? 'text-green-400' : 'text-red-400') +
cell('avg_profit_pct', pnl, v => v + '%', (pnl || 0) >= 0 ? 'text-green-400' : 'text-red-400') +
(rw && rw.positions_taken != null
? '<td class="text-right px-2 py-1.5" title="信号数 ' + (tr != null ? tr : '?') + '(组合模拟实际成交 ' + rw.positions_taken + ' 笔)">' + rw.positions_taken + (tr != null && tr !== rw.positions_taken ? ' <span class="text-[10px] text-slate-500">/' + tr + '</span>' : '') + '</td>'
: cell('total_trades', tr, v => v, '', false)) +
cell('avg_hold_days', rw.avg_hold_days != null ? rw.avg_hold_days : null, v => v + 'd') +
cell('cagr_pct', cagr, v => v + '%', 'font-bold text-amber-300') +
cell('composite', comp, v => v, 'font-bold text-amber-300') +
cell('sharpe_ratio', rw.sharpe_ratio != null ? rw.sharpe_ratio : null, v => v) +
cell('profit_factor', rw.profit_factor != null ? rw.profit_factor : null, v => v) +
cell('universality_score', univ, v => v + ' <span class="text-slate-500" title="普适=跨独立年份有效性: 有效年占比×覆盖因子。100分=每年都有效(非特例)">' + (univYears || '') + '</span>') +
cell('portfolio_max_dd_pct', ddV, v => v + '%', '') +
'<td class="text-center px-2 py-1.5" onclick="event.stopPropagation()"><button onclick="runStrategyBacktest(\'' + s.version + '\')" class="px-2 py-0.5 bg-blue-600/60 hover:bg-blue-500 rounded text-xs">▶回测</button></td></tr>';
};
const renderRegimeTable = (strats, rg, isCur, marketLabel, tempHtml) => {
// 2026-08-16 温区表显示该温区下【资格达标】的策略(qualification[rg] 存在 = 有10y数据+长期合格)
// 策略可适应多个温区:在它达标的每个温区表都显示一行(不再局限单个 best_regime
const metas = strats
.filter(s => s.qualification && s.qualification[rg] && (s.regime_winrates || {})[rg] && (s.regime_winrates || {})[rg].trades != null);
const regimeGet = (s, key) => {
const rw = (s.regime_winrates || {})[rg] || {};
const pf = rw.portfolio || {};
switch (key) {
case 'version': return s.version || '';
case 'name': return s.name || '';
case 'win_rate': return rw.win_rate != null ? rw.win_rate : -999;
case 'avg_profit': return rw.avg_pnl != null ? rw.avg_pnl : -999;
case 'trades': return rw.trades || 0;
case 'hold': return rw.avg_hold_days != null ? rw.avg_hold_days : 0;
case 'cagr': return pf.cagr_pct != null ? pf.cagr_pct : -999;
case 'sharpe': return rw.sharpe_ratio != null ? rw.sharpe_ratio : -999;
case 'profit_factor': return rw.profit_factor != null ? rw.profit_factor : -999;
case 'composite': {
// 复用温区行综合分公式(与 renderRegimeRow 一致)
const ret = Math.min(pf.total_return_pct || 0, 100) / 100 * 30;
const w = (rw.win_rate || 0) / 100 * 20;
const sh = Math.min(Math.max((pf.sharpe_ratio || 0), 0), 20) / 20 * 20;
const pfc = Math.min((pf.profit_factor || 0), 5) / 5 * 15;
const dd = (1 - Math.min(pf.portfolio_max_dd_pct || 0, 50) / 50) * 15;
const rn = rw.trades || 0;
const rm = rw.universality ? (rw.universality.months || 0) : 0;
const rconf = Math.min(1, rn / 40) * (rm > 0 ? Math.min(1, rm / 6) : 1);
// 2026-08-17 效率惩罚(与 renderRegimeRow 一致)——否则排序分≠显示分,排名错乱
const _sig = rn, _pos = rw.positions_taken || 0;
const _ratio = _pos > 0 ? _sig / _pos : 99;
const _eff = _ratio <= 2 ? 1.0 : _ratio <= 5 ? 0.9 : _ratio <= 10 ? 0.75 : 0.5;
return Math.round((ret + w + sh + pfc + dd) * rconf * _eff);
}
case 'universality': return (rw.universality || {}).score || 0;
case 'max_dd': return pf.portfolio_max_dd_pct != null ? pf.portfolio_max_dd_pct : 999;
default: return 0;
}
};
if (window._sortKey) {
const key = window._sortKey, dir = window._sortDir;
metas.sort((a, b) => {
const va = regimeGet(a, key), vb = regimeGet(b, key);
let cmp = (typeof va === 'string') ? va.localeCompare(vb) : va - vb;
return dir === 'desc' ? -cmp : cmp;
});
} else {
metas.sort((x, y) => ((y.regime_winrates || {})[rg].win_rate || -1) - ((x.regime_winrates || {})[rg].win_rate || -1));
}
if (!metas.length) return '';
const meta = REGIME_META[rg] || {icon: '🌐', name: rg};
return '<div class="mb-3 rounded-lg border ' + (isCur ? 'border-emerald-700/60 bg-emerald-900/10' : 'border-slate-700') + '">' +
'<div class="flex items-center gap-2 px-3 py-1.5 text-sm ' + (isCur ? 'text-emerald-300' : 'text-slate-400') + '">' +
marketLabel + ' · ' + meta.icon + ' ' + meta.name + (isCur ? ' <span class="text-[10px] bg-emerald-500/20 text-emerald-300 px-1 rounded">当前温区</span>' : '') + (tempHtml || '') +
'<span class="text-xs text-slate-500">(' + metas.length + '个策略)</span>' +
'<span class="text-[10px] text-slate-600">' + (isCur ? '当前温区匹配即激活' : '策略适应温区(best_regime') + '</span>' +
'<span class="text-[10px] text-slate-500 ml-auto">大盘基准: 1y' + (window._bench1y != null ? window._bench1y + '%' : '?') +
' 2y' + (window._bench2y != null ? window._bench2y + '%' : '?') +
' 10y' + (window._bench10y != null ? window._bench10y + '%' : '?') + '</span>' +
'<button onclick="batchAvail(\'' + rg + '\', true)" class="text-[10px] px-1.5 py-0.5 bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30">✅全可用</button>' +
'<button onclick="batchAvail(\'' + rg + '\', false)" class="text-[10px] px-1.5 py-0.5 bg-red-500/20 text-red-300 rounded hover:bg-red-500/30">🚫全不可用</button>' +
'</div>' +
'<div class="overflow-x-auto"><table class="w-full text-xs whitespace-nowrap"><thead><tr class="text-slate-500 border-b border-slate-700">' +
th('version', '版本', 'text-left', '策略版本标识', 'version') + th('name', '名称', 'text-left', '策略人话名称', 'name') +
'<th class="text-center px-2 py-1 align-top" title="长期10y/近期2y/当下1y 适应温区年化 vs 大盘"><div class="spec-th-col"><div class="spec-th-label">资格</div>' + (window._colBtnsOn !== false ? '<div class="spec-th-btns"><span class="spec-btn colinfo" title="看这列是干嘛的" onclick="event.stopPropagation();showColumnHelp(\'qualification\')">ⓘ</span><span class="spec-btn ai" title="看策略模块spec" onclick="event.stopPropagation();showModuleHelp(\'strategy\',\'ai\')">§</span></div>' : '') + '</div></th>' +
'<th class="text-center px-2 py-1 align-top"><div class="spec-th-col"><div class="spec-th-label">可用</div>' + (window._colBtnsOn !== false ? '<div class="spec-th-btns"><span class="spec-btn colinfo" title="看这列是干嘛的" onclick="event.stopPropagation();showColumnHelp(\'manual_available\')">ⓘ</span><span class="spec-btn ai" title="看策略模块spec" onclick="event.stopPropagation();showModuleHelp(\'strategy\',\'ai\')">§</span></div>' : '') + '</div></th>' +
th('win_rate', '胜率', 'text-right', '盈利笔数占比', 'win_rate') + th('avg_profit', '平均收益', 'text-right', '每笔平均收益率', 'avg_profit') + th('trades', '成交/信号', 'text-right', '实际入场笔数 / 触发信号数(组合模拟10等分仓位下实际成交)', 'trades') +
th('hold', '均持仓', 'text-right', '平均持仓天数', 'hold') + th('cagr', '年化', 'text-right', '年化收益率', 'cagr') + th('composite', '综合', 'text-right', '多维度综合评分', 'composite') +
th('sharpe', '夏普', 'text-right', '每单位波动超额收益', 'sharpe') + th('profit_factor', '盈亏比', 'text-right', '总盈利/总亏损', 'profit_factor') +
th('universality', '普适', 'text-right', '跨独立年份有效性(非特例)', 'universality') + th('max_dd', '回撤', 'text-right', '组合模拟最大回撤', 'max_dd') +
'<th class="text-center px-2 py-1.5">操作</th></tr></thead>' +
// ── 2026-08-17 分区(老莫):上区=已激活,下区=备选(资格达标未激活),默认折叠 ──
(() => {
const _isAct = (s) => window._isActRegime(s, rg);
const active = metas.filter(_isAct);
const backup = metas.filter(s => !_isAct(s));
let html = '<tbody class="align-top">';
if (active.length) {
html += '<tr class="bg-emerald-900/20"><td colspan="15" class="px-2 py-1 text-[10px] text-emerald-300 font-bold">✅ 已激活 (' + active.length + ')</td></tr>';
html += active.map(s => renderRegimeRow(s, rg)).join('');
}
if (backup.length) {
html += '<tr class="border-t border-slate-700/50"><td colspan="15" class="px-2 py-1 text-[10px] text-slate-400">' +
'<button onclick="toggleBackup(\'' + marketLabel + rg + '\')" class="hover:text-slate-200">' +
'<span id="bk-arrow-' + marketLabel + rg + '">' + (window._bkOpen(marketLabel + rg) ? '\u25bc' : '\u25b6') + '</span> 备选 (资格达标未激活, ' + backup.length + ') <span class="text-slate-600">点击展开</span></button></td></tr>';
// 2026-08-17 修复:备选行直接进同一 tbodyrenderRegimeRow 传 bkKey, 行加 data-bk+hidden),列宽与表头对齐
html += backup.map(s => renderRegimeRow(s, rg, marketLabel + rg)).join('');
}
html += '</tbody></table></div></div>';
return html;
})();
};
// ── 2026-08-17 备选区展开/折叠(老莫)──
window.toggleBackup = function(key) {
const rows = document.querySelectorAll('[data-bk="' + key + '"]');
const arrow = document.getElementById('bk-arrow-' + key);
if (!rows.length) { if (arrow) arrow.textContent = '\u25b6'; return; }
const hidden = rows[0].classList.contains('hidden');
rows.forEach(r => r.classList.toggle('hidden'));
if (arrow) arrow.textContent = hidden ? '\u25bc' : '\u25b6';
// 2026-08-18 展开状态持久化(刷新保留)
try { localStorage.setItem('bk_open_' + key, hidden ? '1' : '0'); } catch(e) {}
};
window._bkOpen = function(key) { try { return localStorage.getItem('bk_open_' + key) === '1'; } catch(e) { return false; } };
// ── 2026-08-15 按市场×温区展示:6表格 = A股当前市 → 港股当前市 → A股其他2市 → 港股其他2市,
// 每表格列出该市场在该温区有数据的所有可用策略(含未激活,按该温区胜率降序),激活策略高亮;温度并入当前市标题尾部
const usableStrats = strategies.filter(s => s.current === true || s.state === 'available');
const unavailableStrats = strategies.filter(s => s.current !== true && s.state !== 'available');
const aStrats = usableStrats.filter(s => (s.market || 'all') !== 'hk');
const hkStrats = usableStrats.filter(s => (s.market || 'all') === 'hk');
const regA = window._regimeA || null;
const regHK = window._regimeHK || null;
const aCur = regA && regA.state;
const hkCur = regHK && regHK.state;
const tempHtml = (reg, hasRsi) => {
if (!reg || !reg.temp_band) return '';
const bandIcon = {panic:'🔴', fear:'🟠', neutral:'🟡', greed:'🟢', euphoria:'🔵'}[reg.temp_band] || '⚪';
const bandName = {panic:'恐慌', fear:'偏冷', neutral:'中性', greed:'偏热', euphoria:'亢奋'}[reg.temp_band] || reg.temp_band;
return ' · 温度: <b>' + bandIcon + ' ' + bandName + '</b>' + (hasRsi && reg.temp_rsi ? ' <span class="text-slate-500 text-xs">RSI=' + reg.temp_rsi + '</span>' : '');
};
let html = '';
// 2026-08-17 老莫:?§按钮只保留第一个表格(A股当前市)表头
window._colBtnsOn = true;
// 头两个:A股当前市 + 港股当前市(标题尾部带温度)
if (aCur) html += renderRegimeTable(aStrats, aCur, true, '🇨🇳 A股', tempHtml(regA, true));
window._colBtnsOn = false;
if (hkCur) html += renderRegimeTable(hkStrats, hkCur, true, '🇭🇰 港股', tempHtml(regHK, false));
// 后四个:A股其他2市 + 港股其他2市
for (const rg of REGIME_ORDER) if (rg !== aCur) html += renderRegimeTable(aStrats, rg, false, '🇨🇳 A股', '');
for (const rg of REGIME_ORDER) if (rg !== hkCur) html += renderRegimeTable(hkStrats, rg, false, '🇭🇰 港股', '');
if (unavailableStrats.length) {
html += '<div class="mb-4 opacity-70">' +
'<button class="w-full flex items-center justify-between px-3 py-2 bg-slate-800/50 hover:bg-slate-800 rounded-lg border border-slate-700 text-sm" onclick="this.nextElementSibling.classList.toggle(\'hidden\'); this.querySelector(\'span\').textContent = this.nextElementSibling.classList.contains(\'hidden\') ? \'▶\' : \'▼\'">' +
'<span class="text-slate-400">📚 不可用(已证伪 / 历史版本)</span><span class="text-slate-500 text-xs">(' + unavailableStrats.length + '个)</span><span class="text-slate-500">▶</span></button>' +
'<div class="hidden mt-2">' + renderGroup(unavailableStrats, '不可用', '📚', true) + '</div>' +
'</div>';
}
if (!usableStrats.length && !unavailableStrats.length) {
html = '<div class="text-slate-500">暂无策略版本</div>';
}
html += '<div class="text-xs text-slate-600 mt-2">总收益/最终资产 = 100万本金·最多10等分仓位·24个月组合模拟 · ⚠️样本&lt;40笔 · 绿色=该列最优 · 悬停名称查看改进假设</div>';
el.innerHTML = html;
// 2026-08-18 恢复整页滚动位置(解决重建后滚动归零,老莫反馈)
if (window._stratScrollY != null) { window.scrollTo(0, window._stratScrollY); window._stratScrollY = null; }
}
async function runStrategyBacktest(version) {
const period = document.getElementById('btPeriod')?.value || '6m';
const detail = document.getElementById('strategyDetail');
detail.innerHTML = '<div class="text-blue-400 animate-pulse text-sm">⏳ 正在回测 ' + version + '(约15-30秒)...</div>';
try {
const mkt = document.getElementById('btMarket')?.value || 'all';
const resp = await fetch('/api/research/backtest?strategy=' + version + '&period=' + period + '&market=' + mkt);
const data = await resp.json();
if (data.error) { detail.innerHTML = '<div class="text-red-400">' + data.error + '</div>'; return; }
await loadStrategyList();
showStrategyResult(data);
} catch(e) {
detail.innerHTML = '<div class="text-red-400">失败: ' + e.message + '</div>';
}
}
function showDesc(version) {
// 2026-08-18 补回(commit 9f1185fc 误删导致📖按钮失效)
const s = (window._strats || []).find(x => x.version === version);
const d = s && s.description;
if (!d || !d.title) { alert('暂无说明'); return; }
// 用模态框展示说明(替代旧 alert,含进化说明)
const evoRows = (window._evolutionHistory || []).filter(e => e.child_version === version || e.parent_version === version);
let evoHtml = '';
if (evoRows.length) {
evoHtml = '\n\n【进化来源】\n' + evoRows.map(e =>
(e.parent_version || '?') + ' → ' + (e.child_version || version) +
(e.change_description ? '' + e.change_description : '')).join('\n');
}
const text = ['【' + d.title + '】', '【算法】' + (d.algorithm || ''), '【思路】' + (d.rationale || ''), '【证据】' + (d.evidence || '')].join('\n\n') + evoHtml;
const el = document.getElementById('desc-modal');
if (el) {
el.querySelector('.modal-body').innerHTML = text.replace(/\n/g, '<br>');
el.classList.remove('hidden');
} else {
alert(text);
}
}
async function showStrategyDetail(version) {
const detail = document.getElementById('strategyDetail');
detail.innerHTML = '<div class="text-slate-500 text-sm">加载 ' + version + ' 明细...</div>';
try {
const [anaResp, tradesResp] = await Promise.all([
fetch('/api/research/analysis?strategy=' + version),
fetch('/api/research/trades?strategy=' + version)
]);
const ana = await anaResp.json();
const tradesData = await tradesResp.json();
showStrategyResult({ strategy: version, trades: tradesData.trades || [], insights: (ana.insights || []), analysis: ana });
} catch(e) {
detail.innerHTML = '<div class="text-red-400">失败: ' + e.message + '</div>';
}
}
function showStrategyResult(data) {
const el = document.getElementById('strategyDetail');
const s = data.summary || {};
let html = '<div class="border border-slate-700 rounded-xl p-4 bg-slate-900/40">' +
'<div class="flex items-center gap-3 mb-3"><h3 class="font-bold text-blue-300">' + (data.strategy || '') + ' ' + (data.strategy_name || '') + '</h3>' +
'<span class="text-xs text-slate-500">' + (data.period || '') + '</span></div>';
if (s.total_trades != null) {
html += '<div class="grid grid-cols-3 md:grid-cols-6 gap-2 mb-3 text-center">' +
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">交易</div><div class="font-mono">' + s.total_trades + '</div></div>' +
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">胜率</div><div class="font-mono ' + (s.win_rate >= 50 ? 'text-green-400' : 'text-yellow-400') + '">' + s.win_rate + '%</div></div>' +
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">平均收益</div><div class="font-mono ' + (s.avg_profit_pct >= 0 ? 'text-green-400' : 'text-red-400') + '">' + s.avg_profit_pct + '%</div></div>' +
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">夏普</div><div class="font-mono">' + s.sharpe_ratio + '</div></div>' +
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">盈亏比</div><div class="font-mono">' + (s.profit_factor || '—') + '</div></div>' +
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">回撤</div><div class="font-mono text-red-400">' + s.max_drawdown_pct + '%</div></div></div>';
}
// 归因洞察
if (data.insights && data.insights.length) {
html += '<div class="mb-3"><div class="text-sm font-bold text-slate-300 mb-1">🔬 因子归因洞察(下一版迭代依据)</div>' +
'<div class="space-y-1">' + data.insights.map(i => '<div class="text-xs text-slate-400 bg-slate-800/30 rounded px-2 py-1">' + i + '</div>').join('') + '</div></div>';
}
// 交易明细
const trades = (data.trades || []).slice(0, 50);
if (trades.length) {
html += '<div class="text-sm font-bold text-slate-300 mb-1">交易明细(前50笔)</div>' +
'<div class="overflow-x-auto max-h-96 overflow-y-auto"><table class="w-full text-xs"><thead class="sticky top-0 bg-slate-900"><tr class="text-slate-500 border-b border-slate-700">' +
'<th class="text-left px-2 py-1">代码</th><th class="text-left px-2 py-1">名称</th><th class="text-left px-2 py-1">入场</th>' +
'<th class="text-right px-2 py-1">买入</th><th class="text-right px-2 py-1">卖出</th><th class="text-right px-2 py-1">收益</th>' +
'<th class="text-center px-2 py-1">结果</th><th class="text-right px-2 py-1">持仓</th><th class="text-right px-2 py-1">评分</th></tr></thead><tbody>';
const reasonMap = { target: '🎯止盈', stop: '🛑止损', keep: '⏳到期', staged_end: '🪜分批', trail: '📈跟踪' };
for (const t of trades) {
const pc = (t.profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400';
html += '<tr class="border-b border-slate-800/40">' +
'<td class="px-2 py-1 font-mono">' + t.code + '</td><td class="px-2 py-1">' + (t.name || '') + '</td>' +
'<td class="px-2 py-1 font-mono">' + (t.entry_date || '') + '</td>' +
'<td class="text-right px-2 py-1 font-mono">' + (t.entry_price || 0).toFixed(2) + '</td>' +
'<td class="text-right px-2 py-1 font-mono">' + (t.exit_price || 0).toFixed(2) + '</td>' +
'<td class="text-right px-2 py-1 font-mono ' + pc + '">' + (t.profit_pct || 0).toFixed(1) + '%</td>' +
'<td class="text-center px-2 py-1">' + (reasonMap[t.exit_reason] || t.exit_reason) + '</td>' +
'<td class="text-right px-2 py-1">' + (t.hold_days || 0) + 'd</td>' +
'<td class="text-right px-2 py-1">' + (t.score || 0) + '</td></tr>';
}
html += '</tbody></table></div>';
}
html += '</div>';
el.innerHTML = html;
}
// ── 浮动异常区逻辑(2026-08-12)──
let lastAlertKeys = '';
let alertAutoHideTimer = null;
function toggleAlertFloat(ev) {
if (ev) ev.stopPropagation();
const el = document.getElementById('alertFloat');
const body = document.getElementById('alertBody');
const isCollapsed = el.classList.contains('collapsed');
el.classList.toggle('collapsed');
body.style.display = isCollapsed ? 'block' : 'none';
el.querySelector('.alert-toggle').textContent = isCollapsed ? '▴' : '▾';
if (alertAutoHideTimer) { clearTimeout(alertAutoHideTimer); alertAutoHideTimer = null; }
}
function renderAlerts(alerts) {
// 首次加载初始化:把当前已有异常标记为"已展开",避免打开页面就弹旧异常
if (!localStorage.getItem('alertsInitialized')) {
try {
const initKeys = (Array.isArray(alerts)?alerts:[]).map(a => (a.ts||0)+'_'+(a.source||'')+'_'+(a.code||''));
localStorage.setItem('expandedAlertKeys', JSON.stringify(initKeys));
localStorage.setItem('alertsInitialized', '1');
} catch(e) {}
}
const el = document.getElementById('alertFloat');
const body = document.getElementById('alertBody');
const countEl = document.getElementById('alertCount');
const list = Array.isArray(alerts) ? alerts : [];
// 只显示 error/critical/warninginfo 不打扰)
const shown = list.filter(a => a.level !== 'info');
// ── 24小时过滤:只有24小时内的异常才算"最新异常",有资格右上角显示 ──
const nowSec = Date.now() / 1000;
const fresh = shown.filter(a => (a.ts || 0) > nowSec - 86400);
// 已展开过的异常 keyslocalStorage 持久化,跨会话记住,旧异常不再触发展开)
let expandedKeys = new Set();
try { expandedKeys = new Set(JSON.parse(localStorage.getItem('expandedAlertKeys') || '[]')); } catch(e) {}
// 清理过期的已展开记录(24小时前的清掉,避免无限增长)
const expandedArr = [...expandedKeys];
const freshKeys = fresh.map(a => (a.ts||0) + '_' + (a.source||'') + '_' + (a.code||''));
const freshKeySet = new Set(freshKeys);
// 找出"新异常":在24小时内,且从未被展开过
const newKeys = freshKeys.filter(k => !expandedKeys.has(k));
const hasNew = newKeys.length > 0;
if (!fresh.length) {
// 无最新异常 → 隐藏浮窗
el.style.display = 'none';
el.classList.add('collapsed');
body.style.display = 'none';
countEl.style.display = 'none';
countEl.textContent = '0';
lastAlertKeys = '';
return;
}
// 有最新异常 → 显示浮窗
el.style.display = '';
countEl.style.display = 'inline-block';
countEl.textContent = fresh.length;
const levelIcon = { critical: '🔴', error: '🟠', warning: '🟡' };
body.innerHTML = fresh.map(a =>
`<div class="alert-item ${a.level || 'error'}">
<div><span class="a-src">${levelIcon[a.level]||'🟠'} ${a.source||''}</span>
${a.code ? `<span class="font-mono text-slate-500">${a.code}</span>` : ''}</div>
<div class="a-title">${esc(a.title || '')}</div>
${a.detail ? `<div class="a-detail">${esc(a.detail)}</div>` : ''}
<div class="text-slate-600 text-[10px]">${a.ts_str || ''}</div>
</div>`
).join('');
// ── 展开逻辑:只有"新异常"(未展开过)才触发自动展开 ──
const isCurrentlyCollapsed = el.classList.contains('collapsed');
if (hasNew && isCurrentlyCollapsed) {
// 展开 3 秒后自动折叠
el.classList.remove('collapsed');
body.style.display = 'block';
el.querySelector('.alert-toggle').textContent = '▴';
if (alertAutoHideTimer) clearTimeout(alertAutoHideTimer);
alertAutoHideTimer = setTimeout(() => {
el.classList.add('collapsed');
body.style.display = 'none';
el.querySelector('.alert-toggle').textContent = '▾';
alertAutoHideTimer = null;
}, 3000);
// 把这些新异常标记为"已展开过"(持久化,之后不再触发)
newKeys.forEach(k => expandedKeys.add(k));
try { localStorage.setItem('expandedAlertKeys', JSON.stringify([...expandedKeys])); } catch(e) {}
}
lastAlertKeys = freshKeys.join('|');
}
// refreshAll 内联渲染:把 alerts 交给 renderAlerts
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function loadBroadcast() {
var el = document.getElementById('broadcast-list');
if (!el) return;
fetch('/api/broadcast/recent?hours=72&limit=100').then(function(r){return r.json()}).then(function(data) {
if (!data.length) { el.innerHTML = '<div class="text-slate-500 text-sm">No messages</div>'; return; }
var h = '';
var cat_colors = {trading:'#22c55e',system_error:'#ef4444',health:'#3b82f6',market:'#f59e0b',news:'#8b5cf6',strategy:'#06b6d4',general:'#6b7280'};
var cat_labels = {trading:'Trading',system_error:'Error',health:'Health',market:'Market',news:'News',strategy:'Strategy',general:'System'};
data.forEach(function(m) {
var color = cat_colors[m.category] || '#6b7280';
var cat_label = cat_labels[m.category] || m.category;
var preview = m.content;
var time = (m.ts||'').slice(0,16).replace('T',' ');
h += '<div class="bg-slate-800/60 border border-slate-700 rounded-lg p-3 cursor-pointer hover:border-slate-500" onclick="showBroadcastDetail(this)" data-content="' + m.content.replace(/"/g,'&quot;') + '" data-title="' + (m.title||'').replace(/"/g,'&quot;') + '">';
h += '<div class="flex items-center gap-2 mb-1">';
h += '<span class="text-xs px-2 py-0.5 rounded" style="background:'+color+'22;color:'+color+'">'+cat_label+'</span>';
h += '<span class="text-xs text-slate-500">'+time+'</span>';
if (m.title) h += '<span class="text-sm font-semibold text-slate-300">'+m.title+'</span>';
h += '</div>';
h += '<div class="text-xs text-slate-400 leading-relaxed">'+esc(preview)+'</div>';
h += '</div>';
});
el.innerHTML = h;
}).catch(function(e) { el.innerHTML = '<div class="text-red-400 text-sm">Error: '+e.message+'</div>'; });
}
function showBroadcastDetail(el) {
var content = el.getAttribute('data-content');
var title = el.getAttribute('data-title');
var modal = document.createElement('div');
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/60';
modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
modal.innerHTML = '<div class="bg-slate-800 border border-slate-600 rounded-xl p-6 max-w-2xl max-h-[80vh] overflow-y-auto shadow-2xl"><div class="flex justify-between items-center mb-4"><h3 class="text-lg font-bold text-slate-200">'+(title||'Detail')+'</h3><button onclick="this.closest(\'div.fixed\').remove()" class="text-slate-400 hover:text-white text-xl">&times;</button></div><pre class="text-sm text-slate-300 whitespace-pre-wrap leading-relaxed">'+esc(content)+'</pre></div>';
document.body.appendChild(modal);
}
</script>
</body>
</html>