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;