Files
smart-fin-chart/js/charts/sweet_spot.js
T
2026-09-24 17:14:03 +08:00

762 lines
34 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.
import {
PALETTE, FONT, FINANCIAL_COLORS, axisCommon, titleStyle, standardLegend,
lineSeries, footnote, watermarks, LAYOUT, tooltipOf,
} from '../theme.js';
/**
* 甜蜜击球区 · 股价 ↔ 估值 的换算图表(宁德时代 300750 / 腾讯控股 00700.HK)
*
* 同一套框架、两套口径。三张图的结构完全一致,差异全部下沉到 PROFILES 的两个配置块里:
* 图 1 股价 → 倍数对照(三档情景 + 倍数轴上的区域横带)
* 图 2 非经营资产让倍数便宜了多少(含/扣各层级 + 中间那层沙色带)
* 图 3 任意股价试算(三年后退出,年化收益 + 股价轴上的纵向区域带)
*
* 【为什么纵轴口径两家不同】这不是随意的:
* 宁德 纵轴 PE = 市值 ÷ 2026E 归母净利;它扣的「类现金」是 2,263.8 亿趴在账上的现金,
* 是准现金,扣掉后不改变分子的性质,所以 PE 口径成立。
* 腾讯 纵轴 EV/EBIT;它的「类现金」是 5,259.9 亿投资组合(股权、会波动),
* 只扣净现金 581.9 亿几乎不改变倍数(少 0.23x),必须连投资组合一起扣才是经营资产
* 的真实价格;同时分母换成经营盈利 EBIT,分子分母成对(分子不含投资收益)。
* 两家的甜蜜区锚也不同:宁德的五档来自资料库原表(13/15.5/25/30x);
* 腾讯用巴菲特原始锚 8x 白送 · 10x 甜点 · 12x 上沿。
*
* 【上游底稿】宁德:资料库「宁德时代_股价PE对照表」EsAWMaLKt4ECKPDvESKKvT(2026-09-15);
* 腾讯:资料库「腾讯控股(00700.HK)甜蜜击球区检验」Ey5NeiPls71FC12zn0ieSX(2026-09-17)。
* 数据由 scripts/build_sweet_spot_catl.py / build_sweet_spot_tencent.py 投影成 CSV。
*
* 【硬编码说明】区域边界的**股价**不是写死的,而是从 CSV 的基准倍数反解(multToPrice),
* 所以改股本/汇率/利润预期后纵向区域带会自动跟着走。配置块里只有三类手工常量:
* 现价默认值、非经营资产(仅用于文案)、以及区域档位本身。
*
* 【现价可手输】三张图的「现价」标线不是常量,而是 tab 级参数 `params.price`
* (在 manifest.json 各 tab 的 controls 里声明,见 js/main.js)。profile.priceDefault
* 是无参数调用(SSR 回归测试、脚本)的兜底。
*/
/* ============================================================
* 配置:宁德时代(300750)
* ============================================================ */
const CATL = {
id: 'catl',
company: '宁德时代',
currency: '元',
/** 默认现价 = 2026-09-15 收盘价。三处同源,改它要一起改:
* 本文件 · manifest.json 的 controls[].value · scripts/build_sweet_spot_catl.py */
priceDefault: 316.21,
/** 非经营资产(亿元)。仅用于文案;沙色带的几何量取自 CSV 的倍率差。 */
nonOp: 2263.8,
/** 数据区(= 输入区间)与坐标轴窗口(比数据区各宽 20 元,标线不贴边) */
dataMin: 220,
dataMax: 700,
/** CSV 的价格网格步长(= scripts/build_sweet_spot_catl.py 里 price_grid 的 step)。
* 现价与关键价位的标签避让阈值就用它,见 refLabels()。 */
dataStep: 20,
axis: { min: 200, max: 720, name: '股价(元)', metricMin: 8, metricMax: 38, metricInterval: 5 },
metric: { name: 'PE(倍)', unit: 'x', decimals: 2 },
/** 三档情景的基准是「2026E 归母净利」,65 家机构一致预期 */
sourceNote: '数据来源:宁德时代 2026 中报(本地财报库)· 65 家机构 2026E 一致预期',
/**
* 五档估值区域(按中性情景 964 亿 PE 的上沿划分)。
* 原表把 15.5–25x 细分为「合理偏低」与「合理」两档、但共用同一个配色,
* 这里并为一条色带,避免在 PE 轴上叠出两块无差别的底色。
*/
zones: [
{ name: '击球区', upTo: 13, color: PALETTE.green, note: '<13x' },
{ name: '买入区', upTo: 15.5, color: PALETTE.blue, note: '13–15.5x' },
{ name: '合理', upTo: 25, color: PALETTE.orange, note: '15.5–25x' },
{ name: '偏贵', upTo: 30, color: PALETTE.red, note: '25–30x' },
{ name: '卖出区', upTo: Infinity, color: PALETTE.purple, note: '>30x' },
],
multiple: {
title: '股价 → 2026E PE 对照',
subtitle: (ctx) => `单位:倍 · 三档利润情景 · 区域底带按中性 964 亿划分 · 现价 ${fmtPrice(ctx.now)} 元`,
labels: [265, 480, 605],
nowLabel: '中性',
nowDecimals: 2,
nowUnit: 'x',
notes: () => [
'PE = 市值 ÷ 2026E 归母净利,与股价严格成正比;三档情景只差 ±6% 利润,落到 PE 上不足 1.7x',
'原表把 15.5–25x 细分为「合理偏低」与「合理」,此处并为一条色带;扣现金 PE 见图 2',
],
/** 从上到下:第一条标 2 个关键价位、其余只画线(三条线几乎平行,全标会糊) */
scenarios: [
{ key: 'pe_bear', name: '悲观 900 亿', color: FINANCIAL_COLORS.operatingCashFlow, width: 2.4 },
{ key: 'pe_base', name: '中性 964 亿', color: FINANCIAL_COLORS.netProfit, width: 3.5, hero: true, label: 'right' },
{ key: 'pe_bull', name: '乐观 1,000 亿', color: FINANCIAL_COLORS.investingCashFlow, width: 2.4 },
],
legend: ['悲观 900 亿', '中性 964 亿', '乐观 1,000 亿'],
},
tiers: {
title: '净现金让 PE 便宜了多少',
subtitle: (ctx) => `单位:倍 · 中性 964 亿口径 · 沙色带即净现金 ${ctx.nonOpText} 亿元对应的 PE`,
/** 从上到下。层间的差就是非经营资产的厚度,栈底 = 最后一条(扣得最狠的口径)。 */
lines: [
{ name: '含现金 PE', key: 'pe_base', color: FINANCIAL_COLORS.investingCashFlow, width: 3 },
{ name: '扣现金 PE', key: 'pe_ex_cash', color: PALETTE.brown, width: 3.5 },
],
/** 沙色带(自下而上),名字不进图例(线宽 0,图例色块会是空的) */
bands: [{ name: '净现金差额', color: PALETTE.sand }],
pctKey: 'net_cash_pct',
nowLabel: '扣现金',
nowDecimals: 2,
nowUnit: 'x',
notes: (ctx) => [
'含现金 PE = 市值 ÷ 2026E 净利;扣现金 PE =(市值 − 净现金)÷ 2026E 净利,即只给主营电池业务定价',
`两条线严格平行:差额恒为 ${ctx.bands[0].gap.toFixed(2)}x,但占市值比例从 ${ctx.dataMin} 元的 `
+ `${ctx.minPct}% 收窄到 ${ctx.dataMax} 元的 ${ctx.maxPct}% —— 股价越低,这层折价越厚`,
],
},
anyPrice: {
title: '任意股价试算:三年预期年化收益',
subtitle: () => '单位:% · 目标价 605 元(2028E 1,400 亿 × 20x)· 纵带为估值区域',
lines: [
{
name: '含分红年化', key: 'cagr_total', color: FINANCIAL_COLORS.netProfit, width: 3.5,
hero: true, labels: [265, 450, 605], position: 'top', distance: 10,
},
{
name: '仅价差年化', key: 'cagr_price', color: FINANCIAL_COLORS.investingCashFlow, width: 2.6,
labels: [265, 450], position: 'bottom', distance: 10,
},
],
legend: ['含分红年化', '仅价差年化'],
nowLabel: '含分红年化',
nowDecimals: 1,
nowUnit: '%',
/**
* 纵轴下限 -20(不是 -10):顶端 700 元处「仅价差年化」已跌到 -4.7%,-10 只留 5.3pp 余量,
* 而纵带的名字(「卖出区」等)就画在绘图区底边上方 12–25px 那条带上 —— 手输 680 元时
* 「-3.8%」标签与「卖出区」实打实重叠 22×7px(2026-09-22 全价位扫描量出)。
* 留到 -20 后底部那条带彻底空出来。腾讯那份本来就是 -20 起,两家口径统一。
*/
yAxis: { min: -20, max: 50, interval: 10 },
notes: (ctx) => [
'年化收益 =(605 ÷ 股价)^(1/3) − 1;含分红口径再乘 (1 + 股息率),股息按当前价每年派现、不复投',
`纵带边界(${ctx.currency}):${ctx.boundary}`,
],
},
};
/* ============================================================
* 配置:腾讯控股(00700.HK)
* ============================================================ */
const TENCENT = {
id: 'tencent',
company: '腾讯控股',
currency: '港元',
/** 默认现价 = 2026-09-17 收盘价(与底稿同源,三处同:本文件 · manifest · 生成脚本) */
priceDefault: 426.8,
/**
* 非经营资产 = 净现金 581.9 + 上市投资公允价值 4,872.0 + 非上市投资账面 387.9
* = 5,841.8 亿元(占市值 17.5%)。这是腾讯与宁德口径上最大的差异:
* 宁德的类现金是纯现金,腾讯的是股权组合(其中投资组合 5,259.9 亿),会波动。
*/
nonOp: 5841.8,
dataMin: 240,
dataMax: 640,
/** CSV 的价格网格步长,见 refLabels()。两家同为 20 —— 不是巧合,是两份生成脚本
* 都用了 price_grid(top, 20, key_prices)。改步长要连这里的避让阈值一起想。 */
dataStep: 20,
axis: { min: 220, max: 660, name: '股价(港元)', metricMin: 3, metricMax: 19, metricInterval: 2 },
metric: { name: 'EV/EBIT(倍)', unit: 'x', decimals: 2 },
sourceNote: '数据来源:腾讯控股 2026 中期报告及 2026Q2 业绩公告 · TTM = 2025 全年 − 2025H1 + 2026H1',
/**
* 巴菲特原始锚:8x 白送 · 10x 甜点 · 12x 上沿。
* 超过 12x 就是出区,所以这里的两档「贵」不需要像宁德那样细化 —— 框架里没有卖出档,
* 只有一个「够不够便宜」的判断。
*/
zones: [
{ name: '白送', upTo: 8, color: PALETTE.green, note: '<8x' },
{ name: '甜蜜区', upTo: 10, color: PALETTE.blue, note: '8–10x' },
{ name: '上沿', upTo: 12, color: PALETTE.orange, note: '10–12x' },
{ name: '超出上沿', upTo: 15, color: PALETTE.red, note: '12–15x' },
{ name: '偏贵', upTo: Infinity, color: PALETTE.purple, note: '>15x' },
],
multiple: {
title: '股价 → EV/EBIT 对照',
subtitle: (ctx) => `单位:倍 · 三档 EBIT 情景 · 区域带按 8x/10x/12x 划分 · 现价 ${fmtPrice(ctx.now)} 港元`,
/** 甜蜜区标尺:8x 白送 / 10x 甜点 / 12x 上沿 */
labels: [338.5, 404.5, 470.5],
nowLabel: '基准',
nowDecimals: 2,
nowUnit: 'x',
notes: () => [
'EV/EBIT =(市值 − 非经营资产)÷ TTM EBIT;三档 EBIT 只差 ±10%,落到倍数上放大到近 2x',
'非经营资产 5,841.8 亿 = 净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9(扣法见图 2)',
],
scenarios: [
{ key: 'mult_bear', name: '悲观 2,400 亿', color: FINANCIAL_COLORS.operatingCashFlow, width: 2.4 },
{ key: 'mult_base', name: '基准 2,585 亿', color: FINANCIAL_COLORS.netProfit, width: 3.5, hero: true, label: 'right' },
{ key: 'mult_bull', name: '乐观 2,900 亿', color: FINANCIAL_COLORS.investingCashFlow, width: 2.4 },
],
legend: ['悲观 2,400 亿', '基准 2,585 亿', '乐观 2,900 亿'],
},
tiers: {
title: '扣掉非经营资产后,倍数低了多少',
subtitle: (ctx) => `单位:倍 · 基准 EBIT 2,585 亿 · 沙色带即非经营资产 ${ctx.nonOpText} 亿元对应的倍数`,
/** 三层口径:不扣(市值)→ 只扣净现金 → 连投资组合一起扣。中间那层是「假阴性」的来源。 */
lines: [
{ name: '不扣任何资产', key: 'mult_gross', color: FINANCIAL_COLORS.netCashIncrease, width: 2.6 },
{ name: '只扣净现金', key: 'mult_net_cash', color: PALETTE.grey, width: 2.6 },
{ name: '扣净现金 + 投资组合', key: 'mult_base', color: PALETTE.brown, width: 3.5 },
],
bands: [
{ name: '投资组合差额', color: PALETTE.sand },
{ name: '净现金差额', color: PALETTE.sand },
],
pctKey: 'non_op_pct',
nowLabel: '全扣',
nowDecimals: 2,
nowUnit: 'x',
notes: (ctx) => [
`只扣净现金只挪动 ${ctx.bands[1].gap.toFixed(2)}x;真正厚的是投资组合(${ctx.bands[0].gap.toFixed(2)}x)—— `
+ '这是腾讯与宁德最大的口径差异',
'漏扣投资组合会得出「超上沿」的假阴性:它是股权不是现金、会波动,打 7 折后基准倍数回到 11.29x',
],
},
anyPrice: {
title: '任意股价试算:三年退出年化收益',
subtitle: () => '单位:% · 三年后退出在甜蜜区上沿 12x / 甜点 10x · 纵带为估值区域',
/**
* 目标价三年不变(不给 EBIT 增长),三条线的差别只在「退出倍数」与「分母是否被折旧侵蚀」:
* 12x 上沿 470.5 港元(= 报告「三·甜蜜区股价标尺」的 12x 一档)
* 10x 甜点 404.5 港元
* 折旧后 12x 418.7 港元(5 年直线折旧,稳态 EBIT 2,247 亿 —— 报告「四·分母的定时炸弹」)
*/
lines: [
{
name: '退出 12x 上沿', key: 'cagr_12x', color: FINANCIAL_COLORS.netProfit, width: 3.5,
hero: true, labels: [371.5, 404.5, 470.5], position: 'top', distance: 10,
},
{
name: '退出 10x 甜点', key: 'cagr_10x', color: FINANCIAL_COLORS.investingCashFlow, width: 2.6,
labels: [404.5], position: 'bottom', distance: 12,
},
{
name: '12x 上沿 · 折旧后', key: 'cagr_12x_dep', color: FINANCIAL_COLORS.netCashIncrease, width: 2.6,
labels: [],
},
],
legend: ['退出 12x 上沿', '退出 10x 甜点', '12x 上沿 · 折旧后'],
nowLabel: '退出 12x 上沿',
nowDecimals: 1,
nowUnit: '%',
yAxis: { min: -20, max: 30, interval: 10 },
notes: (ctx) => [
'目标价 =(倍数 × EBIT + 非经营资产)÷ 90.95 亿股 ÷ 0.86154;EBIT 不给增长(三年后与今日同口径)',
`纵带边界(${ctx.currency}):${ctx.boundary}`,
],
},
};
const PROFILES = { catl: CATL, tencent: TENCENT };
/* ============================================================
* 通用工具(两家公司共用)
* ============================================================ */
/** 十六进制转 rgba —— 区域带要低透明度叠在网格上,不能直接铺饱和色 */
function tint(hex, alpha) {
const value = parseInt(hex.slice(1), 16);
return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${alpha})`;
}
/** 取某价位的某个指标:命中数据行就用原值,否则在相邻两行之间线性插值 */
function metricAt(rows, price, key) {
const hit = rows.find((item) => item.price === price);
if (hit) return hit[key];
for (let i = 1; i < rows.length; i += 1) {
const low = rows[i - 1];
const high = rows[i];
if (price > low.price && price < high.price) {
const ratio = (price - low.price) / (high.price - low.price);
return low[key] + ratio * (high[key] - low[key]);
}
}
return Number.NaN;
}
/**
* 解析 tab 参数里的现价。手输价格通常不落在 CSV 的价位上(价位是 240/260/…/426.8/440/…),
* 所以取值一律走 metricAt 的插值:倍数与股价严格成正比(截距是非经营资产)、
* 年化收益是幂函数,相邻两行最多差 20 元,插值误差远小于显示精度。
*/
function resolvePrice(profile, params) {
const value = Number(params?.price);
return Number.isFinite(value) && value > 0 ? value : profile.priceDefault;
}
/** 价格文案:316.21 → '316.21'、320 → '320'(手输整数不带小数点) */
function fmtPrice(value) {
return String(Number(value.toFixed(2)));
}
/**
* 关键价位标签 + 现价标签。
*
* 数据点标签只能落在数据行上(`label.position` 不接受函数,只能逐点覆盖 data),
* 手输的现价不一定正好是数据行,所以只有命中数据行时才把它也标出来 ——
* 命不中也不亏,现价的数值由 nowGraphics 的标注直接给出。
*
* 【避让】现价与某个关键价位落在同一个数据格内时,**关键价位让位**:两者数值几乎
* 相同,两个标签的包围盒会实打实叠在一起(宁德输 260 元时,现价 12.48x 与关键价
* 265 元的 12.72x 重叠 34×11px;图 3 上「37.8% × 36.9%」「32.5% × 31.7%」各一处,
* 2026-09-22 用 geometry_check 量出)。现价永远保留 —— 它是这次交互的主角。
*
* 阈值取「一个数据格」而不是现算像素:标签宽约 43px、每元约 1.8px,像素阈值会随
* 轴范围漂;而两家的 CSV 网格步长都是 20,20 元本身就约等于一个标签的宽度,
* 越过一格就必然分得开。实测两家真实阈值 20.3(宁德)/ 17.2(腾讯),取 20 两边都够。
*/
function refLabels(profile, rows, now, refs) {
if (!rows.some((row) => row.price === now)) return refs;
return [...refs.filter((ref) => Math.abs(ref - now) > profile.dataStep), now];
}
/** 数值轴(倍数 / 年化收益共用),与 catl.js 的 valueAxis 同一套样式 */
const valueAxis = (name, extra = {}) => ({
...axisCommon,
type: 'value',
name,
nameLocation: 'middle',
nameGap: 56,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
...extra,
});
/**
* 股价轴:数值轴。
* 不画竖向网格(规范只保留浅色横线)—— 图 3 的纵向区域带本身就是参考线,
* 图 1、图 2 靠「现价」竖线定位。
*/
const priceAxis = (profile, extra = {}) => ({
...axisCommon,
type: 'value',
name: profile.axis.name,
nameLocation: 'middle',
nameGap: 34,
min: profile.axis.min,
max: profile.axis.max,
axisLine: { show: true, lineStyle: { color: PALETTE.axisLine } },
splitLine: { show: false },
...extra,
});
const GRID = { left: 78, right: 78, top: LAYOUT.gridTop, bottom: 80 };
function baseOption(profile, {
title, subtitle, xAxis, yAxis, series, legend, width, height,
tooltipFormatter, extraNote, grid, extraGraphics = [],
}) {
const notes = [profile.sourceNote];
if (extraNote) notes.push(extraNote);
return {
backgroundColor: '#fff',
title: { ...titleStyle, text: title, subtext: subtitle },
legend: standardLegend(legend, { itemGap: 22 }),
// 三行脚注(来源 + 两行口径)会顶到 height-66,而横轴刻度底边在 height-52。
// 本项目这几张图的横轴带轴名(「股价(元)/(港元)」),它比刻度再低约 32px;实测
// grid.bottom=118 时轴名与脚注只剩 7px 净空(同站三行脚注的图通常有 23px),
// 所以统一提到 134 —— 改脚注行数时这里要跟着重算。
grid: { ...GRID, ...grid },
tooltip: tooltipOf(series, tooltipFormatter),
xAxis,
yAxis,
series,
graphic: [...watermarks(width, height), ...extraGraphics, footnote(notes.join('\n') + ' | @Joey知行记')],
};
}
/** 反解倍数阈值对应的股价:在基准倍数单调递增的序列上线性插值 */
function multToPrice(rows, mult, key) {
for (let i = 1; i < rows.length; i += 1) {
const prev = rows[i - 1];
const cur = rows[i];
if ((prev[key] - mult) * (cur[key] - mult) <= 0) {
const ratio = (mult - prev[key]) / (cur[key] - prev[key]);
return prev.price + ratio * (cur.price - prev.price);
}
}
return null;
}
/**
* 倍数轴上的横向区域带(图 1、图 2 共用)。
* 必须挂在**第一个系列**上:markArea 在系列自己的分组里、先于该系列图元绘制,
* 而系列分组按声明顺序绘制 —— 挂在后面的系列上会盖住前一条线。
*/
function multBandMarkAreas(profile) {
const { metricMin, metricMax } = profile.axis;
let low = metricMin;
return profile.zones.map((zone) => {
const band = [{
yAxis: low,
itemStyle: { color: tint(zone.color, 0.07) },
label: {
show: true,
position: 'insideTopLeft',
distance: 10,
formatter: `${zone.name} ${zone.note}`,
color: zone.color,
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
},
}, { yAxis: Math.min(zone.upTo, metricMax) }];
low = zone.upTo;
return band;
});
}
/**
* 各档阈值对应的股价边界(最后一档 upTo 是 Infinity,不参与)。
*
* 缺席的阈值必须**留 null 占位**,不能 filter 掉:filter 会让后面的档位整体前移一格,
* 把「偏贵」的上沿当成「卖出区」的下沿。数据区只到 620 元时(偏贵上沿 625 元在区外)
* 就踩过这个坑,表现为「偏贵」色带一路铺到轴右端、而「卖出区」退化成零宽。
*/
function zoneEdges(profile, rows) {
const key = profile.multiple.scenarios.find((item) => item.hero).key;
return profile.zones.slice(0, -1).map((zone) => multToPrice(rows, zone.upTo, key));
}
/**
* 股价轴上的纵向区域带(图 3 用)。
* 标签贴底:顶部留给折线标签与「现价」竖线的标注,贴底才不会撞。
*/
function priceBandMarkAreas(profile, rows) {
const edges = [profile.axis.min, ...zoneEdges(profile, rows), profile.axis.max];
return profile.zones.map((zone, index) => [{
xAxis: edges[index] ?? profile.axis.max,
itemStyle: { color: tint(zone.color, 0.08) },
label: {
show: true,
position: 'insideBottom',
distance: 12,
formatter: zone.name,
color: zone.color,
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
},
}, { xAxis: edges[index + 1] ?? profile.axis.max }]);
}
/**
* 股价 → 指标折线。
* 只在 labels 上的点标数值:20+ 个数据点全标会糊成一片,
* 而这几个点正好是决策价位。
* 不设 z:同图各条线在窗口内不相交(分子随股价线性增长、共享同一个横轴截距),
* 声明顺序即绘制顺序,多设一层 z 只会让 SVG 分组变形。
*/
function priceLine({
name, rows, key, color, width = 2.6, labels = [],
unit = '', decimals = 2, position = 'right', distance = 8,
}) {
const line = lineSeries({ name, data: [], color, width });
return {
...line,
label: { show: false },
data: rows.map((row) => ({
value: [row.price, row[key]],
label: labels.includes(row.price)
? {
...line.label,
show: true,
position,
distance,
formatter: `${row[key].toFixed(decimals)}${unit}`,
}
: { show: false },
})),
};
}
/**
* 现价竖线:所有图共用。
* label 一律关掉 —— markLine 的标签会**跟着竖线旋转 90°**,长串中文变成一列竖排字,
* 极难读。文字改用 nowGraphics() 以 graphic 单独排,保持水平。
*/
function nowMarkLine(now) {
return {
silent: true,
symbol: 'none',
lineStyle: { type: 'dashed', width: 1.6, color: PALETTE.brown },
label: { show: false },
data: [{ xAxis: now }],
};
}
/**
* 现价竖线上方的水平标注,位置由 grid 反推(图表尺寸变化时会重算)。
* 现价贴近右边界时改到竖线左侧右对齐,否则文字会顶出画布 —— 手输价格可能落在整个
* 轴窗口的任意位置,不能像过去只按固定现价那样假设右边一定有地方。
*
* 【纵向位置:画在绘图区**外面**那条留白带里(图例底 ≈107、绘图区顶 145),
* 不是贴绘图区顶边。】贴边时它和绘图区顶行的文字抢位置,低价端必然撞:
* 宁德输 220 元时与「卖出区 >30x」重叠 35×8px、腾讯输 240 港元时与「偏贵 >15x」
* 重叠 16×8px(2026-09-22 用 geometry_check 量出,两家的默认价都恰好躲过了)。
* 挪到绘图区外,顶部就只剩图例(居中、85–107),与本条永不相交;
* 顺带解决了「换个手输价、标注就上下跳」的问题 —— 现在它始终在同一高度。
*/
function nowGraphics(profile, width, now, text) {
const { min, max } = profile.axis;
const x = GRID.left + ((now - min) / (max - min)) * (width - GRID.left - GRID.right);
const flip = x + 8 + text.length * 13 > width - GRID.right;
return [{
type: 'text',
left: Math.round(flip ? x - 8 : x + 8),
top: LAYOUT.gridTop - 30,
silent: true,
style: {
text,
fill: PALETTE.brown,
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
...(flip ? { textAlign: 'right' } : {}),
},
}];
}
/**
* 现价标注文案。各图格式一致,且指标取不到时不打印 "NaN" —— 那说明现价落在数据区
* 之外(插值无从取值),把原因写出来比一个 NaN 有用。输入框本身也会拦下这种值。
*/
function nowNote(profile, now, label, value, decimals, unit) {
const head = `现价 ${fmtPrice(now)} ${profile.currency}`;
return Number.isFinite(value) ? `${head} = ${label} ${value.toFixed(decimals)}${unit}` : `${head}(超出数据区)`;
}
/** 图 1、图 2 的 tooltip:倍数,两位小数 */
const multipleFormatter = (profile) => (value) => {
const number = Number(Array.isArray(value) ? value[1] : value);
return `${number.toFixed(profile.metric.decimals)}${profile.metric.unit}`;
};
/* ============================================================
* 三个通用 builder
* ============================================================ */
/**
* 图 1:股价 → 倍数对照
*
* 一条直线就是一个情景:分子随股价线性增长(截距是负的非经营资产),所以三档情景在图上
* 是从同一个横轴截距发散出来的一组射线,窗口内近似平行。这张图能回答"现价落在哪个区域",
* 但回答不了"贵还是便宜"—— 那取决于分母(利润/EBIT)能不能兑现,属于图 3 与脚注的事。
*/
function buildMultipleChart(profile, rows, { width, height, params }) {
const now = resolvePrice(profile, params);
const cfg = profile.multiple;
const heroIndex = cfg.scenarios.findIndex((item) => item.hero);
const nowValue = metricAt(rows, now, cfg.scenarios[heroIndex].key);
const lines = cfg.scenarios.map((scenario) => {
const labels = scenario.hero ? refLabels(profile, rows, now, cfg.labels) : [];
const line = priceLine({
name: scenario.name,
rows,
key: scenario.key,
color: scenario.color,
width: scenario.width,
labels,
unit: profile.metric.unit,
position: scenario.label ?? 'right',
});
if (scenario.hero) line.markLine = nowMarkLine(now);
return line;
});
// markArea 挂在第一个系列上(见 multBandMarkAreas 注释)
lines[0].markArea = { silent: true, data: multBandMarkAreas(profile) };
return baseOption(profile, {
title: cfg.title,
subtitle: cfg.subtitle({ now }),
xAxis: priceAxis(profile),
yAxis: valueAxis(profile.metric.name, {
min: profile.axis.metricMin,
max: profile.axis.metricMax,
interval: profile.axis.metricInterval,
}),
series: lines,
legend: cfg.legend,
width,
height,
grid: { bottom: 134 },
extraGraphics: nowGraphics(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: multipleFormatter(profile),
extraNote: cfg.notes({ now, rows }).join('\n'),
});
}
/**
* 图 2:非经营资产让倍数便宜了多少
*
* 各层级口径是**严格平行**的一组线,层间距恒为 该项资产 ÷ 分母 —— 间距与股价无关,
* 但折价占市值的比例随股价下降而放大。所以这张图要看的不是几条线的高低,
* 而是中间那层沙色:它是一块不产生经营利润、但实实在在归股东的资产。
* 宁德只有一层(净现金);腾讯有两层,薄的是净现金、厚的是投资组合 ——
* 只扣现金几乎不动,正是"假阴性"的来源。
*
* 实现说明:ECharts 没有"两个系列之间填充",只能用 stack —— 栈底是最低那条,
* 往上每一层是相邻两层之差;差额段线宽 0、面积填沙色,上沿即上一层口径。
* stack 的叠放顺序不能改(改了基准就错),于是用 z 控制绘制层级,
* 防止沙色覆盖掉栈底那条实线。
*/
function buildAssetTierChart(profile, rows, { width, height, params }) {
const now = resolvePrice(profile, params);
const cfg = profile.tiers;
const bottom = cfg.lines[cfg.lines.length - 1];
const nowValue = metricAt(rows, now, bottom.key);
const base = {
...lineSeries({ name: bottom.name, data: [], color: bottom.color, width: bottom.width }),
label: { show: false },
stack: 'tier',
z: 3,
areaStyle: { opacity: 0 },
data: rows.map((row) => [row.price, row[bottom.key]]),
};
base.markLine = nowMarkLine(now);
base.markArea = { silent: true, data: multBandMarkAreas(profile) };
// 自下而上堆差:第 i 层带 = lines[i] − lines[i+1](lines 从上到下)
const bands = [];
for (let i = cfg.lines.length - 2; i >= 0; i -= 1) {
const upper = cfg.lines[i];
const lower = cfg.lines[i + 1];
const style = cfg.bands[cfg.lines.length - 2 - i];
bands.push({
...lineSeries({ name: style.name, data: [], color: style.color, width: 0 }),
label: { show: false },
stack: 'tier',
z: 2,
lineStyle: { color: style.color, width: 0, opacity: 0 },
itemStyle: { color: style.color },
showSymbol: false,
// opacity 必须显式写:areaStyle 的默认 opacity 是 0.7,只给 rgba 底色会被再乘一次
areaStyle: { color: style.color, opacity: 0.7 },
data: rows.map((row) => [row.price, row[upper.key] - row[lower.key]]),
});
}
// 栈顶以上的每一层都要自己的实线(栈只有上沿,没有中间的线)
const overlays = cfg.lines.slice(0, -1).map((tier, index) => ({
...lineSeries({ name: tier.name, data: [], color: tier.color, width: tier.width }),
label: { show: false },
z: 4 + index,
data: rows.map((row) => [row.price, row[tier.key]]),
}));
const pctAt = (price) => metricAt(rows, price, cfg.pctKey);
const bandCtx = bands.map((band, index) => ({
name: band.name,
// bands 自下而上,配置里的 bands 也是自下而上 → 顺序一致
gap: rows[0] ? rows[0][cfg.lines[cfg.lines.length - 2 - index].key] - rows[0][cfg.lines[cfg.lines.length - 1 - index].key] : 0,
}));
return baseOption(profile, {
title: cfg.title,
subtitle: cfg.subtitle({
nonOpText: profile.nonOp.toLocaleString('zh-CN'),
bands: bandCtx,
}),
xAxis: priceAxis(profile),
yAxis: valueAxis(profile.metric.name, {
min: profile.axis.metricMin,
max: profile.axis.metricMax,
interval: profile.axis.metricInterval,
}),
// 栈底 → 差额带 → 各层实线;「差额」不进图例(线宽 0,图例色块会是空的)
series: [base, ...bands, ...overlays],
legend: cfg.lines.map((tier) => tier.name),
width,
height,
grid: { bottom: 134 },
extraGraphics: nowGraphics(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: multipleFormatter(profile),
extraNote: cfg.notes({
bands: bandCtx,
dataMin: profile.dataMin,
dataMax: profile.dataMax,
minPct: pctAt(profile.dataMin).toFixed(1),
maxPct: pctAt(profile.dataMax).toFixed(1),
rows,
}).join('\n'),
});
}
/**
* 图 3:任意股价试算
*
* 计算器要输入一个价格才出结果,图表反过来:整档价格都画出来,任意价位沿竖线直接读。
* 纵轴穿过 0 的位置就是"三年白干"—— 它正好落在退出价上,所以零线本身就是一条结论。
* 纵向底带把估值区域直接画在股价轴上:横带看倍数、竖带看股价,是同一套档位。
*/
function buildAnyPriceChart(profile, rows, { width, height, params }) {
const now = resolvePrice(profile, params);
const cfg = profile.anyPrice;
const heroIndex = cfg.lines.findIndex((item) => item.hero);
const heroSpec = cfg.lines[heroIndex];
const nowValue = metricAt(rows, now, heroSpec.key);
const edges = zoneEdges(profile, rows);
const lines = cfg.lines.map((item) => {
const line = priceLine({
name: item.name,
rows,
key: item.key,
color: item.color,
width: item.width,
labels: refLabels(profile, rows, now, item.labels ?? []),
unit: '%',
decimals: 1,
position: item.position ?? 'right',
distance: item.distance ?? 8,
});
if (item.hero) {
line.markLine = nowMarkLine(now);
// markArea 挂在主角系列上,否则会盖住同图上先声明的折线
line.markArea = { silent: true, data: priceBandMarkAreas(profile, rows) };
}
return line;
});
return baseOption(profile, {
title: cfg.title,
subtitle: cfg.subtitle({ now }),
xAxis: priceAxis(profile),
yAxis: valueAxis('年化收益(%)', cfg.yAxis),
series: lines,
legend: cfg.legend,
width,
height,
grid: { bottom: 134 },
extraGraphics: nowGraphics(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: (value) => `${Number(Array.isArray(value) ? value[1] : value).toFixed(1)}%`,
extraNote: cfg.notes({
currency: profile.currency,
// 边界从数据反解、不写死:改股本/汇率/利润预期后这里跟着走
boundary: profile.zones.slice(0, -1)
.map((zone, index) => (edges[index] === null ? null : `${zone.name} ${edges[index].toFixed(1)}`))
.filter(Boolean).join(' | '),
rows,
}).join('\n'),
});
}
/* ============================================================
* 导出:每家三张图
* ============================================================ */
export const sweetCatlPricePeChart = (rows, options) => buildMultipleChart(CATL, rows, options);
export const sweetCatlNetCashChart = (rows, options) => buildAssetTierChart(CATL, rows, options);
export const sweetCatlAnyPriceChart = (rows, options) => buildAnyPriceChart(CATL, rows, options);
export const sweetTencentPriceEvChart = (rows, options) => buildMultipleChart(TENCENT, rows, options);
export const sweetTencentNonOpChart = (rows, options) => buildAssetTierChart(TENCENT, rows, options);
export const sweetTencentAnyPriceChart = (rows, options) => buildAnyPriceChart(TENCENT, rows, options);
export const SWEET_SPOT_PROFILES = PROFILES;