v1.4.0: 持仓跟踪智能体(产业链上下游深度分析) - 定期跟踪自选股/持仓, RAG采集个股+上游+下游+同业动态, LLM输出产业链深度分析+影响度判定, 重大变化邮件通知, 跟踪页+设置项

This commit is contained in:
2026-08-19 23:26:30 +08:00
parent 8e2e4f0335
commit eaa40bd26b
11 changed files with 956 additions and 4 deletions
+20
View File
@@ -25,6 +25,13 @@ async function loadSettings() {
const cats = mono.categories || [];
$$('#monitorCats input').forEach(c => c.checked = cats.includes(c.value));
renderState(curSettings.state);
// 持仓跟踪
const tr = curSettings.tracking || {};
$('#trackingEnabled').checked = !!tr.enabled;
$('#trackingInterval').value = tr.interval_min;
$('#trackingImpact').value = tr.impact_threshold;
$('#trackingNotify').checked = !!tr.notify;
renderTrackingState(tr);
loadLog();
} catch (e) {
toast('设置加载失败');
@@ -38,6 +45,13 @@ function renderState(state) {
<div class="mini-stat"><div class="ms-label">上次通知</div><div class="ms-value num">${state.last_sent_count || 0} 条</div></div>`;
}
function renderTrackingState(tr) {
$('#trackingState').innerHTML = `
<div class="mini-stat"><div class="ms-label">上次运行</div><div class="ms-value">${tr.last_run || '—'}</div></div>
<div class="mini-stat"><div class="ms-label">最近跟踪</div><div class="ms-value">${tr.last_stock || '—'}</div></div>
<div class="mini-stat"><div class="ms-label">最近告警影响度</div><div class="ms-value num">${tr.last_alert ? tr.last_alert + '/100' : '—'}</div></div>`;
}
function collectMail() {
return {
smtp_host: $('#smtpHost').value.trim(), smtp_port: $('#smtpPort').value,
@@ -69,6 +83,12 @@ async function save(which) {
llm_model: $('#llmModel').value.trim()
};
if (which === 'monitor') body.monitor = collectMonitor();
if (which === 'tracking') body.tracking = {
tracking_enabled: $('#trackingEnabled').checked,
tracking_interval: $('#trackingInterval').value,
tracking_impact_threshold: $('#trackingImpact').value,
tracking_notify: $('#trackingNotify').checked
};
const r = await api('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
toast(r.msg || '已保存');
} catch (e) { toast('保存失败:' + e.message); }
+143
View File
@@ -0,0 +1,143 @@
/* 持仓跟踪智能体 */
async function load() {
try {
const d = await api('/api/tracking');
const items = d.items || [];
renderRecords(items);
loadPositions(items);
} catch (e) {
$('#recTb').innerHTML = '<tr><td colspan="7" class="empty">加载失败</td></tr>';
}
loadState();
}
function loadState() {
api('/api/settings').then(d => {
const t = d.tracking || {};
$('#trackState').innerHTML = `
<div class="kpi"><div class="kpi-label">自动跟踪</div><div class="kpi-value" style="font-size:15px">${t.enabled ? '✅ 开启' : '⏸ 关闭'}<span style="color:var(--muted);font-size:12px">(每 ${t.interval_min} 分钟)</span></div></div>
<div class="kpi"><div class="kpi-label">上次运行</div><div class="kpi-value" style="font-size:15px">${t.last_run || '—'}</div></div>
<div class="kpi"><div class="kpi-label">最近跟踪标的</div><div class="kpi-value" style="font-size:15px">${t.last_stock || '—'}</div></div>
<div class="kpi"><div class="kpi-label">最近告警影响度</div><div class="kpi-value" style="font-size:15px">${t.last_alert ? t.last_alert + '/100' : '—'}</div></div>
<div class="kpi"><div class="kpi-label">重大判定阈值</div><div class="kpi-value" style="font-size:15px">≥ ${t.impact_threshold}</div></div>`;
}).catch(() => {});
}
async function loadPositions(all) {
try {
const w = await api('/api/watchlist');
const pos = w.items || [];
if (!pos.length) {
$('#posBox').innerHTML = '<div class="empty">自选股为空。请先在 <a href="/stocks">股票池</a> 或个股详情页 ⭐ 添加持仓/关注标的</div>';
return;
}
// 每只自选股取最近一条跟踪
const rows = [];
for (const p of pos) {
const latest = all.find(r => r.code === p.code);
rows.push({ stock: p, latest });
}
$('#posBox').innerHTML = `<table>
<tr><th>股票</th><th>现价</th><th>最近跟踪</th><th>影响度</th><th>性质</th><th>摘要</th><th>操作</th></tr>
${rows.map(x => {
const p = x.stock, r = x.latest;
const meta = r ? (JSON.parse(r.meta || '{}')) : null;
const sigCls = meta ? (meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注') : 'tag-观望';
return `<tr>
<td><b>${p.name}</b><div style="color:var(--muted);font-size:12px">${p.code} · ${p.industry}</div></td>
<td class="num">${p.close}</td>
<td style="color:var(--muted)">${r ? r.created_at : '—'}</td>
<td>${r && meta ? `<span class="tag ${sigCls}">${meta.impact_score}</span>` : '<span style="color:var(--muted)">未跟踪</span>'}</td>
<td>${meta ? `<span class="tag tag-${meta.change_kind === '利好' ? '强烈推荐' : meta.change_kind === '利空' ? '负' : '关注'}">${meta.change_kind}</span>` : '—'}</td>
<td style="max-width:300px;white-space:normal;color:var(--text2)">${meta ? escapeHtml(meta.summary || '') : '—'}</td>
<td><div class="flex">
<button class="btn btn-primary" onclick="runOne('${p.code}')">跟踪</button>
${r ? `<button class="btn" onclick="showDetail(${r.id})">详情</button>` : ''}
</div></td>
</tr>`;
}).join('')}
</table>`;
} catch (e) {
$('#posBox').innerHTML = '<div class="empty">加载失败</div>';
}
}
function renderRecords(items) {
if (!items.length) return;
$('#recTb').innerHTML = items.map(r => {
let meta = {};
try { meta = JSON.parse(r.meta || '{}'); } catch (e) {}
const sigCls = meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注';
return `<tr class="row-link" onclick="showDetail(${r.id})">
<td style="color:var(--muted)">${r.created_at}</td>
<td><b>${r.stock_name}</b><div style="color:var(--muted);font-size:12px">${r.code}</div></td>
<td style="color:var(--text2)">${escapeHtml(r.industry)}</td>
<td><span class="tag ${sigCls}">${meta.impact_score || '—'}</span></td>
<td><span class="tag tag-${meta.change_kind === '利好' ? '强烈推荐' : meta.change_kind === '利空' ? '负' : '关注'}">${meta.change_kind || '—'}</span></td>
<td style="max-width:320px;white-space:normal;color:var(--text2)">${escapeHtml(meta.summary || '')}</td>
<td><button class="btn" onclick="event.stopPropagation();showDetail(${r.id})">查看报告 ↗</button></td>
</tr>`;
}).join('');
}
async function runAll() {
if (!confirm('将对全部自选股执行一次产业链深度跟踪(每只约30-60秒,逐只进行),确定?')) return;
try {
const r = await api('/api/tracking/run', { method: 'POST' });
toast(r.msg || '已启动');
pollRefresh();
} catch (e) { toast('启动失败:' + e.message); }
}
async function runOne(code) {
try {
await api('/api/tracking/run/' + code, { method: 'POST' });
toast('跟踪已启动,完成后自动刷新');
setTimeout(() => { load(); toast('跟踪完成'); }, 80000);
} catch (e) { toast('启动失败:' + e.message); }
}
/* 详情弹窗:报告 + 数据源 */
async function showDetail(id) {
try {
const d = await api('/api/tracking/' + id);
const meta = d.meta || {}, src = d.sources || {};
const sigCls = meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注';
const newsBlock = (label, icon, seg) => {
if (!seg) return '';
const txt = ((seg.db || '') + (seg.rag || '')).trim();
return `<details><summary>${icon} ${label} <span class="src-count">${txt.includes('[') ? txt.split('[').length - 1 : 0}</span></summary>
<div class="src-body"><pre class="src-pre">${escapeHtml(txt || '暂无')}</pre></div></details>`;
};
openModal(`
<h3>🧭 产业链跟踪:${d.stock_name}${d.code}</h3>
<div class="news-meta" style="margin-bottom:12px">
<span class="cat-tag">${escapeHtml(d.industry)}</span>
<span class="tag ${sigCls}">影响度 ${meta.impact_score || '—'}/100</span>
<span class="tag tag-${meta.change_kind === '利好' ? '强烈推荐' : meta.change_kind === '利空' ? '负' : '关注'}">${meta.change_kind || '—'}</span>
<span style="color:var(--muted)">${d.created_at}</span>
</div>
<div class="markdown-body">${mdRender(d.report)}</div>
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:10px">
<b style="color:var(--gold)">数据源(智能体参考内容)</b>
${newsBlock('个股直接动态', '📄', src.direct)}
${newsBlock('上游产业链(供给/成本)', '⬆️', src.upstream)}
${newsBlock('下游产业链(需求/景气)', '⬇️', src.downstream)}
${newsBlock('同业竞争', '🏢', src.peers)}
<details><summary>📈 技术面 / 机构动向</summary><div class="src-body"><pre class="src-pre">${escapeHtml(src.indicators || '')}</pre></div></details>
</div>
<div style="margin-top:14px;text-align:right">
<button class="btn" onclick="document.querySelector('.modal-mask').classList.remove('show')">关闭</button>
<a class="btn btn-primary" href="/stock/${d.code}" target="_blank">查看个股 →</a>
</div>
`);
} catch (e) { toast('加载失败'); }
}
let pollTimer = null;
function pollRefresh() {
clearTimeout(pollTimer);
pollTimer = setTimeout(() => load(), 30000);
}
load();