Files
group_fqcd_jr/app/static/portal/common/advisor-plan-view.js
T
Windows 74b7d00dff feat(投顾): 方案交付落点、可视化图表与推荐依据的大模型增强
交付落点
- 新增 GET /api/v1/users/me/advisor-contents(客户读**自己**已发布方案):
  「发送给客户」原先只改数据状态、客户端没有任何页面或接口能读到它
- 客户端新增「我的投顾方案」页与导航入口

可视化(投顾结果区与客户页**共用** common/advisor-plan-view.js,避免两处漂移)
- 净值折线图(带坐标轴与网格)、组合业绩等权合成曲线(含区间收益与最大回撤)、
  资产配置环形图与图例、组合构成条
- 修 num(null)=0 的假 0:Number(null)/Number('') 会得 0,导致「没数据」被渲染成 0.00%;
  现一律显示「--」。同理管理费/起投未维护时按没数据处理,不显示 0
- 涨跌口径为「涨红跌绿」(A 股习惯),由 CSS 变量 --plan-up / --plan-down 集中定义

推荐依据接入大模型(可选,失败即回退)
- 新增 AdvisorReasonService:**只改文案,不参与选品**(候选池与排序在它之前已固定)
- 输入只允许是已算出的真实参数(风险等级、排序得分、区间收益、最大回撤、期限与流动性)
- 命中收益承诺词(保本/保证收益/稳赚/无风险…)整条丢弃并回退规则文案
- 未启用 / 缺密钥 / 超时 / 解析失败一律回退,推荐主流程不因模型不可用而失败
- 前端标注来源(AI 生成 / 规则生成)

数据与权限
- 客户角色补齐:绑 customer 角色、补建缺失的账户与交易段权限码(9060-9065)
- 净值全量同步(20 只产品),行情同步脚本按 --codes 分块(全量一次会被超时终止)

测试
- 新增 tests/unit/service/test_advisor_reason_service.py(10 项,专测三条合规边界)
- 前端模块自检纳入 service-request-module;补「两处共用同一渲染」回归测试
2026-09-16 18:17:47 +08:00

398 lines
17 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.
// 投顾推荐方案的「可视化」渲染 —— 投顾工作台结果区与客户「我的投顾方案」页**共用**。
//
// 为什么单独抽一个模块:这两处展示的是同一份 `advisor_recommendation_plan`,
// 各写一套必然漂移(改了一边忘了另一边)。本模块保持**纯渲染 + 一次注解式取数**:
// · `renderPlanProducts(products)` —— 同步产出骨架(只用方案自带字段);
// · `hydratePlanView(root, apiClient, products)` —— 取行情/净值后填图表与指标。
//
// ⚠️ 不 import `api-client.js`:投顾页与客户页引入的 `apiClient` 版本不同,
// 在共享模块里再 import 一份会多出一个实例(`ENDPOINTS` 注册表也就有了两份)。
// 所以 apiClient 由调用方当参数传进来(依赖注入)。
//
// 数据来源(都已在 `api-client.js` 注册):
// · P001 `GET /api/v1/products` → 最新净值/当日涨跌/基金经理/费率
// · P002 `GET /api/v1/products/{code}/nav-history` → 近 N 个交易日净值序列(画折线)
//
// 颜色口径:**涨红跌绿**(A 股习惯)。样式里用 `--plan-up` / `--plan-down` 两个变量
// 集中定义(`common/advisor-plan.css`),要与产品列表页的“绿涨红跌”一致时对调即可。
import { escapeHtml } from '/static/portal/common/formatters.js';
//: 从净值序列里取最近多少个交易日画走势。
const SPARK_DAYS = 180;
//: 卡片上展示的区间涨跌(交易日数)。
const INTERVALS = Object.freeze([
['近 20 日', 20],
['近 60 日', 60],
]);
//: 环形图配色(最多 6 色循环)。用固定色值而不是主题变量:环形切片需要彼此可区分。
const SLICE_COLORS = Object.freeze([
'#2d6f67', '#c99a2e', '#4a7fb5', '#a6573f', '#7a6bab', '#5f8f52',
]);
/**
* ⚠️ 这里必须显式挡 `null` / `undefined` / `''`。
* 直接 `Number(null)` 会得到 **0** —— 于是"没有数据"被渲染成 `0.00%`(曾真出过这个 bug:
* 近 20/60 日与管理费全显示 0.00%,看起来像"真的是 0")。`Number('')` 同理。
*/
function num(value) {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
/** 涨跌幅文本;拿不到就 `--`,绝不渲染成 `+0.00%`(会把"不知道"说成"平盘")。 */
function pctText(value) {
const parsed = num(value);
if (parsed === null) return '--';
return `${parsed > 0 ? '+' : ''}${parsed.toFixed(2)}%`;
}
/** 涨跌配色类;`null` 用中性色。 */
function pctClass(value) {
const parsed = num(value);
if (parsed === null) return 'advisor-plan__value--flat';
if (parsed > 0) return 'advisor-plan__value--up';
if (parsed < 0) return 'advisor-plan__value--down';
return 'advisor-plan__value--flat';
}
function navValues(points) {
return points.map((point) => num(point.nav)).filter((value) => value !== null);
}
function intervalChange(values, tradingDays) {
if (values.length < 2) return null;
const baseIndex = Math.max(0, values.length - 1 - tradingDays);
const base = values[baseIndex];
if (!base) return null;
return ((values[values.length - 1] - base) / base) * 100;
}
function metric(label, value, className = '') {
return `<div class="advisor-plan__metric"><dt>${escapeHtml(label)}</dt>`
+ `<dd class="${className}">${escapeHtml(value)}</dd></div>`;
}
// ---- 折线图(带坐标轴/网格;内联 SVG,不引图表库) ----
function shortDate(iso) {
return String(iso || '').slice(5);
}
/**
* 通用折线图。`series` 是 `[{date, value}]`;`formatter` 决定 y 轴刻度文本。
* 缺数据(<2 点)返回空串,由调用方给"暂无数据"文案。
*/
function lineChartSvg(series, { formatter, ariaLabel }) {
if (series.length < 2) return '';
const width = 640;
const height = 220;
const left = 58;
const right = 16;
const top = 14;
const bottom = 30;
const values = series.map((point) => point.value);
let min = Math.min(...values);
let max = Math.max(...values);
if (min === max) {
min -= 0.01;
max += 0.01;
}
const padding = (max - min) * 0.08;
min -= padding;
max += padding;
const xAt = (index) => left + (index * (width - left - right)) / (series.length - 1);
const yAt = (value) => top + (1 - (value - min) / (max - min)) * (height - top - bottom);
const rows = 4;
const grid = Array.from({ length: rows + 1 }, (_, index) => {
const value = min + ((max - min) * index) / rows;
const y = yAt(value).toFixed(1);
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}"/>`
+ `<text x="${left - 8}" y="${(Number(y) + 3.5).toFixed(1)}" text-anchor="end">`
+ `${escapeHtml(formatter(value))}</text>`;
}).join('');
const labelIndexes = [0, Math.floor((series.length - 1) / 2), series.length - 1];
const xLabels = labelIndexes.map((index, position) => {
const anchor = position === 0 ? 'start' : (position === 2 ? 'end' : 'middle');
return `<text x="${xAt(index).toFixed(1)}" y="${height - 9}" text-anchor="${anchor}">`
+ `${escapeHtml(shortDate(series[index].date))}</text>`;
}).join('');
const coords = series
.map((point, index) => `${xAt(index).toFixed(1)},${yAt(point.value).toFixed(1)}`)
.join(' ');
const rising = values[values.length - 1] >= values[0];
return `<svg class="advisor-plan__chart-svg" viewBox="0 0 ${width} ${height}" role="img"`
+ ` aria-label="${escapeHtml(ariaLabel)}">`
+ `<g class="advisor-plan__chart-grid">${grid}</g>`
+ `<g class="advisor-plan__axis">${xLabels}</g>`
+ `<polyline class="advisor-plan__line advisor-plan__line--${rising ? 'up' : 'down'}"`
+ ` points="${coords}"/></svg>`;
}
/** 把一条净值序列归一成"相对首日的涨跌%"(组合合成的原料)。 */
function normalizedPct(points) {
const cleaned = points
.map((point) => ({ date: String(point.nav_date), value: num(point.nav) }))
.filter((point) => point.value !== null);
if (cleaned.length < 2) return [];
const base = cleaned[0].value;
if (!base) return [];
return cleaned.map((point) => ({ date: point.date, value: (point.value / base - 1) * 100 }));
}
/** 等权组合:各基金按共同交易日对齐后取均值。 */
function equalWeightSeries(navsByCode) {
const series = Object.values(navsByCode)
.map((points) => normalizedPct(points))
.filter((pts) => pts.length >= 2);
if (series.length < 2) return [];
let common = new Set(series[0].map((point) => point.date));
series.slice(1).forEach((pts) => {
const dates = new Set(pts.map((point) => point.date));
common = new Set([...common].filter((date) => dates.has(date)));
});
const dates = [...common].sort();
if (dates.length < 2) return [];
return dates.map((date) => ({
date,
value: series.reduce((sum, pts) => {
const hit = pts.find((point) => point.date === date);
return sum + (hit ? hit.value : 0);
}, 0) / series.length,
}));
}
function maxDrawdownPct(series) {
let peak = -Infinity;
let worst = 0;
series.forEach((point) => {
peak = Math.max(peak, point.value);
worst = Math.min(worst, point.value - peak);
});
return worst;
}
// ---- 环形图(资产配置比例) ----
function donutSvg(entries) {
const total = entries.reduce((sum, entry) => sum + entry.weight, 0) || 1;
const radius = 54;
const circumference = 2 * Math.PI * radius;
let offset = 0;
const slices = entries.map((entry, index) => {
const length = (entry.weight / total) * circumference;
const slice = `<circle class="advisor-plan__slice" cx="70" cy="70" r="${radius}"`
+ ` stroke="${SLICE_COLORS[index % SLICE_COLORS.length]}"`
+ ` stroke-dasharray="${length.toFixed(2)} ${(circumference - length).toFixed(2)}"`
+ ` stroke-dashoffset="${(-offset).toFixed(2)}" transform="rotate(-90 70 70)"/>`;
offset += length;
return slice;
}).join('');
return `<svg class="advisor-plan__donut" viewBox="0 0 140 140" role="img" aria-label="组合配置比例">`
+ slices
+ '<text class="advisor-plan__donut-title" x="70" y="66" text-anchor="middle">等权组合</text>'
+ `<text class="advisor-plan__donut-sub" x="70" y="84" text-anchor="middle">${entries.length} 只</text>`
+ '</svg>';
}
// ---- 方案骨架 ----
function riskOf(product) {
const evidence = product.recommendation_evidence_card || {};
return ((evidence.suitability || {}).risk_level) || product.risk_level || '--';
}
function cardHtml(product) {
const code = String(product.product_code || '');
return '<article class="advisor-plan__card" data-plan-card="' + escapeHtml(code) + '">'
+ '<header class="advisor-plan__head">'
+ `<div><h4 class="advisor-plan__name">${escapeHtml(product.product_name || '--')}</h4>`
+ `<p class="advisor-plan__sub">${escapeHtml(code)}`
+ ` · ${escapeHtml(product.product_category || '--')}</p></div>`
+ '<div class="advisor-plan__badges">'
+ `<span class="advisor-plan__risk">${escapeHtml(riskOf(product))}</span>`
+ `<span class="advisor-plan__score">评分 ${escapeHtml(String(product.score ?? '--'))}</span>`
+ '</div></header>'
+ `<div class="advisor-plan__chart" data-plan-chart="${escapeHtml(code)}">`
+ '<p class="advisor-plan__chart-pending">走势加载中…</p></div>'
+ `<dl class="advisor-plan__metrics" data-plan-metrics="${escapeHtml(code)}">`
+ metric('风险等级', riskOf(product)) + metric('AI 评分', String(product.score ?? '--'))
+ '</dl>'
+ `<ul class="advisor-plan__highlights" data-plan-highlights="${escapeHtml(code)}"></ul>`
// 标注文案来源:模型写的必须让人一眼看出来(与风控页"大模型生成"的标注惯例一致)。
+ '<p class="advisor-plan__reason"><strong>推荐依据'
+ (product.reason_source === 'llm'
? '<span class="advisor-plan__tag">AI 生成</span>'
: '<span class="advisor-plan__tag advisor-plan__tag--rule">规则生成</span>')
+ `</strong>${escapeHtml(product.reason || '--')}</p>`
+ '</article>';
}
/** 同步骨架:只用方案自带字段,行情/净值随后由 `hydratePlanView` 补。 */
export function renderPlanProducts(products) {
if (!products.length) return '';
const codes = products.map((product) => String(product.product_code || '')).join(',');
const entries = products.map((product) => ({
label: product.product_name || product.product_code || '--',
code: String(product.product_code || ''),
weight: 1,
}));
const legend = entries.map((entry, index) => {
const share = Math.round((entry.weight / entries.length) * 100);
return `<li><i style="background:${SLICE_COLORS[index % SLICE_COLORS.length]}"></i>`
+ `<span class="advisor-plan__legend-name">${escapeHtml(entry.label)}</span>`
+ `<span class="advisor-plan__legend-code">${escapeHtml(entry.code)}</span>`
+ `<b>${share}%</b></li>`;
}).join('');
return `<div class="advisor-plan" data-plan-view data-codes="${escapeHtml(codes)}">`
+ '<section class="advisor-plan__block">'
+ '<h4>组合业绩(等权合成)</h4>'
+ '<div class="advisor-plan__stats" data-plan-combo-stats></div>'
+ '<div class="advisor-plan__chart" data-plan-combo>'
+ '<p class="advisor-plan__chart-pending">组合走势加载中…</p></div>'
+ '</section>'
+ '<section class="advisor-plan__block">'
+ '<h4>资产配置比例(等权)</h4>'
+ '<div class="advisor-plan__config">'
+ `<div class="advisor-plan__donut-wrap">${donutSvg(entries)}</div>`
+ `<ul class="advisor-plan__legend">${legend}</ul>`
+ '</div></section>'
+ `<div class="advisor-plan__grid">${products.map(cardHtml).join('')}</div>`
+ '</div>';
}
// ---- 注解式补水 ----
async function fetchNav(apiClient, code) {
try {
const response = await apiClient.get('P002', {
pathParams: { productCode: code },
query: { days: SPARK_DAYS },
});
const points = response?.data?.points;
return Array.isArray(points) ? points : [];
} catch {
// 单只基金取不到净值不该拖垮整块:留空,卡片显示“暂无净值数据”。
return [];
}
}
function fillCardProducts(products) {
return new Map(products.map((product) => [String(product.product_code || ''), product]));
}
function enrichCard(block, code, product, facts, points) {
const chartNode = block.querySelector(`[data-plan-chart="${code}"]`);
const metricsNode = block.querySelector(`[data-plan-metrics="${code}"]`);
const highlightsNode = block.querySelector(`[data-plan-highlights="${code}"]`);
const values = navValues(points);
if (chartNode) {
const series = points
.map((point) => ({ date: String(point.nav_date), value: num(point.nav) }))
.filter((point) => point.value !== null);
chartNode.innerHTML = series.length >= 2
? lineChartSvg(series, {
formatter: (value) => value.toFixed(3),
ariaLabel: `${product?.product_name || code} 近 ${series.length} 个交易日净值走势`,
})
: '<p class="advisor-plan__chart-empty">暂无净值数据'
+ '(可运行 tools/sync_nav_history.py 同步)</p>';
}
if (metricsNode) {
const latestNav = values.length ? values[values.length - 1] : num(facts?.current_nav);
const rows = [
metric('最新净值', latestNav === null ? '--' : latestNav.toFixed(4)),
metric('当日涨跌', pctText(facts?.change_pct), pctClass(facts?.change_pct)),
];
INTERVALS.forEach(([label, days]) => {
const change = intervalChange(values, days);
rows.push(metric(label, pctText(change), pctClass(change)));
});
rows.push(metric('基金经理', facts?.fund_manager || '--'));
const fee = num(facts?.management_fee_rate);
// `management_fee_rate` 在库里可能是 NULL 或 0(未维护)——显示成「0.00%/年」
// 会被读成"这只基金不要管理费",与事实相反,所以按"没数据"处理。
rows.push(metric('管理费', fee === null || fee <= 0 ? '--' : `${(fee * 100).toFixed(2)}%/年`));
metricsNode.innerHTML = rows.join('');
}
if (highlightsNode) {
const items = [];
const c60 = intervalChange(values, 60);
const c20 = intervalChange(values, 20);
if (c60 !== null) items.push(`近 60 个交易日区间收益 ${pctText(c60)}`);
if (c20 !== null) items.push(`近 20 日区间收益 ${pctText(c20)}`);
if (values.length >= 2) {
items.push(`区间最大回撤 ${pctText(Math.min(0, maxDrawdownPct(
values.map((value) => ({ date: '', value })),
)))}`);
}
items.push(`风险等级 ${riskOf(product)},已按你的风险测评与投资期限做匹配校验`);
if (facts?.fund_manager) items.push(`基金经理 ${facts.fund_manager}`);
highlightsNode.innerHTML = items.map((text) => `<li>${escapeHtml(text)}</li>`).join('');
}
}
function fillCombo(block, navsByCode) {
const chartNode = block.querySelector('[data-plan-combo]');
const statsNode = block.querySelector('[data-plan-combo-stats]');
const series = equalWeightSeries(navsByCode);
if (!chartNode) return;
if (series.length < 2) {
chartNode.innerHTML = '<p class="advisor-plan__chart-empty">成分基金净值不足,'
+ '暂无法合成组合走势。</p>';
return;
}
const total = series[series.length - 1].value;
const drawdown = maxDrawdownPct(series);
chartNode.innerHTML = lineChartSvg(series, {
formatter: (value) => `${value.toFixed(1)}%`,
ariaLabel: `等权组合近 ${series.length} 个交易日累计收益走势`,
});
if (statsNode) {
statsNode.innerHTML = metric('区间收益', pctText(total), pctClass(total))
+ metric('最大回撤', pctText(drawdown), pctClass(drawdown))
+ metric('成分基金', `${Object.keys(navsByCode).length} 只`)
+ metric('区间', `${series[0].date} ~ ${series[series.length - 1].date}`);
}
}
/**
* 注解式补水:取 P001(一次,含全部产品)+ P002(每只一次),
* 填组合走势、卡片折线图、指标与看点。任何一步失败都只降级为「暂无数据」,不抛错。
*/
export async function hydratePlanView(root, apiClient, products = []) {
const block = root?.querySelector?.('[data-plan-view]');
if (!block || typeof apiClient?.get !== 'function') return;
const codes = String(block.dataset.codes || '').split(',').filter(Boolean);
if (!codes.length) return;
const byCode = fillCardProducts(products);
const productsByCode = new Map();
try {
const response = await apiClient.get('P001');
(response?.data?.products || []).forEach((item) => {
productsByCode.set(String(item.product_code), item);
});
} catch {
// 产品清单取不到:仍能画净值走势,只是基金经理/费率显示 `--`。
}
const navs = await Promise.all(codes.map((code) => fetchNav(apiClient, code)));
const navsByCode = {};
codes.forEach((code, index) => { navsByCode[code] = navs[index] || []; });
fillCombo(block, navsByCode);
codes.forEach((code, index) => {
enrichCard(block, code, byCode.get(code), productsByCode.get(code), navs[index] || []);
});
}