Files
MoFin/static/index.html
T
xxm 870ee7e3b9 fix: 组合Tab突出成员盈利率 + 手工可行性标注
- 前端: 成员卡片盈利率/年化/10年收益提为大字醒目显示(原10px小字用户看不到)
- 前端: v_mr卡片加实盘链路说明(22k信号→组合执行628→每日扫描仅3候选→人工确认)
- 前端: 标注毛利vs扣费口径(单笔均收益=毛利, 组合年化/收益=已扣0.2%费用)
- 后端: members补充组合模拟实际执行笔数(5槽/36槽), 用于年均笔数展示
2026-08-02 23:32:48 +08:00

2490 lines
140 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; }
::-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; }
</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="evaluation">📊 评估</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="signals">🔍 信号</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>
<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-evaluation" 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-signals" 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-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>
<!-- 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();
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 === 'signals') renderSignals();
else if (name === 'health') renderHealth();
else if (name === 'principles') renderPrinciples();
else if (name === 'watch') renderWatch();
else if (name === 'research') renderResearch();
}
// ── 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 : [];
// 右上角时间显示:来源时间说明
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>';
el.innerHTML = `
<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 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>
`;
}
// ── 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'),
fetchJSON('/api/signals'),
fetchJSON('/api/candidates'),
]).then(([mkt, signals, candidates]) => {
const sectors = mkt.sectors || [];
const sigs = signals || [];
const cands = candidates || [];
const trend = sigs.filter(s => s.source && s.source !== 'xiaoguo').concat(sigs.filter(s => s.source === 'market_scanner'));
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 class="spec-btn help" onclick="showModuleHelp('signals','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('signals','ai')">§</span></span></span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4">
<div class="card p-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>=0?'':'bg-slate-800/20'} rounded mb-0.5">
<span class="text-xs">${s.name}</span>
<span class="text-xs font-mono ${(s.change||0)>=0?'badge-up':'badge-down'}">${(s.change||0)>=0?'+':''}${(s.change||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">📡 趋势信号 (${trend.length})</h3>
${trend.length ? trend.slice(0,6).map(s => `
<div class="p-1.5 bg-slate-800/30 rounded mb-0.5 text-xs">
<span class="font-semibold ${s.overall_sentiment==='利好'?'text-green-400':s.overall_sentiment==='利空'?'text-red-400':'text-slate-400'}">${s.overall_sentiment||'中性'}</span>
${s.sector} ${(s.summary||'').slice(0,60)}
</div>
`).join('') : '<div class="text-xs text-slate-500">暂无</div>'}
</div>
<div class="card p-4">
<h3 class="text-sm font-semibold mb-2">📊 信号汇总 (${sigs.length})</h3>
${sigs.length ? sigs.slice(0,6).map(s => `
<div class="p-1.5 bg-slate-800/30 rounded mb-0.5 text-xs">
<span class="font-semibold ${s.overall_sentiment==='利好'?'text-green-400':s.overall_sentiment==='利空'?'text-red-400':'text-slate-400'}">${s.overall_sentiment||'中性'}</span>
${s.sector} ${(s.summary||'').slice(0,60)}
</div>
`).join('') : '<div class="text-xs text-slate-500">暂无</div>'}
</div>
</div>
<div class="card p-4 mb-4">
<h3 class="text-sm font-semibold mb-2">🎯 主力建仓候选 (${cands.length})</h3>
${cands.length ? `
<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 class="text-xs text-slate-500">暂无候选(开盘后自动扫描)</div>'}
</div>
<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-48 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||0)>=0?'badge-up':'badge-down'}">${(s.change||0)>=0?'+':''}${(s.change||0).toFixed(1)}%</span>
</div>
`).join('')}
</div>
</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');
}
// ── 信号页 ──
async function renderSignals() {
const el = document.getElementById('tab-signals');
el.innerHTML = '<div class="text-slate-400 text-sm">加载中…</div>';
try {
const [signals, candidates] = await Promise.all([
fetchJSON('/api/signals'),
fetchJSON('/api/candidates')
]);
let html = `
<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('signals','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('signals','ai')">§</span></span></span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<div class="card p-4">
<div class="text-xs text-slate-500 mb-1">今日信号</div>
<div class="flex gap-4 text-sm">
<span class="text-blue-400">📡 趋势信号: ${signals.length || 0} 条</span>
<span class="text-green-400">🎯 候选股: ${candidates.length || 0} 只</span>
</div>
</div>
<div class="card p-4">
<div class="text-xs text-slate-500 mb-1">全市场扫描</div>
<div class="flex gap-4 text-sm">
<span class="text-slate-300">📋 候选池: ${candidates.length || 0} 只</span>
<span class="text-yellow-400">🏆 已入自选: ${candidates.filter(c => c.promoted).length || 0} 只</span>
</div>
</div>
</div>`;
if (!signals || signals.length === 0) {
html += '<div class="card p-6 text-center text-slate-500 text-sm">暂无信号</div>';
el.innerHTML = html;
return;
}
html += '<div class="space-y-3">';
for (const s of signals) {
const sev = s.severity || '-';
const sevColor = sev === 'high' ? 'text-red-400' : sev === 'medium' ? 'text-yellow-400' : 'text-green-400';
const sent = s.overall_sentiment || '-';
const sentColor = sent === '利好' ? 'text-green-400' : sent === '利空' ? 'text-red-400' : 'text-slate-400';
const source = s.source || 'trend';
const sourceIcon = source === 'market_scanner' ? '🎯' : source === 'macro' ? '🌐' : '📡';
html += `
<div class="card p-4">
<div class="flex items-center gap-2 mb-1">
<span class="${sevColor} text-xs font-semibold">${sev.toUpperCase()}</span>
<span class="${sentColor} text-sm font-semibold">${sent}</span>
<span class="text-slate-500 text-xs">${sourceIcon} ${source}</span>
<span class="text-slate-600 text-xs ml-auto">${s.created_at ? s.created_at.slice(5,16) : ''}</span>
</div>
<div class="text-sm text-slate-300">${s.sector || '-'}</div>
<div class="text-xs text-slate-500 mt-1">${(s.summary || '').slice(0,120)}</div>
</div>`;
}
html += '</div>';
el.innerHTML = html;
} catch(e) {
el.innerHTML = `<div class="text-red-400 text-sm">加载失败: ${e.message}</div>`;
}
}
// ── Spec System ──
let _specCache = {};
function closeSpecModal() {
const el = document.getElementById('specModal');
if (el) { el.classList.add('hidden'); el.classList.remove('flex'); }
}
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>');
}
function renderResearch() {
const el = document.getElementById('tab-research');
if (!el) return;
el.innerHTML =
'<div class="flex gap-2 items-center mb-3">' +
'<button class="rsub-tab-btn px-3 py-1.5 text-sm rounded-lg border border-slate-700 bg-slate-800 text-blue-400" data-rtab="strategy">📊 策略</button>' +
'<button class="rsub-tab-btn px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-400" data-rtab="combo">🧬 组合</button>' +
'<span class="text-xs text-slate-500">策略=版本迭代对比 · 组合=市场阶段分工方案</span>' +
'</div>' +
'<div id="rsub-strategy">' +
'<div id="evo-box" class="mb-3 bg-slate-800/60 border border-slate-700 rounded-lg p-3"><div class="text-xs text-slate-500">🧬 进化模块加载中...</div></div>' +
'<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="strategyList"><div class="text-slate-500 text-sm">加载中...</div></div>' +
'<div id="strategyDetail" class="mt-4"></div>' +
'</div>' +
'<div id="rsub-combo" class="hidden"></div>';
// 子Tab切换
el.querySelectorAll('.rsub-tab-btn').forEach(btn => {
btn.onclick = () => {
el.querySelectorAll('.rsub-tab-btn').forEach(b => { b.classList.remove('bg-slate-800', 'text-blue-400'); b.classList.add('text-slate-400'); });
btn.classList.add('bg-slate-800', 'text-blue-400'); btn.classList.remove('text-slate-400');
const target = btn.dataset.rtab;
el.querySelectorAll('[id^="rsub-"]').forEach(div => div.classList.add('hidden'));
document.getElementById('rsub-' + target).classList.remove('hidden');
if (target === 'combo') renderResearchCombo();
};
});
// 恢复用户上次选择的周期(防自动刷新重置,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() {
const listEl = document.getElementById('strategyList');
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 = await fetch('/api/research/strategies?period_tag=' + pt);
const data = await resp.json();
if (data.error) { listEl.innerHTML = '<div class="text-red-400">' + data.error + '</div>'; return; }
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');
renderStrategyTable(strats);
} catch(e) {
listEl.innerHTML = '<div class="text-red-400">加载失败: ' + e.message + '</div>';
}
}
const ASC_DEFAULT = new Set(['max_dd']); // 回撤类:小=好,默认升序
async function loadEvolutionBox() {
const box = document.getElementById('evo-box');
if (!box) return;
try {
const r = await fetch('/api/evolution/dashboard');
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="loadEvolutionBox()" 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.evolution && d.evolution.length > 0) {
h += '<div class="text-sm text-slate-400 mb-1">🔄 迭代历史:</div>';
for (const e of d.evolution.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>';
}
}
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'))loadEvolutionBox();}).observe(document.body,{attributes:true,subtree:true});
// ══════════════════════════════════════════════════════
// 组合方案子Tab2026-08-02 新增)
// 展示: 当前组合方案(v_next4趋势+v_mr超跌, regime分工) + 组合回测版本 + 组合自我进化
// ══════════════════════════════════════════════════════
async function renderResearchCombo() {
const el = document.getElementById('rsub-combo');
if (!el) return;
el.innerHTML = '<div class="text-slate-500 text-sm">🧬 组合方案加载中...</div>';
try {
const r = await fetch('/api/evolution/combo');
const d = await r.json();
if (d.error) { el.innerHTML = '<div class="text-red-400">' + d.error + '</div>'; return; }
let h = '';
// ── 1. 当前市场阶段 + 组合方案路由卡片 ──
const regime = d.regime || {};
const regName = {trend_up:'📈 趋势市', choppy:'〰️ 震荡市', trend_down:'📉 下跌市'}[regime.regime] || (regime.regime || '未知');
const regColor = regime.regime === 'trend_up' ? 'text-emerald-400' : regime.regime === 'choppy' ? 'text-amber-400' : 'text-red-400';
h += '<div class="bg-slate-800/60 border border-slate-700 rounded-lg p-3 mb-3">' +
'<div class="flex items-center justify-between mb-2">' +
'<span class="text-base font-semibold text-blue-300">🧬 当前组合方案</span>' +
'<span class="text-sm">市场阶段: <span class="' + regColor + ' font-bold">' + regName + '</span>' + (regime.adx ? ' <span class="text-slate-500 text-xs">ADX=' + regime.adx + '</span>' : '') + '</span>' +
'</div>' +
'<div class="text-xs text-slate-400 mb-2">v_next4 趋势追涨 + v_mr 均值回复,按大盘市场阶段自动分工(strategy_lifecycle 6.5 实盘落地)</div>' +
'<div class="space-y-1.5">';
for (const rt of (d.routing || [])) {
const active = regime.regime === rt.regime;
h += '<div class="flex items-center gap-2 text-sm ' + (active ? 'bg-blue-900/30 border border-blue-500/40 rounded px-2 py-1.5' : 'text-slate-500 px-2 py-1') + '">' +
'<span class="w-24">' + ({trend_up:'📈 趋势市', choppy:'〰️ 震荡市', trend_down:'📉 下跌市'}[rt.regime] || rt.regime) + '</span>' +
'<span class="font-mono font-bold ' + (active ? 'text-emerald-400' : '') + '">' + rt.active + '</span>' +
'<span class="text-slate-400 flex-1">' + rt.action + ' · ' + rt.desc + '</span>' +
(active ? '<span class="text-[10px] bg-emerald-500/20 text-emerald-300 px-1.5 py-0.5 rounded">当前生效</span>' : '') +
'</div>';
}
h += '</div></div>';
// ── 2. 组合成员策略指标 ──
const members = d.members || {};
if (Object.keys(members).length) {
h += '<div class="text-sm font-bold text-slate-300 mb-1.5">成员策略(10年回测,组合收益已扣费)</div>' +
'<div class="grid grid-cols-2 gap-2 mb-3">';
for (const [v, m] of Object.entries(members)) {
const u = m.universality || {};
const yearly5 = m.positions_taken_5slot && m.positions_taken_5slot > 0 ? Math.round(m.positions_taken_5slot / 10) : '—';
h += '<div class="bg-slate-800/50 rounded-lg p-3">' +
'<div class="flex items-center justify-between mb-1.5"><span class="font-mono font-bold text-base text-' + (v === 'v_next4' ? 'emerald-400' : 'amber-300') + '">' + v + '</span>' +
'<span class="text-[10px] text-slate-500">' + (m.role || '') + '</span></div>' +
// 盈利率大字突出(用户关注的核心指标)
'<div class="flex items-end gap-4 mb-2">' +
'<div><div class="text-[10px] text-slate-500">单笔均收益(毛利)</div><div class="font-mono font-bold text-lg ' + ((m.avg_profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400') + '">' + (m.avg_profit_pct != null ? m.avg_profit_pct + '%' : '—') + '</div></div>' +
'<div><div class="text-[10px] text-slate-500">组合年化(扣费)</div><div class="font-mono font-bold text-lg text-amber-200">' + (m.cagr_pct != null ? m.cagr_pct + '%' : '—') + '</div></div>' +
'<div><div class="text-[10px] text-slate-500">组合10年收益</div><div class="font-mono font-bold text-lg text-amber-200">' + (m.return_pct != null ? m.return_pct + '%' : '—') + '</div></div>' +
'</div>' +
'<div class="grid grid-cols-4 gap-1 mb-1 text-center text-xs">' +
'<div><div class="text-slate-500 text-[10px]">胜率</div><div class="font-mono">' + (m.win_rate != null ? m.win_rate + '%' : '—') + '</div></div>' +
'<div><div class="text-slate-500 text-[10px]">信号总数</div><div class="font-mono">' + (m.trades != null ? (m.trades > 999 ? Math.round(m.trades/1000) + 'k' : m.trades) : '—') + '</div></div>' +
'<div><div class="text-slate-500 text-[10px]">组合执行</div><div class="font-mono">' + (m.positions_taken_5slot != null ? m.positions_taken_5slot + '笔' : '—') + '</div></div>' +
'<div><div class="text-slate-500 text-[10px]">覆盖月</div><div class="font-mono">' + (u.months || '—') + '</div></div>' +
'</div>' +
'<div class="text-[10px] text-slate-500">均持' + (m.avg_hold_days != null ? m.avg_hold_days + '天' : '—') + ' · 回撤' + (m.max_dd_pct != null ? m.max_dd_pct + '%' : '—') + ' · 组合年均' + yearly5 + '笔(人工可操作)</div>' +
(v === 'v_mr' ? '<div class="text-[10px] text-slate-600 mt-1.5 border-t border-slate-800 pt-1">📋 实盘链路:22k信号 → 组合模拟只执行' + (m.positions_taken_5slot || 0) + '笔 → 每日扫描器只挑最超跌少数候选(2026-08-02首跑仅3只)→ 人工确认建仓。22k是候选池,不是操作量。</div>' : '') +
'</div>';
}
h += '</div>';
}
// ── 3. 组合回测版本列表 ──
const combos = d.combos || [];
h += '<div class="text-sm font-bold text-slate-300 mb-1.5">组合方案版本(回测记录)</div>';
if (combos.length) {
h += '<div class="overflow-x-auto mb-2"><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-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-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-right px-2 py-1">普适</th></tr></thead><tbody>';
for (const c of combos) {
const st = c.summary_stats || {};
const pf = st.portfolio_full || {};
const u = st.universality || {};
const name = c.version === 'v_combo' ? 'v8.1波段+B20动量' : c.version;
h += '<tr class="border-b border-slate-800/40">' +
'<td class="px-2 py-1 font-mono font-bold text-blue-400">' + c.version + '</td>' +
'<td class="px-2 py-1 whitespace-normal break-words">' + name + '</td>' +
'<td class="text-right px-2 py-1 font-mono text-slate-500">' + (c.period_tag || '') + '</td>' +
'<td class="text-right px-2 py-1 font-mono">' + ((pf.positions_taken || 0)) + '/' + (st.total_trades != null ? (st.total_trades > 999 ? Math.round(st.total_trades/1000) + 'k' : st.total_trades) : '—') + '</td>' +
'<td class="text-right px-2 py-1 font-mono">' + (st.win_rate != null ? st.win_rate + '%' : '—') + '</td>' +
'<td class="text-right px-2 py-1 font-mono ' + ((st.avg_profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400') + '">' + (st.avg_profit_pct != null ? st.avg_profit_pct + '%' : '—') + '</td>' +
'<td class="text-right px-2 py-1 font-mono text-amber-200">' + (pf.cagr_pct != null ? pf.cagr_pct + '%' : '—') + '</td>' +
'<td class="text-right px-2 py-1 font-mono">' + (pf.portfolio_max_dd_pct != null ? pf.portfolio_max_dd_pct + '%' : '—') + '</td>' +
'<td class="text-right px-2 py-1 font-mono">' + (u.score != null ? u.score + '/' + (u.months || 0) + '月' : '—') + '</td>' +
'</tr>';
}
h += '</tbody></table></div>';
h += '<div class="text-xs text-slate-600 mb-3">⚠️ v_combo 为历史组合实验:2年+47.9%但5年验证2023/24震荡转折年亏损(B动量族假突破),未采纳——当前组合采用 v_next4+v_mr regime 分工方案</div>';
} else {
h += '<div class="text-xs text-slate-600 mb-3">暂无组合回测记录</div>';
}
// ── 4. 组合自我进化 ──
h += '<div class="bg-slate-800/60 border border-slate-700 rounded-lg p-3 mt-3">' +
'<div class="flex items-center justify-between mb-2"><span class="text-base font-semibold text-emerald-400">🧬 组合方案自我进化</span><button onclick="renderResearchCombo()" class="text-sm text-blue-400 hover:text-blue-300">刷新</button></div>' +
'<div class="text-sm text-slate-400">组合方案 = 策略分工路由(regime → 策略),进化方向:</div>' +
'<ul class="text-sm text-slate-300 mt-1.5 space-y-1">' +
'<li>① 市场阶段判定参数(MA20/ADX 阈值)随实盘验证迭代</li>' +
'<li>② 各成员策略独立进化(v_next4 参数迭代见策略Tab</li>' +
'<li>③ 组合权重/接管时机按回测与实盘偏差调优</li>' +
'</ul>' +
'<div class="text-xs text-slate-500 mt-2">📌 组合级进化记录待接入:当前进化系统只跟踪单策略(strategy_evolution 表),组合路由的自动调优需要新的 evolution 数据表。</div>' +
'</div>';
el.innerHTML = h;
} catch(e) {
el.innerHTML = '<div class="text-red-400">组合方案加载失败: ' + e.message + '</div>';
}
}
function showDesc(version) {
const s = (window._strats || []).find(x => x.version === version);
const d = s && s.description;
if (!d || !d.title) { showToast('暂无说明'); return; }
const modal = document.createElement('div');
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm';
modal.onclick = () => modal.remove();
modal.innerHTML = `
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-2xl w-full mx-4 p-6 shadow-2xl" onclick="event.stopPropagation()">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-bold text-blue-300">${d.title}</h3>
<button onclick="this.closest('.fixed').remove()" class="text-slate-500 hover:text-white text-xl leading-none">&times;</button>
</div>
<div class="space-y-4 text-sm text-slate-300">
<div><div class="text-xs font-semibold text-blue-400 mb-1">算法</div><p class="leading-relaxed">${d.algorithm || ''}</p></div>
<div><div class="text-xs font-semibold text-blue-400 mb-1">思路</div><p class="leading-relaxed">${d.rationale || ''}</p></div>
<div><div class="text-xs font-semibold text-blue-400 mb-1">证据</div><p class="leading-relaxed">${d.evidence || ''}</p></div>
</div>
</div>`;
document.body.appendChild(modal);
}
function showConviction(version) {
const s = (window._strats || []).find(x => x.version === version);
const st = (s && s.summary_stats) || {};
const model = st.conviction_model;
if (!model) { showToast('该策略无信念分级仓位'); return; }
const slots = st.sizing_slots || 10;
const isStack = model.includes('叠乘');
// boost分布(从trades统计在前端没有, 用summary里的说明)
const rules = isStack ? [
['动量基因 DNA', '收缩突破形态(ATR处20日最低1/4位+破20日新高+量比>1.2)', '×2'],
['行业确认牛', '行业板块ADX>25(行业级趋势强化)', '×2'],
['资金加速度', 'flow_delta>0(主力资金净流入在加速)', '×2'],
] : [
['动量基因 DNA', '收缩突破形态(ATR处20日最低1/4位+破20日新高+量比>1.2)', '任一命中 ×2'],
['行业趋势强', '行业板块ADX≥20', '任一命中 ×2'],
['资金加速度', 'flow_delta>0(主力资金净流入在加速)', '任一命中 ×2'],
];
const modal = document.createElement('div');
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm';
modal.onclick = () => modal.remove();
modal.innerHTML = `
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-lg w-full mx-4 p-6 shadow-2xl" onclick="event.stopPropagation()">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-bold text-amber-300">⚡ 信念分级仓位</h3>
<button onclick="this.closest('.fixed').remove()" class="text-slate-500 hover:text-white text-xl leading-none">&times;</button>
</div>
<div class="space-y-3 text-sm text-slate-300">
<div class="bg-slate-800/60 rounded-lg px-3 py-2 font-mono text-xs text-amber-200">${model}</div>
<div class="text-xs text-slate-400">基准 ${slots} 仓(总资金分${slots}份,每份一票)。命中信念因子的票按规则放大单票仓位:</div>
${rules.map(r => `<div class="flex items-start gap-2 border-b border-slate-800 pb-2">
<div class="text-amber-400 font-semibold whitespace-nowrap w-28">${r[0]}</div>
<div class="flex-1 text-xs text-slate-400">${r[1]}</div>
<div class="text-emerald-400 font-mono font-bold">${r[2]}</div>
</div>`).join('')}
<div class="text-xs text-slate-500">${isStack ? '多个因子同时命中则叠乘(如 DNA+行业牛 = ×4),封顶 ×4;均不命中 ×1(基准仓)。' : '多个因子同时命中不叠乘,统一 ×2;均不命中 ×1(基准仓)。'}</div>
</div>
</div>`;
document.body.appendChild(modal);
}
function showToast(msg) {
const t = document.createElement('div');
t.className = 'fixed top-4 right-4 z-50 bg-slate-800 border border-slate-600 rounded-lg px-4 py-2 text-sm text-slate-200 shadow-lg';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2000);
}
function sortBy(key) {
if (window._sortKey === key) {
window._sortDir = window._sortDir === 'desc' ? 'asc' : 'desc';
} else {
window._sortKey = key;
window._sortDir = ASC_DEFAULT.has(key) ? 'asc' : '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 'full_return': return (st.portfolio_full || {}).total_return_pct != null ? st.portfolio_full.total_return_pct : -999;
case 'capital_final': return (st.portfolio_full || pf).capital_final || 0;
case 'cagr': return (st.portfolio_full || pf).cagr_pct != null ? (st.portfolio_full || 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 (st.portfolio_full || pf).portfolio_max_dd_pct != null ? (st.portfolio_full || 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;
}
function renderStrategyTable(strategies) {
const el = document.getElementById('strategyList');
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);
s._confidence = conf;
s._composite = Math.round((ret + wr + sh + pfc + dd) * conf);
}
// 保存数据用于列排序
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 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) => {
const arrow = window._sortKey === key ? (window._sortDir === 'desc' ? '▼' : '▲') : '';
return `<th class="${cls || 'text-right'} px-2 py-1.5 cursor-pointer hover:text-slate-300 select-none" onclick="sortBy('${key}')" title="${title || '点击排序'}">${label}${arrow ? ' ' + arrow : ''}</th>`;
};
let html = '<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') + th('name', '名称', 'text-left') +
th('sizing_slots', '仓位', 'text-center') +
'<th class="text-center px-2 py-1.5">说明</th>' +
th('composite', '综合', 'text-right') +
th('universality', '普适', 'text-right') +
th('full_return', '全参与', 'text-right') +
th('capital_final', '最终资产', 'text-right') +
th('cagr', '年化', 'text-right') +
th('trades', '执行数', 'text-right', '实际参与组合模拟的交易笔数;信号总数=为鼠标悬停查看') +
th('hold', '均持仓', 'text-right') +
th('win_rate', '胜率', 'text-right') +
th('avg_profit', '平均收益', 'text-right') + th('sharpe', '夏普', 'text-right') +
th('profit_factor', '盈亏比', 'text-right') + th('max_dd', '资产回撤', 'text-right') +
'<th class="text-center px-2 py-1.5">操作</th></tr></thead><tbody>';
for (const s of strategies) {
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.version === CURRENT_STRATEGY && (s.market || 'all') !== 'hk';
const rowCls = 'border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer' + (isCurrent ? ' bg-emerald-900/20 border-l-2 border-l-emerald-400' : '');
html += '<tr class="' + rowCls + '" onclick="showStrategyDetail(\'' + s.version + '\')">' +
'<td class="px-2 py-1.5 font-mono font-bold ' + (isCurrent ? 'text-emerald-400' : 'text-blue-400') + '">' + s.version + (isCurrent ? ' <span class="text-[10px] bg-emerald-500/20 text-emerald-300 px-1 rounded">当前</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', s._composite, 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', (st.universality || {}).score, v => v + '<span class="text-slate-500">/' + (st.universality || {}).months + '月</span>') +
cell('full_return_pct', (st.portfolio_full || {}).total_return_pct, v => v + '%' + ((st.portfolio_full||{}).std ? '<span class="text-slate-500 text-[10px]">±' + (st.portfolio_full||{}).std + '</span>' : ''), 'font-bold text-amber-300') +
cell('capital_final', (st.portfolio_full||pf).capital_final, v => '¥' + (v/10000).toFixed(0) + '万') +
cell('cagr_pct', (st.portfolio_full||pf).cagr_pct, v => v + '%', hl((st.portfolio_full||pf).cagr_pct, best.cagr_pct)) +
cell('total_trades', st.total_trades, v => ((st.portfolio_full||{}).positions_taken ?? (pf.positions_taken||0)) + '/' + (v > 999 ? Math.round(v/1000) + 'k' : v), '', false) +
cell('avg_hold_days', st.avg_hold_days, v => v + 'd') +
cell('win_rate', st.win_rate, v => v + '%', hl(st.win_rate, best.win_rate)) +
cell('avg_profit_pct', st.avg_profit_pct, v => v + '%', (st.avg_profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400') +
cell('sharpe_ratio', st.sharpe_ratio, v => v, hl(st.sharpe_ratio, best.sharpe_ratio)) +
cell('profit_factor', st.profit_factor, v => v, hl(st.profit_factor, best.profit_factor)) +
cell('portfolio_max_dd_pct', (st.portfolio_full||pf).portfolio_max_dd_pct, v => v + '%', hl((st.portfolio_full||pf).portfolio_max_dd_pct, 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>';
}
html += '</tbody></table></div>' +
'<div class="text-xs text-slate-600 mt-2">总收益/最终资产 = 100万本金·最多10等分仓位·24个月组合模拟 · ⚠️样本&lt;40笔 · 绿色=该列最优 · 悬停名称查看改进假设</div>';
el.innerHTML = html;
}
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>';
}
}
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;
}
</script>
</body>
</html>