feat: 策略追踪评估系统(版本化+理论盈亏+浮盈/浮亏+自动SL/TP检测+30s刷新)

This commit is contained in:
hmo
2026-07-27 19:03:05 +08:00
parent 12e3abc57d
commit c572bbc3ef
4 changed files with 305 additions and 82 deletions
+90 -78
View File
@@ -612,89 +612,101 @@ function renderMarket() {
}
// ── Evaluation Tab ──
let evalTimer = null;
function renderEvaluation() {
const el = document.getElementById('tab-evaluation');
Promise.all([
fetchJSON('/api/evaluation'),
fetchJSON('/api/stats/accuracy'),
]).then(([evals, stats]) => {
const p1 = stats.phase1 || {};
const p2 = stats.phase2 || {};
if (evalTimer) clearInterval(evalTimer);
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 class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('evaluation','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('evaluation','ai')">§</span></span></span>
<span class="text-xs bg-blue-900/50 text-blue-300 px-2 py-0.5 rounded-full">${evals.length} 只已评估</span>
<button class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 ml-auto" onclick="triggerEval()">🔄 重新评估</button>
<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="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div class="card p-3 text-center">
<div class="text-lg font-bold text-green-400">${p1.correct||0}</div>
<div class="text-xs text-slate-500">阶段一 达标</div>
</div>
<div class="card p-3 text-center">
<div class="text-lg font-bold text-red-400">${p1.wrong||0}</div>
<div class="text-xs text-slate-500">阶段一 止损</div>
</div>
<div class="card p-3 text-center">
<div class="text-lg font-bold text-yellow-400">${p1.pending||0}</div>
<div class="text-xs text-slate-500">待验证</div>
</div>
<div class="card p-3 text-center">
<div class="text-lg font-bold text-white">${p1.accuracy_pct||0}%</div>
<div class="text-xs text-slate-500">准确率</div>
</div>
</div>
<div class="card overflow-hidden">
<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-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>${evals.map(x => {
const adv = (x.advice_evaluation && x.advice_evaluation[0]) || {};
const t = x.theoretical || {};
const a = x.actual || {};
const status = t.status || 'safe';
const icon = status === 'take_profit_hit' ? '🟢' : status === 'stop_loss_hit' ? '🔴' : status === 'in_entry_zone' ? '📥' : '💤';
return `<tr class="stock-row border-b border-slate-800/30">
<td class="p-3"><div class="font-medium text-white">${x.name}</div><div class="text-slate-500">${x.code}</div></td>
<td class="p-3 text-slate-300">${x.current || (x.advice_evaluation && x.advice_evaluation[0] && x.advice_evaluation[0].current_advice || '') || '—'}</td>
<td class="p-3"><span class="${(t.theoretical_pnl_pct||0)>=0?'badge-up':'badge-down'}">${(t.theoretical_pnl_pct||0).toFixed(1)}%</span></td>
<td class="p-3"><span class="${(a.actual_pnl_pct||0)>=0?'badge-up':'badge-down'}">${(a.actual_pnl_pct||0).toFixed(1)}%</span></td>
<td class="p-3">${icon} ${status.replace(/_/g,' ')}</td>
</tr>`;
}).join('')}</tbody>
</table>
</div>
</div>
<div class="text-xs text-slate-600 mt-2">更新时间: ${stats.updated_at?.slice(0,16)||'—'}</div>
`;
// Load feedback
fetchJSON('/api/feedback').then(fb => {
const trend = fb.accuracy_trend || {};
const completed = fb.phase1_completed_count || 0;
const reassess = fb.reassess_needed_count || 0;
if (!completed && !reassess) return;
const fbEl = document.createElement('div');
fbEl.className = 'card p-4 mt-4';
fbEl.innerHTML = `
<div class="flex items-center gap-2 mb-3">
<span class="text-sm font-semibold">🔄 反馈闭环</span>
<span class="text-xs text-slate-500">趋势: ${trend.trend||'stable'}</span>
<span class="text-xs ${completed>0?'text-green-400':'text-slate-500'}">完成 ${completed}</span>
<span class="text-xs ${reassess>0?'text-yellow-400':'text-slate-500'}">重评 ${reassess}</span>
</div>
${(fb.feedback||[]).filter(f => f.adjustments?.length).slice(0,10).map(f => f.adjustments.map(a => `
<div class="flex items-start gap-2 p-2 bg-slate-800/30 rounded-lg mb-1 text-xs">
<span class="${a.type==='phase1_success'?'text-green-400':a.type==='phase1_failure'?'text-red-400':'text-yellow-400'}">${a.type==='phase1_success'?'✅':a.type==='phase1_failure'?'❌':'🔄'}</span>
<span class="text-slate-300">${a.message}</span>
</div>
`).join('')).join('')}
`;
el.appendChild(fbEl);
});
<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>`;
evalTimer = setInterval(renderEvaluation, 30000);
});
}
function triggerEval() {