Files
Toolbox/public/tools/markets/markets_dip_eval.js
T
yangxiangyuan 6a7a2b089a feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑
1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置
2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议
3. 新增推送告警白名单过滤功能,限制推送指定指数告警
4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求
5. 新增多套调试探针脚本与单元测试用例
2026-08-04 17:33:14 +08:00

112 lines
5.4 KiB
JavaScript
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.
/**
* ===== 建行定投评估(前端逻辑) =====
* 银行场外基金定投/赎回参考:点击「开始评估」按需抓取四大指数 PE-TTM 与近10年分位,无定时任务。
*
* 档位阈值与后端 src/server/markets_dip_eval.js 保持一致:
* 沪深300:PE<12 定投200元/日 | 12~14.5 定投150元/日 | 14.5~16 定投100元/日·可止盈50% | PE>16 暂停定投+止盈清仓
* 中证500:PE<25 定投200元/日 | 25~32 定投150元/日 | 32~36 定投100元/日·可止盈50% | PE>36 暂停定投+止盈清仓
* 纳指100:PE<24 定投200元/日 | 24~30 定投150元/日 | 30~36 定投100元/日·可止盈50% | PE>36 暂停定投+止盈清仓
* 标普500:PE<16 定投200元/日 | 16~21 定投150元/日 | 21~25 定投100元/日·可止盈50% | PE>25 暂停定投+止盈清仓
*
* 依赖 markets.js 提供的全局函数:el / runTrackedInteraction / setButtonBusy
*/
// 档位 → 徽章样式(绿=定投加仓、蓝=轻定投、黄=可止盈一半、红=暂停+清仓)
const DIP_LEVEL_STYLE = {
buy_heavy: { bg: '#dcfce7', color: '#15803d', label: '低估档' },
buy: { bg: '#dcfce7', color: '#16a34a', label: '定投档' },
buy_light: { bg: '#e0f2fe', color: '#0369a1', label: '轻定投档' },
take_half: { bg: '#fef3c7', color: '#b45309', label: '止盈档' },
exit: { bg: '#fee2e2', color: '#b91c1c', label: '清仓档' },
unknown: { bg: '#f1f5f9', color: '#64748b', label: '未知' }
}
// HTML 转义,防止数据源返回内容注入
const dipEscapeHtml = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]))
// 渲染评估结果表格
const renderDipReport = data => {
const list = el('dip-list')
const status = el('dip-status')
const errorsBox = el('dip-errors')
if (!list) return
const rows = Array.isArray(data.rows) ? data.rows : []
if (!rows.length) {
list.innerHTML = '<tr><td colspan="8" class="muted" style="text-align:center;padding:30px">未获取到有效数据,请查看下方异常提示</td></tr>'
} else {
list.innerHTML = rows.map(r => {
const st = DIP_LEVEL_STYLE[r.level] || DIP_LEVEL_STYLE.unknown
const pe = Number.isFinite(r.pe_ttm) ? r.pe_ttm.toFixed(2) : '—'
const pct = Number.isFinite(r.percentile) ? `${r.percentile.toFixed(1)}%` : '—'
const note = r.note ? `<div style="margin-top:6px;font-size:12px;color:#b45309;line-height:1.6">⚠ ${dipEscapeHtml(r.note)}</div>` : ''
return `<tr>
<td><b>${dipEscapeHtml(r.name)}</b></td>
<td>${dipEscapeHtml(r.code)}</td>
<td>${dipEscapeHtml(r.market || '—')}</td>
<td style="font-weight:700">${pe}</td>
<td title="${dipEscapeHtml(r.percentile_note || '')}">${pct}</td>
<td>${dipEscapeHtml(r.pe_date || '—')}</td>
<td>${dipEscapeHtml(r.source || '—')}</td>
<td>
<span style="display:inline-block;padding:3px 10px;border-radius:999px;background:${st.bg};color:${st.color};font-weight:700;font-size:13px">${st.label}:${dipEscapeHtml(r.advice || '—')}</span>
${note}
</td>
</tr>`
}).join('')
}
if (status) {
status.style.display = ''
status.textContent = `评估时间 ${data.captured_at || '—'} | 指数 ${rows.length} 个 | 异常 ${(Array.isArray(data.errors) ? data.errors.length : 0)} 条`
}
// 异常提示区:单指数失败不影响其他指数展示
if (errorsBox) {
const errs = Array.isArray(data.errors) ? data.errors : []
if (errs.length) {
errorsBox.style.display = ''
errorsBox.innerHTML = errs.map(e => `<div style="font-size:13px;color:#b91c1c;line-height:1.9">⚠ ${dipEscapeHtml(e)}</div>`).join('')
} else {
errorsBox.style.display = 'none'
errorsBox.innerHTML = ''
}
}
}
// 点击「开始评估」:走 markets 统一交互详情弹窗(双模式由 runTrackedInteraction 内部处理)
const runDipEval = async () => {
const btn = el('dip-btn-run')
try {
const data = await runTrackedInteraction({
title: '建行定投评估',
action: '抓取四大指数 PE-TTM 与近10年分位并判档',
summary: '按需抓取:中证指数官网 / 蛋卷基金(备源 multpl.com)...',
button: btn,
loadingText: '评估中…',
request: { url: '/api/markets/dip_eval', method: 'GET' },
successText: d => `评估完成:${(d && Array.isArray(d.rows)) ? d.rows.length : 0} 个指数,${((d && d.errors) || []).length} 条异常`
}, async ctx => {
return await ctx.fetchJson('/api/markets/dip_eval', {}, { 说明: '按需抓取,无定时任务' })
})
renderDipReport(data || {})
} catch (e) {
// 交互弹窗已展示错误详情(异常时窗口停留),这里给表格区一个兜底提示
const list = el('dip-list')
if (list) {
list.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:30px;color:#b91c1c">评估失败:${dipEscapeHtml(String((e && e.message) || e))}(网络波动或数据源不可用,可稍后重试)</td></tr>`
}
const status = el('dip-status')
if (status) status.style.display = 'none'
const errorsBox = el('dip-errors')
if (errorsBox) { errorsBox.style.display = 'none'; errorsBox.innerHTML = '' }
}
}
document.addEventListener('DOMContentLoaded', () => {
const btn = el('dip-btn-run')
if (btn) btn.addEventListener('click', runDipEval)
})