762 lines
34 KiB
JavaScript
762 lines
34 KiB
JavaScript
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;
|