重评核心重构:ds-v4-pro + 原策略全文 + strategy_history + 前端三改造

后端(重评管线):
- 新增 llm_client.py 共享客户端: REASSESS_MODEL=deepseek-v4-pro 单点,
  gateway预检(fail-fast), 150s超时+1次重试, 永不抛异常
- batch_reassess/per_stock_reassess: curl/urllib -> call_llm,
  prompt传入原策略全文+当前参数+最近3条变更, 输出 维持/修改判断+
  修改点理由+最终新策略, max_tokens 4096
- mofin_db: 新增 strategy_history 表 + snapshot_strategy_history(),
  write_holding_strategy 覆写前自动快照(保留20条/code)
- mofin_db: holding_strategies 补 tag 列迁移 + 写入保留
  (tag缺席=保留旧值, 显式传''=允许清除), 修复推荐标签被静默丢弃
- mo_data.read_decisions: SELECT 补 tag
- stale_detector/promote_candidates: 子进程超时 240/60 -> 480s

前端:
- 移除 报告Tab -> mofin_health 全部流程/Cron 表加 最后十次 列
  (modal列表->详情), /api/reports 支持 cron+script 多路匹配
  (jobs.json name->id 解析 + 文件名/标题子串兜底)
- 移除 决策库Tab
- 盯盘Tab 重构: 全部持仓+自选, sort_group 分组(推荐/持仓/自选),
  推荐行琥珀高亮+🔥badge+行内策略, 新增 操作策略 列查看
  最近3次完整策略(/api/strategy_history/<code>, 表缺失时降级当前行)
- 提示词Tab: registry.py 数据路径改回 /home/hmo/MoFin/data/prompts
  (红线: 数据只在规范数据根), 空态提示初始化命令
This commit is contained in:
hmo
2026-07-20 23:51:24 +08:00
parent 0e13b3edda
commit de9927a627
11 changed files with 1037 additions and 600 deletions
+241 -446
View File
@@ -58,15 +58,13 @@ body { background: #0f0f13; color: #e2e8f0; }
<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" data-tab="reports">📋 报告</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="decisions">📝 决策库</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 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 blink-tab" data-tab="watch" style="color:#f97316;border-color:#f97316">👁️ 盯盘</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>
@@ -74,8 +72,6 @@ body { background: #0f0f13; color: #e2e8f0; }
<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-reports" class="tab-content hidden"></div>
<div id="tab-decisions" 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>
@@ -95,10 +91,24 @@ body { background: #0f0f13; color: #e2e8f0; }
</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>
<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 id="watchContent" class="space-y-2"></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>
@@ -212,8 +222,6 @@ function renderTab(name) {
if (name === 'overview') renderOverview();
else if (name === 'holdings') renderHoldings();
else if (name === 'watchlist') renderWatchlist();
else if (name === 'reports') renderReports();
else if (name === 'decisions') renderDecisions();
else if (name === 'evaluation') renderEvaluation();
else if (name === 'prompts') renderPrompts();
else if (name === 'market') renderMarket();
@@ -437,49 +445,6 @@ function renderWatchlist() {
`;
}
// ── Reports Tab ──
function renderReports() {
const el = document.getElementById('tab-reports');
if (!reportsData.length) {
el.innerHTML = '<div class="card p-8 text-center text-slate-500">暂无报告</div>';
return;
}
el.innerHTML = `
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold">📋 报告列表</span>
<span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('reports','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('reports','ai')">§</span></span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
${reportsData.map(r => `
<div class="card p-4 hover:border-slate-600 transition" onclick="viewReport('${r.id}')">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs px-2 py-0.5 rounded-full ${r.type==='盘中'?'bg-blue-900/50 text-blue-300':r.type==='盘后'?'bg-purple-900/50 text-purple-300':'bg-slate-800 text-slate-400'}">${r.type||'其他'}</span>
<span class="text-xs text-slate-500">${r.created_at?.slice(5,16)||''}</span>
</div>
<div class="text-sm font-medium text-white">${r.title||r.id}</div>
<div class="text-xs text-slate-400 mt-1 line-clamp-2">${r.summary||''}</div>
</div>
`).join('')}
</div>
`;
}
async function viewReport(id) {
const d = await fetchJSON(`/api/report/${id}`);
if (d.error) return;
const el = document.getElementById('tab-reports');
el.innerHTML = `
<div class="card p-6">
<div class="flex items-center gap-2 mb-4">
<button onclick="renderReports()" class="text-xs text-blue-400 hover:text-blue-300">← 返回报告列表</button>
<span class="text-xs text-slate-500">${d.created_at||''}</span>
</div>
<div class="text-lg font-bold text-white mb-4">${d.title||'报告'}</div>
<div class="text-sm text-slate-300 leading-relaxed whitespace-pre-wrap">${d.content||d.summary||'(无内容)'}</div>
</div>
`;
}
// ── Market Tab ──
let marketRefreshInterval = null;
@@ -586,356 +551,6 @@ function renderMarket() {
marketRefreshInterval = setInterval(refreshMarketData, 30000);
}
// ── Decisions Tab ──
let decisionsIsComposing = false;
let decisionsSearchTerm = '';
let decisionsRecommendOnly = false;
let decisionsTagFilter = ''; // '' = all tags
function decisionsFilter() {
if (decisionsIsComposing) return; // 跳过IME输入法组合中的中间值
const el = document.getElementById('decisionsSearch');
if (!el) return;
decisionsSearchTerm = el.value.trim().toLowerCase();
renderDecisionsFiltered();
}
function toggleRecommendFilter() {
decisionsRecommendOnly = !decisionsRecommendOnly;
const btn = document.getElementById('recommendFilterBtn');
if (btn) {
btn.classList.toggle('bg-yellow-900/30', decisionsRecommendOnly);
btn.classList.toggle('border-yellow-400', decisionsRecommendOnly);
btn.textContent = decisionsRecommendOnly ? '⭐ 推荐中' : '⭐ 当前推荐';
}
renderDecisionsFiltered();
}
function decisionsTagFilterSet(tag) {
decisionsTagFilter = (tag === decisionsTagFilter) ? '' : tag;
renderDecisionsFiltered();
// 重新聚焦搜索框
document.getElementById('decisionsSearch')?.focus();
}
/** 计算"距操作区距离"(越小越近),百分比 */
function calcProximity(d) {
const price = d.price || 0;
const sl = d.stop_loss || 0;
const tp = d.take_profit || 0;
const el = d.entry_low || 0;
const eh = d.entry_high || 0;
if (!price) return 999;
let minDist = 999;
// 距止损的距离(百分比)
if (sl > 0) {
const dist = Math.abs((price - sl) / sl) * 100;
minDist = Math.min(minDist, dist);
}
// 距止盈的距离(百分比)
if (tp > 0) {
const dist = Math.abs((tp - price) / price) * 100;
minDist = Math.min(minDist, dist);
}
// 距买入区的距离
if (el > 0 && eh > 0) {
if (price >= el && price <= eh) {
// 在买入区内,距边界距离
const distToLow = (price - el) / (eh - el) * 100;
const distToHigh = (eh - price) / (eh - el) * 100;
minDist = Math.min(minDist, Math.min(distToLow, distToHigh));
} else if (price < el) {
const dist = (el - price) / el * 100;
minDist = Math.min(minDist, dist);
} else {
const dist = (price - eh) / eh * 100;
minDist = Math.min(minDist, dist);
}
}
return minDist;
}
function renderDecisionsFiltered() {
const el = document.getElementById('tab-decisions');
const items = decisionsCache;
const term = decisionsSearchTerm;
const recOnly = decisionsRecommendOnly;
const tagFilter = decisionsTagFilter;
// 搜索过滤
let filtered = term ? items.filter(x => {
const name = (x.name || '').toLowerCase();
const code = (x.code || '').toLowerCase();
const action = (x._raw_action || x.action || '').toLowerCase();
const note = (x.updated_reason || x.note || '').toLowerCase();
return name.includes(term) || code.includes(term) || action.includes(term) || note.includes(term);
}) : items;
// 当前推荐筛选(点击⭐按钮时启用)
if (recOnly) {
filtered = filtered.filter(x => x.tag === 'current_recommend' || x.tag === 'active_manual');
}
// 标签过滤
if (tagFilter) {
filtered = filtered.filter(x => x.tag === tagFilter);
}
// 只显示有策略的条目
const withStrategy = filtered.filter(x => {
const tg = x.trigger || {};
return !!(x.stop_loss || tg.stop_loss || x.take_profit || tg.take_profit || x.entry_low || tg.entry_zone || x._raw_action);
});
// 给每个条目算距离
withStrategy.forEach(x => { x._proximity = calcProximity(x); });
// 分组(按优先级从高到低)
// 1. 执行中(已建仓+部分退出,按距操作区距离排序)
const executing = withStrategy.filter(x => x.execution?.status === 'executing' || x.execution?.status === 'partial_exit')
.sort((a, b) => a._proximity - b._proximity);
// 2. 接近操作区(距操作区 < 5%,不论状态)
const proxThreshold = 5;
const proxKeys = new Set(executing.map(x => x.code));
const nearZone = withStrategy.filter(x =>
!proxKeys.has(x.code) && x._proximity < proxThreshold &&
x.execution?.status !== 'observing'
).sort((a, b) => a._proximity - b._proximity);
nearZone.forEach(x => proxKeys.add(x.code));
// 3. 观察中
const observing = withStrategy.filter(x =>
!proxKeys.has(x.code) && x.execution?.status === 'observing'
).sort((a, b) => a._proximity - b._proximity);
observing.forEach(x => proxKeys.add(x.code));
// 4. 其余
const others = withStrategy.filter(x => !proxKeys.has(x.code))
.sort((a, b) => a._proximity - b._proximity);
// 统计标签
const tagCounts = {};
withStrategy.forEach(x => {
const t = x.tag || '';
if (t) tagCounts[t] = (tagCounts[t] || 0) + 1;
});
const hasTags = Object.keys(tagCounts).length > 0;
const matched = term ? `(搜索"${term}" 匹配 ${filtered.length} 只)` : '';
const tagPills = hasTags ? `
<div class="flex gap-1 flex-wrap mb-3">
<button onclick="decisionsTagFilterSet('')"
class="text-xs px-2 py-1 rounded-full transition ${!tagFilter ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}">
全部
</button>
${Object.entries(tagCounts).map(([tag, count]) => {
const label = tag === 'active_manual' ? '当前推荐操作' :
tag === 'current_recommend' ? '知微推荐' : tag;
const active = tagFilter === tag;
const colors = (tag === 'active_manual' || tag === 'current_recommend')
? (active ? 'bg-amber-500 text-white' : 'bg-amber-900/40 text-amber-300 hover:bg-amber-800/50')
: (active ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700');
return `<button onclick="decisionsTagFilterSet('${tag}')"
class="text-xs px-2 py-1 rounded-full transition ${colors}">
${label} (${count})
</button>`;
}).join('')}
</div>
` : '';
el.innerHTML = `
${tagPills}
<div class="flex items-center gap-2 mb-3 flex-wrap">
<span class="text-sm font-semibold">策略决策库 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('decisions','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('decisions','ai')">§</span></span></span>
<span class="text-xs text-slate-500">· ${withStrategy.length}${matched}</span>
<button id="recommendFilterBtn" onclick="toggleRecommendFilter()"
class="text-xs px-2 py-1 rounded-full border transition ${decisionsRecommendOnly ? 'bg-amber-900/30 border-amber-400 text-amber-300' : 'bg-slate-800 border-slate-700 text-slate-400 hover:bg-slate-700'}">
${decisionsRecommendOnly ? '⭐ 推荐中' : '⭐ 当前推荐'}
</button>
<span class="text-xs text-green-400">${executing.length} 执行中</span>
<span class="text-xs text-orange-400">${nearZone.length} 接近操作</span>
<span class="text-xs text-yellow-400">${observing.length} 观察</span>
<span class="text-xs text-slate-500">${others.length} 其他</span>
</div>
<div class="relative mb-4">
<input id="decisionsSearch" type="text" placeholder="搜索股票名或代码…" value="${term}"
class="w-full px-3 py-2 pl-8 text-xs bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
oninput="if(!this._composing)decisionsFilter()"
oncompositionstart="this._composing=true"
oncompositionend="this._composing=false;decisionsFilter()" />
<span class="absolute left-2.5 top-2 text-slate-500 text-xs">🔍</span>
${term ? `<button onclick="document.getElementById('decisionsSearch').value='';decisionsSearchTerm='';renderDecisionsFiltered()" class="absolute right-2 top-2 text-slate-500 hover:text-white text-xs">&times;</button>` : ''}
</div>
${executing.length > 0 ? `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-green-400 bg-green-900/30 px-2 py-0.5 rounded-full">▶ 正在执行</span>
<span class="text-xs text-slate-500">已建仓 · 距操作区最近优先</span>
</div>
${renderDecCards(executing, true)}
</div>
` : ''}
${nearZone.length > 0 ? `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-orange-400 bg-orange-900/30 px-2 py-0.5 rounded-full">⚡ 接近操作区</span>
<span class="text-xs text-slate-500">距关键位&lt;5%,重点关注</span>
</div>
${renderDecCards(nearZone, true)}
</div>
` : ''}
${observing.length > 0 ? `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-yellow-400 bg-yellow-900/30 px-2 py-0.5 rounded-full">◐ 观察中</span>
<span class="text-xs text-slate-500">自选关注,等待入场时机</span>
</div>
${renderDecCards(observing, false)}
</div>
` : ''}
${others.length > 0 ? `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-slate-400 bg-slate-800/50 px-2 py-0.5 rounded-full">○ 其他</span>
<span class="text-xs text-slate-500">远离操作区</span>
</div>
${renderDecCards(others, false)}
</div>
` : ''}
${withStrategy.length === 0 ? `<div class="card p-8 text-center text-slate-500">${term ? '未匹配到结果' : '暂无带策略的条目'}</div>` : ''}
`;
if (term) document.getElementById('decisionsSearch')?.focus();
}
async function renderDecisions() {
const d = await fetchJSON('/api/decisions');
decisionsCache = d.decisions || [];
renderDecisionsFiltered();
}
function renderDecCards(items, showExecution) {
return `<div class="space-y-2">${items.map(d => {
const exec = d.execution || {};
const analysis = d.analysis || {};
const timeline = d.advice_timeline || d.changelog || [];
// 策略区(兼容新旧字段名)
const tg = d.trigger || {};
const stopLoss = d.stop_loss || tg.stop_loss || '';
const takeProfit = d.take_profit || tg.take_profit || '';
const entryLow = d.entry_low || 0;
const entryHigh = d.entry_high || 0;
const techSnap = d.tech_snapshot || analysis.tech_basis || '';
const timingSignal = d.timing_signal || analysis.timing_signal || '';
const action = d.action || '';
const hasTrigger = !!(stopLoss || (entryLow && entryHigh) || takeProfit);
// 执行区(实际)
const execStatus = exec.status || 'none';
const execPnl = exec.pnl_pct || '';
const execPrice = exec.entry_price || '';
const execShares = exec.shares || '';
const execNotes = exec.notes || '';
return `<div class="card p-4">
<div class="flex items-start justify-between mb-2">
<div>
<span class="font-medium text-white">${d.name}</span>
<span class="text-xs text-slate-500 ml-2">${d.code}</span>
<span class="text-xs px-1.5 py-0.5 rounded ml-2 ${d.type?.includes('持仓')?'bg-blue-900/50 text-blue-300':'bg-purple-900/50 text-purple-300'}">${d.type||'—'}</span>
${execStatus === 'executing' ? '<span class="text-xs px-1.5 py-0.5 rounded bg-green-900/50 text-green-300 ml-1">已执行</span>' : ''}
${execStatus === 'partial_exit' ? '<span class="text-xs px-1.5 py-0.5 rounded bg-orange-900/50 text-orange-300 ml-1">部分退出</span>' : ''}
${execStatus === 'observing' ? '<span class="text-xs px-1.5 py-0.5 rounded bg-yellow-900/50 text-yellow-300 ml-1">观察中</span>' : ''}
${d.tag === 'active_manual' || d.tag === 'current_recommend' ? (() => {
let label = '⭐ ';
if (d.tag === 'active_manual') {
if (execStatus === 'executing') label += '执行中 持有';
else if (execStatus === 'partial_exit') label += '执行中 部分退出';
else label += '待买入';
} else {
if (execStatus === 'partial_exit') label += '部分退出 剩余半仓';
else if (execStatus === 'observing') label += '推荐买入';
else label += '推荐操作';
}
return `<span class="text-xs px-1.5 py-0.5 rounded bg-amber-900/50 text-amber-300 ml-1 cursor-pointer" onclick="decisionsTagFilterSet('${d.tag}')">${label}</span>`;
})() : ''}
</div>
<span class="text-xs text-slate-500">${d.timestamp?.slice(5,16)||''}</span>
</div>
<!-- 双维度策略 v&#115; 执行 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-2">
<!-- 策略理论 -->
<div class="bg-blue-900/10 rounded-lg p-3 border border-blue-900/30">
<div class="text-xs font-semibold text-blue-400 mb-1">📐 策略理论</div>
${hasTrigger ? `
<div class="grid grid-cols-3 gap-1 text-xs">
${stopLoss ? `<div><span class="text-red-400">止损</span> ${stopLoss}</div>` : ''}
${entryLow ? `<div><span class="text-yellow-400">买入区</span> ${entryLow}~${entryHigh}</div>` : tg.entry_zone ? `<div><span class="text-yellow-400">买入区</span> ${tg.entry_zone}</div>` : ''}
${takeProfit ? `<div><span class="text-green-400">止盈</span> ${takeProfit}</div>` : ''}
</div>
${action ? `<div class="text-xs text-slate-300 mt-1 font-medium">${action}</div>` : ''}
${analysis.reasoning ? `<div class="text-xs text-slate-400 mt-1">${analysis.reasoning.slice(0,100)}</div>` : ''}
${timingSignal && !timingSignal.includes('neutral') ? `<div class="text-xs text-yellow-400 mt-1">⏱ ${timingSignal}</div>` : ''}
` : `<div class="text-xs text-slate-500">无策略</div>`}
</div>
<!-- 执行实际 -->
<div class="bg-green-900/10 rounded-lg p-3 border border-green-900/30">
<div class="text-xs font-semibold text-green-400 mb-1"> 实际执行</div>
${execStatus === 'executing' || execStatus === 'partial_exit' ? `
<div class="text-xs">
<div class="flex justify-between"><span class="text-slate-400">建仓价</span><span class="font-mono text-white">¥${execPrice||'?'}</span></div>
<div class="flex justify-between"><span class="text-slate-400">持仓</span><span class="font-mono text-white">${execShares||'?'}股</span></div>
<div class="flex justify-between"><span class="text-slate-400">盈亏</span><span class="font-mono ${(execPnl||'').includes('-')?'text-red-400':'text-green-400'}">${execPnl||'?'}</span></div>
${exec.remaining_shares ? `<div class="flex justify-between"><span class="text-slate-400">剩余</span><span class="font-mono text-white">${exec.remaining_shares}股</span></div>` : ''}
${exec.realized_gain ? `<div class="flex justify-between"><span class="text-slate-400">已实现</span><span class="font-mono text-green-400">+${exec.realized_gain}</span></div>` : ''}
${exec.notes ? `<div class="text-slate-400 mt-1">${exec.notes}</div>` : ''}
</div>
` : execStatus === 'observing' ? `
<div class="text-xs text-slate-400">观察中,等待入场时机</div>
<div class="text-xs text-slate-500 mt-1">${execNotes||'关注回调至买入区'}</div>
` : `
<div class="text-xs text-slate-500">尚未执行</div>
`}
</div>
</div>
<!-- 建议记录 -->
${timeline.length ? `
<div class="border-t border-slate-800/50 pt-2 mt-1">
<div class="text-xs text-slate-500 mb-1">📜 建议记录 (${timeline.length})</div>
<div class="space-y-1">
${timeline.slice(-3).map(a =>
`<div class="text-xs text-slate-400 pl-2 border-l-2 border-blue-500/50 flex items-center gap-1">
<span class="text-slate-500">${a.date?.slice(5,16)||''}</span>
<span class="${a.direction==='买入'?'text-green-400':a.direction==='卖出'?'text-red-400':'text-slate-300'}">${a.direction||''}</span>
<span>${a.summary?.slice(0,40)||''}</span>
${a.status === 'pending' ? '<span class="text-yellow-500">⏳</span>' : a.status === 'confirmed' ? '<span class="text-green-500">✓</span>' : a.status === 'ignored' ? '<span class="text-slate-600">✗</span>' : ''}
</div>`
).join('')}
</div>
</div>
` : ''}
${d.updated_reason ? `<div class="text-xs text-slate-500 mt-1">📝 ${d.updated_reason}</div>` : ''}
</div>`;
}).join('')}</div>`;
}
// ── Evaluation Tab ──
function renderEvaluation() {
const el = document.getElementById('tab-evaluation');
@@ -1127,7 +742,11 @@ function renderPrompts() {
// 提示词列表
if (prompts.length === 0) {
html += '<div class="card p-6 text-center text-slate-500">暂无提示词记录,请先初始化注册表。</div>';
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>';
@@ -1161,7 +780,11 @@ function renderPrompts() {
// 保存数据供版本弹窗使用
window._promptData = data;
}).catch(err => {
el.innerHTML = `<div class="card p-6 text-center text-red-400">加载失败: ${err.message}</div>`;
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>';
});
}
@@ -1710,68 +1333,240 @@ async function refreshHealth() {
// ── 盯盘 ──
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) lines.push('买入区间: ' + (s.entry_low||'—') + '~' + (s.entry_high||'—'));
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');
}
async function renderWatch() {
const el = document.getElementById('watchContent');
if (!el) return;
try {
// 清除旧定时器,切走时停
if (watchTimer) clearInterval(watchTimer);
const data = await fetchJSON('/api/watch');
const stocks = data.stocks || [];
document.getElementById('watchCount').textContent = stocks.length;
document.getElementById('watchRefreshTime').textContent = new Date().toLocaleTimeString();
if (stocks.length === 0) {
el.innerHTML = '<div class="card p-6 text-center text-slate-500 text-sm">暂无有效操作建议</div>';
el.innerHTML = '<div class="p-6 text-center text-slate-500 text-sm">暂无策略数据</div>';
return;
}
let html = '';
for (const s of stocks) {
const p = s.price || 0;
const cp = s.change_pct || 0;
const sig = s.timing_signal || '';
const isBuy = sig.includes('买入') || sig.includes('加仓');
const isSell = sig.includes('卖出') || sig.includes('止盈');
const sigColor = isBuy ? 'bg-green-900/50 text-green-300' : isSell ? 'bg-red-900/50 text-red-300' : 'bg-yellow-900/50 text-yellow-300';
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 inZone = (p && el_ && eh_ && p >= el_ && p <= eh_) ? '✅区内' : (p && eh_ && p > eh_) ? '⬆️超出' : '⬇️低于';
html += `
<div class="card p-4 flex items-center gap-3" onclick="openStock('${s.code}')" style="cursor:pointer">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold text-sm text-slate-200">${s.code}</span>
<span class="text-slate-400 text-xs">${s.name || ''}</span>
<span class="text-xs px-1.5 py-0.5 rounded ${sigColor}">${sig}</span>
<span class="text-xs text-slate-500">${s.decision_type==='持仓策略'?'💼':'⭐'}</span>
</div>
<div class="flex items-center gap-3 mt-1 text-xs text-slate-400">
<span class="font-mono">${p.toFixed(2)}</span>
<span class="font-mono ${chgColor}">${cp >= 0 ? '+' : ''}${cp.toFixed(2)}%</span>
<span>区${el_.toFixed(1)}~${eh_.toFixed(1)}</span>
<span>${inZone}</span>
<span>RR ${rr.toFixed(2)}</span>
<span>损${sl_.toFixed(1)}${tp_.toFixed(1)}</span>
</div>
</div>
<div class="text-right text-xs">
<div class="text-slate-400">仓位</div>
<div class="text-yellow-400 font-mono">${s.position_advice || '—'}</div>
</div>
</div>`;
// 分组渲染
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-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;
// 分组标题行
html += '<tr class="border-b border-slate-700">' +
'<td colspan="10" class="p-2 font-semibold 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 shares = s.shares || 0;
const posPct = s.position_pct || 0;
const buyZone = (el_ && eh_) ? el_.toFixed(1) + '~' + eh_.toFixed(1) : '—';
const sltp = (sl_ || tp_) ? '损' + (sl_||'—') + '/盈' + (tp_||'—') : '—';
const posDisplay = 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';
const recBadge = isRec ? '<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-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) : '—') + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + posDisplay + '</td>' +
'<td class="p-2 ' + stratCls + ' max-w-[200px] break-all line-clamp-2">' + 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>';
el.innerHTML = html;
// 自动刷新:每30秒
watchTimer = setInterval(renderWatch, 30000);
} catch(e) {
el.innerHTML = `<div class="text-red-400 text-sm">加载失败: ${e.message}</div>`;
el.innerHTML = '<div class="p-6 text-red-400 text-sm">加载失败: ' + e.message + '</div>';
watchTimer = setInterval(renderWatch, 60000);
}
}
// ── 操作策略历史 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) params.push('买入区间: ' + (h.entry_low||'—') + '~' + (h.entry_high||'—'));
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() {