import { PALETTE, FONT, FINANCIAL_COLORS, axisCommon, titleStyle, standardLegend, lineSeries, footnote, watermarks, LAYOUT, tooltipOf, } from '../theme.js'; /** * 甜蜜击球区 · 宁德时代(300750)—— 股价 ↔ 2026E 估值 的换算图表 * * 上游底稿:资料库节点「宁德时代_股价PE对照表」 * https://www.workbuddy.cn/space/d/EsAWMaLKt4ECKPDvESKKvT(2026-09-15 版) * 数据由 scripts/build_sweet_spot_catl.py 投影成 data/sweetspot/*.csv。 * * 【口径】所有 PE 都是**静态预期 PE**:市值 ÷ 2026E 归母净利(悲观 900 / 中性 964 / * 乐观 1,000 亿元,65 家机构一致预期)。扣现金 PE =(市值 − 净现金 2,263.8 亿元)÷ * 2026E 净利,即"只给主营电池业务定价"。股息率按 2023–2025 年实际分红率 50% 测算。 * * 【甜蜜区框架】8x 白送 · 10x 甜点 · 12x 上沿 是巴菲特锚;资料库原表用的是五档 * 更宽的区域(按中性情景 PE 划分),因为它的分子是"含净现金的市值"。两套档位不要 * 混读:本文所有区域带一律用原表的五档,定义集中在 PE_ZONES,改档位只改这一处。 * * 【图 1】股价 → PE 对照(PE 轴上的横向区域带) * 【图 2】含现金 PE vs 扣现金 PE(沙色带 = 净现金的厚度) * 【图 3】任意股价试算(股价轴上的纵向区域带 + 三年预期年化收益) * * 精度例外:PE 保留两位小数。项目默认一位,但"现价落在买入区还是合理区"取决于 * 15.18x 与 15.5x 这条边界,一位小数会把 15.18 读成 15.2、与 15.5 分不清。 * * 硬编码说明:区域边界的**股价**不是写死的,而是从 CSV 的 pe_base 反解(peToPrice), * 所以改股本或利润预期后纵向区域带会自动跟着走。本文只有两个手工常量:现价 NOW 与 * 净现金 NET_CASH(仅用于文案),都与 build_sweet_spot_catl.py 同源。 */ /** 现价(2026-09-15 收盘)。与生成脚本同源,重跑脚本时要同步。 */ const NOW = 316.21; /** 净现金(亿元)。仅用于图 2 的文案;沙色带的几何量取自 CSV 的 pe_base − pe_ex_cash。 */ const NET_CASH = 2263.8; /** 2028E 目标价(1,400 亿 × 20x ÷ 46.27 亿股),图 3 的年化收益基准。 */ const TARGET_2028 = 605; /** * 五档估值区域(按中性情景 964 亿 PE 的上沿划分)。 * 原表把 15.5–25x 细分为「合理偏低」与「合理」两档、但共用同一个配色, * 这里并为一条色带,避免在 PE 轴上叠出两块无差别的底色。 */ const 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' }, ]; const PE_MIN = 8; const PE_MAX = 38; const PRICE_MIN = 200; const PRICE_MAX = 720; const SOURCE_NOTE = '数据来源:宁德时代 2026 中报(本地财报库)· 65 家机构 2026E 一致预期'; /** 十六进制转 rgba —— 区域带要低透明度叠在网格上,不能直接铺饱和色 */ function tint(hex, alpha) { const value = parseInt(hex.slice(1), 16); return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${alpha})`; } /** 取某价位的某个指标;缺失返回 NaN,让标签渲染成 NaN 而不是抛错 */ function metricAt(rows, price, key) { const row = rows.find((item) => item.price === price); return row ? row[key] : Number.NaN; } /** 数值轴(PE / 收益率共用),与 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 = (extra = {}) => ({ ...axisCommon, type: 'value', name: '股价(元)', nameLocation: 'middle', nameGap: 34, min: PRICE_MIN, max: PRICE_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({ title, subtitle, xAxis, yAxis, series, legend, width, height, tooltipFormatter, extraNote, sourceNote, grid, extraGraphics = [], }) { const notes = [sourceNote ?? SOURCE_NOTE]; 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知行记')], }; } /** 反解 PE 阈值对应的股价:在 pe_base 单调递增的序列上线性插值 */ function peToPrice(rows, pe) { for (let i = 1; i < rows.length; i += 1) { const prev = rows[i - 1]; const cur = rows[i]; if ((prev.pe_base - pe) * (cur.pe_base - pe) <= 0) { const ratio = (pe - prev.pe_base) / (cur.pe_base - prev.pe_base); return prev.price + ratio * (cur.price - prev.price); } } return null; } /** * PE 轴上的横向区域带(图 1、图 2 共用)。 * 必须挂在**第一个系列**上:markArea 在系列自己的分组里、先于该系列图元绘制, * 而系列分组按声明顺序绘制 —— 挂在后面的系列上会盖住前一条线。 */ function peBandMarkAreas() { let low = PE_MIN; return PE_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, PE_MAX) }]; low = zone.upTo; return band; }); } /** * 股价轴上的纵向区域带(图 3 用)。 * 标签贴底:顶部留给折线标签与「现价」竖线的标注,贴底才不会撞。 */ function priceBandMarkAreas(rows) { const edges = [PRICE_MIN, ...PE_ZONES .map((zone) => peToPrice(rows, zone.upTo)) .filter((price) => price !== null), PRICE_MAX]; return PE_ZONES.map((zone, index) => [{ xAxis: edges[index], 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] }]); } /** * 股价 → 指标折线。 * 只在 labeledPrices 上的点标数值:30+ 个数据点全标会糊成一片, * 而这几个点正好是决策价位。 */ function priceLine({ name, rows, key, color, width = 2.6, labeledPrices = [], 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: labeledPrices.includes(row.price) ? { ...line.label, show: true, position, distance, formatter: `${row[key].toFixed(decimals)}${unit}`, } : { show: false }, })), }; } /** * 现价竖线:三张图共用。 * label 一律关掉 —— markLine 的标签会**跟着竖线旋转 90°**,长串中文变成一列竖排字, * 极难读。文字改用 nowGraphics() 以 graphic 单独排,保持水平。 */ function nowMarkLine() { return { silent: true, symbol: 'none', lineStyle: { type: 'dashed', width: 1.6, color: PALETTE.brown }, label: { show: false }, data: [{ xAxis: NOW }], }; } /** 现价竖线上方的水平标注,位置由 grid 反推(图表尺寸变化时会重算) */ function nowGraphics(width, text) { const x = GRID.left + ((NOW - PRICE_MIN) / (PRICE_MAX - PRICE_MIN)) * (width - GRID.left - GRID.right); return [{ type: 'text', left: Math.round(x + 8), top: LAYOUT.gridTop + 4, silent: true, style: { text, fill: PALETTE.brown, fontSize: 12, fontWeight: 600, fontFamily: FONT }, }]; } /** * 图 1:股价 → 2026E PE 对照 —— 资料库原表第 1 节的图形版 * * 一条直线就是一个情景:PE 与股价严格成正比,所以三档利润在图上表现为三条**平行**的 * 窄带(±6% 的利润预期落到 PE 上不足 1.7x)。这张图能回答"现价落在哪个区域", * 但回答不了"贵还是便宜"—— 那取决于利润能不能兑现,属于分母问题,不在本图射程内。 */ export function sweetCatlPricePeChart(rows, { width, height }) { const nowPe = metricAt(rows, NOW, 'pe_base'); const hero = priceLine({ name: '中性 964 亿', rows, key: 'pe_base', color: FINANCIAL_COLORS.netProfit, width: 3.5, labeledPrices: [265, NOW, 480, 605], unit: 'x', position: 'right', }); hero.markLine = nowMarkLine(); const bear = { ...lineSeries({ name: '悲观 900 亿', data: [], color: FINANCIAL_COLORS.operatingCashFlow, width: 2.4 }), label: { show: false }, data: rows.map((row) => [row.price, row.pe_bear]), }; // markArea 挂在第一个系列上(见 peBandMarkAreas 注释) bear.markArea = { silent: true, data: peBandMarkAreas() }; const bull = { ...lineSeries({ name: '乐观 1,000 亿', data: [], color: FINANCIAL_COLORS.investingCashFlow, width: 2.4 }), label: { show: false }, data: rows.map((row) => [row.price, row.pe_bull]), }; return baseOption({ title: '股价 → 2026E PE 对照', subtitle: `单位:倍 · 三档利润情景 · 区域底带按中性 964 亿划分 · 现价 ${NOW} 元`, xAxis: priceAxis(), yAxis: valueAxis('PE(倍)', { min: PE_MIN, max: PE_MAX, interval: 5 }), series: [bear, hero, bull], legend: ['悲观 900 亿', '中性 964 亿', '乐观 1,000 亿'], width, height, grid: { bottom: 134 }, extraGraphics: nowGraphics(width, `现价 ${NOW} 元 = 中性 ${nowPe.toFixed(2)}x`), tooltipFormatter: (value) => `${Number(Array.isArray(value) ? value[1] : value).toFixed(2)}x`, extraNote: 'PE = 市值 ÷ 2026E 归母净利,与股价严格成正比;三档情景只差 ±6% 利润,落到 PE 上不足 1.7x\n' + '原表把 15.5–25x 细分为「合理偏低」与「合理」,此处并为一条色带;扣现金 PE 见图 2', }); } /** * 图 2:净现金让 PE 便宜了多少 —— 资料库原表第 2 节右图的图形版 * * 含现金 PE 与扣现金 PE 是两条**严格平行**的线,间距恒为 净现金 ÷ 2026E 净利 * (约 2.35x)—— 间距与股价无关,但折价占市值的比例随股价下降而放大 * (220 元 22.2% → 700 元 7.0%)。所以这张图要看的不是两条线的高低, * 而是中间那层沙色:它是一块不产生经营利润、但实实在在归股东的资产。 * * 实现说明:ECharts 没有"两个系列之间填充",只能用 stack —— 下段是扣现金 PE、 * 上段是差额,差额段线宽 0、面积填沙色,上沿即含现金 PE。stack 的叠放顺序不能改 * (改了基准就错),于是用 z 控制绘制层级,防止沙色覆盖掉扣现金那条实线。 */ export function sweetCatlNetCashChart(rows, { width, height }) { const gap = rows[0].pe_base - rows[0].pe_ex_cash; const nowEx = metricAt(rows, NOW, 'pe_ex_cash'); const cashLine = { ...lineSeries({ name: '扣现金 PE', data: [], color: PALETTE.brown, width: 3.5 }), label: { show: false }, stack: 'pe', z: 3, areaStyle: { opacity: 0 }, data: rows.map((row) => [row.price, row.pe_ex_cash]), }; cashLine.markLine = nowMarkLine(); cashLine.markArea = { silent: true, data: peBandMarkAreas() }; const band = { ...lineSeries({ name: '净现金差额', data: [], color: PALETTE.sand, width: 0 }), label: { show: false }, stack: 'pe', z: 2, lineStyle: { color: PALETTE.sand, width: 0, opacity: 0 }, itemStyle: { color: PALETTE.sand }, showSymbol: false, // opacity 必须显式写:areaStyle 的默认 opacity 是 0.7,只给 rgba 底色会被再乘一次 areaStyle: { color: PALETTE.sand, opacity: 0.7 }, data: rows.map((row) => [row.price, row.pe_base - row.pe_ex_cash]), }; const gross = { ...lineSeries({ name: '含现金 PE', data: [], color: FINANCIAL_COLORS.investingCashFlow, width: 3 }), label: { show: false }, z: 4, data: rows.map((row) => [row.price, row.pe_base]), }; return baseOption({ title: '净现金让 PE 便宜了多少', subtitle: `单位:倍 · 中性 964 亿口径 · 沙色带即净现金 ${NET_CASH.toLocaleString('zh-CN')} 亿元对应的 PE`, xAxis: priceAxis(), yAxis: valueAxis('PE(倍)', { min: PE_MIN, max: PE_MAX, interval: 5 }), series: [cashLine, band, gross], // 「净现金差额」不进图例(线宽 0,图例色块会是空的),靠副标题与脚注交代 legend: ['含现金 PE', '扣现金 PE'], width, height, grid: { bottom: 134 }, extraGraphics: nowGraphics(width, `现价 ${NOW} 元 = 扣现金 ${nowEx.toFixed(2)}x`), tooltipFormatter: (value) => `${Number(Array.isArray(value) ? value[1] : value).toFixed(2)}x`, extraNote: '含现金 PE = 市值 ÷ 2026E 净利;扣现金 PE =(市值 − 净现金)÷ 2026E 净利,即只给主营电池业务定价\n' + `两条线严格平行:差额恒为 ${gap.toFixed(2)}x,但占市值比例从 220 元的 22.2% 收窄到 700 元的 7.0% —— 股价越低,这层折价越厚`, }); } /** * 图 3:任意股价试算 —— 资料库原表第 5 节计算器的图形版 * * 计算器要输入一个价格才出结果,图表反过来:220–700 元全档都画出来,任意价位 * 沿竖线直接读。两条线是同一个目标价(605 元 = 2028E 1,400 亿 × 20x)下的三年 * 年化收益,差额即 50% 分红率的贡献。纵轴穿过 0 的位置就是"三年白干"—— 正好 * 是 605 元这条卖点线,所以零线本身就是一条结论。 * * 纵向底带把估值区域直接画在股价轴上:横带看 PE、竖带看股价,是同一套档位。 * 击球区上沿 270.9 元、买入区上沿 322.9 元、合理区上沿 520.9 元(由 pe_base 反解)。 */ export function sweetCatlAnyPriceChart(rows, { width, height }) { const hero = priceLine({ name: '含分红年化', rows, key: 'cagr_total', color: FINANCIAL_COLORS.netProfit, width: 3.5, labeledPrices: [265, NOW, 450, TARGET_2028], unit: '%', decimals: 1, position: 'top', distance: 10, }); hero.markLine = nowMarkLine(); // markArea 挂在第一个系列上,否则会盖住同图上先声明的折线 hero.markArea = { silent: true, data: priceBandMarkAreas(rows) }; const priceOnly = priceLine({ name: '仅价差年化', rows, key: 'cagr_price', color: FINANCIAL_COLORS.investingCashFlow, width: 2.6, labeledPrices: [265, NOW, 450], unit: '%', decimals: 1, position: 'bottom', distance: 10, }); return baseOption({ title: '任意股价试算:三年预期年化收益', subtitle: '单位:% · 目标价 605 元(2028E 1,400 亿 × 20x)· 纵带为估值区域', xAxis: priceAxis(), yAxis: valueAxis('年化收益(%)', { min: -10, max: 50, interval: 10 }), series: [hero, priceOnly], legend: ['含分红年化', '仅价差年化'], width, height, grid: { bottom: 134 }, extraGraphics: nowGraphics(width, `现价 ${NOW} 元 = 含分红年化 ${metricAt(rows, NOW, 'cagr_total').toFixed(1)}%`), tooltipFormatter: (value) => `${Number(Array.isArray(value) ? value[1] : value).toFixed(1)}%`, extraNote: `年化收益 =(${TARGET_2028} ÷ 股价)^(1/3) − 1;含分红口径再乘 (1 + 股息率),股息按当前价每年派现、不复投\n` + '纵带边界由 pe_base 反解:击球区上沿 270.9 元 | 买入区上沿 322.9 元 | 合理区上沿 520.9 元', }); }