Files
smart-fin-chart/js/charts/sweet_spot.js
T
2026-09-21 17:45:21 +08:00

422 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.
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 元',
});
}