feat: 增加宁德时代图表

This commit is contained in:
R524809
2026-09-21 17:45:21 +08:00
parent a15b3f8027
commit 3a3d19d5dd
24 changed files with 1903 additions and 5 deletions
+1040
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -46,6 +46,15 @@ import {
moutaiBaseWineVolumeChart, moutaiFlyingPriceChart, moutaiPriceAdjustmentChart,
moutaiTonPriceChart, moutaiDividendChart, moutaiDistributorsChart,
} from './moutai.js';
import {
peerInstallChart, peerUtilizationChart, peerShareChart,
peerGpPerWhChart, peerAspChart, peerMarginChart, peer2026h1Chart,
peerGpGapChart, peerGpChangeChart, catlUnitEconomicsChart, catlVolumeGpChart,
globalCrChart, globalTop10Chart, globalRegionShareChart,
} from './catl.js';
import {
sweetCatlPricePeChart, sweetCatlNetCashChart, sweetCatlAnyPriceChart,
} from './sweet_spot.js';
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
export const chartRegistry = {
@@ -114,6 +123,23 @@ export const chartRegistry = {
'moutai-ton-price': moutaiTonPriceChart,
'moutai-dividend': moutaiDividendChart,
'moutai-distributors': moutaiDistributorsChart,
'catl-unit-economics': catlUnitEconomicsChart,
'catl-volume-gp': catlVolumeGpChart,
'peer-install': peerInstallChart,
'peer-utilization': peerUtilizationChart,
'peer-share': peerShareChart,
'peer-gp-per-wh': peerGpPerWhChart,
'peer-asp': peerAspChart,
'peer-margin': peerMarginChart,
'peer-2026h1': peer2026h1Chart,
'peer-gp-gap': peerGpGapChart,
'peer-gp-change': peerGpChangeChart,
'global-cr': globalCrChart,
'global-top10': globalTop10Chart,
'global-region-share': globalRegionShareChart,
'sweet-catl-price-pe': sweetCatlPricePeChart,
'sweet-catl-net-cash': sweetCatlNetCashChart,
'sweet-catl-any-price': sweetCatlAnyPriceChart,
};
export function getChartBuilder(key) {
+421
View File
@@ -0,0 +1,421 @@
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 元',
});
}