diff --git a/css/style.css b/css/style.css index 3403547..9a05246 100644 --- a/css/style.css +++ b/css/style.css @@ -53,11 +53,29 @@ button { background: var(--accent); border-color: var(--accent); border-radius: 50%; - font-size: 22px; - line-height: 1; } -.reload-button:hover { opacity: .88; transform: rotate(-45deg); } +/* 图标继承按钮的 color(currentColor),尺寸只在这里定义,换图标不用动 JS。 + 20px 是配 36px 圆钮的光学最优值:新图标的弧线比原 ↻ 字形细,19px 会显轻。 */ +.reload-button .reload-icon { + display: block; + width: 20px; + height: 20px; + fill: currentColor; +} + +/* 悬停转一圈:刷新语义的标准反馈,比原先 ↻ 字形的 -45° 倾斜更贴合新图标 */ +.reload-button:hover { opacity: .88; } +.reload-button:hover .reload-icon { animation: reload-spin .6s ease-in-out; } + +@keyframes reload-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .reload-button:hover .reload-icon { animation: none; } +} .export-button:hover { color: var(--accent); border-color: var(--accent); } /* ── 合并队列(购物车)────────────────────────────── */ diff --git a/data/catl/catl_peer_2026h1.csv b/data/catl/catl_peer_2026h1.csv new file mode 100644 index 0000000..bade0cb --- /dev/null +++ b/data/catl/catl_peer_2026h1.csv @@ -0,0 +1,7 @@ +company,gp_per_wh,margin_data +宁德时代,0.122,20.63 +比亚迪*,0.097,21.00 +LG新能源†,-0.010,-0.70 +中创新航*,0.077,16.71 +国轩高科*,0.063,14.02 +亿纬锂能,0.078,16.19 diff --git a/data/catl/catl_peer_asp.csv b/data/catl/catl_peer_asp.csv new file mode 100644 index 0000000..a9b2cc7 --- /dev/null +++ b/data/catl/catl_peer_asp.csv @@ -0,0 +1,7 @@ +period,catl,byd,calb,guoxuan,eve +2021,0.784,0.720,0.660,0.683, +2022,0.978,0.800,0.872,0.840, +2023,0.889,0.700,0.618,0.720, +2024,0.664,0.550,0.496,0.626, +2025,0.585,0.470,0.459,0.473,0.516 +2026H1,0.591,0.460,0.459,0.452,0.483 diff --git a/data/catl/catl_peer_gp_change.csv b/data/catl/catl_peer_gp_change.csv new file mode 100644 index 0000000..1f1b9d5 --- /dev/null +++ b/data/catl/catl_peer_gp_change.csv @@ -0,0 +1,7 @@ +company,gp_2023,gp_2026h1 +宁德时代,0.198,0.122 +比亚迪*,0.154,0.097 +LG新能源†,0.119,-0.010 +中创新航*,0.080,0.077 +国轩高科*,0.112,0.063 +亿纬锂能,,0.078 diff --git a/data/catl/catl_peer_gp_gap.csv b/data/catl/catl_peer_gp_gap.csv new file mode 100644 index 0000000..2675881 --- /dev/null +++ b/data/catl/catl_peer_gp_gap.csv @@ -0,0 +1,7 @@ +period,catl,peer_avg,gap_multiple +2021,0.172,0.080,2.15 +2022,0.168,0.118,1.42 +2023,0.198,0.096,2.06 +2024,0.159,0.087,1.83 +2025,0.139,0.075,1.85 +2026H1,0.122,0.073,1.68 diff --git a/data/catl/catl_peer_gp_per_wh.csv b/data/catl/catl_peer_gp_per_wh.csv new file mode 100644 index 0000000..db5415b --- /dev/null +++ b/data/catl/catl_peer_gp_per_wh.csv @@ -0,0 +1,7 @@ +period,catl,byd,lges,calb,guoxuan,eve +2021,0.172,0.144,0.067,0.037,0.123, +2022,0.168,0.144,0.091,0.090,0.147, +2023,0.198,0.154,0.119,0.080,0.112, +2024,0.159,0.121,0.032,0.079,0.095, +2025,0.139,0.103,0.068,0.077,0.068,0.080 +2026H1,0.122,0.097,-0.010,0.077,0.063,0.078 diff --git a/data/catl/catl_peer_install.csv b/data/catl/catl_peer_install.csv new file mode 100644 index 0000000..74cf2a1 --- /dev/null +++ b/data/catl/catl_peer_install.csv @@ -0,0 +1,7 @@ +period,catl,byd,lges,calb,guoxuan,global_total +2021,99.5,26.4,59.4,8.0,6.7,301.5 +2022,191.6,70.4,70.4,20.0,14.1,517.9 +2023,259.7,111.4,95.8,33.4,17.1,705.5 +2024,339.3,153.7,96.3,39.4,28.5,894.4 +2025,464.7,194.8,108.8,62.8,53.5,1187.0 +2026H1,242.7,87.7,52.6,31.2,28.0,608.5 diff --git a/data/catl/catl_peer_install_share.csv b/data/catl/catl_peer_install_share.csv new file mode 100644 index 0000000..1a9cf54 --- /dev/null +++ b/data/catl/catl_peer_install_share.csv @@ -0,0 +1,7 @@ +period,catl,byd,lges,calb,guoxuan +2021,33.0,8.7,19.7,2.6,2.2 +2022,37.0,13.6,13.6,3.9,2.7 +2023,36.8,15.8,13.6,4.7,2.4 +2024,37.9,17.2,10.8,4.4,3.2 +2025,39.2,16.4,9.2,5.3,4.5 +2026H1,39.9,14.4,8.6,5.1,4.6 diff --git a/data/catl/catl_peer_margin.csv b/data/catl/catl_peer_margin.csv new file mode 100644 index 0000000..229fbed --- /dev/null +++ b/data/catl/catl_peer_margin.csv @@ -0,0 +1,7 @@ +period,catl,byd,lges,calb,guoxuan,eve +2021,22.00,20.00,4.31,5.60,18.00, +2022,17.17,18.00,4.74,10.32,17.50, +2023,22.27,22.00,6.41,13.00,15.50, +2024,23.94,22.00,2.25,15.89,15.14, +2025,23.84,22.00,5.69,16.69,14.39,15.50 +2026H1,20.63,21.00,-0.70,16.71,14.02,16.19 diff --git a/data/catl/catl_peer_utilization.csv b/data/catl/catl_peer_utilization.csv new file mode 100644 index 0000000..ef9ec47 --- /dev/null +++ b/data/catl/catl_peer_utilization.csv @@ -0,0 +1,7 @@ +period,catl,byd,lges,calb,guoxuan +2021,95.1,,73.4,95.2,41.0 +2022,83.3,71.0,73.6,93.8,46.0 +2023,70.5,72.0,69.3,75.6,48.0 +2024,76.3,70.0,57.8,78.7,46.0 +2025,96.9,82.0,47.6,,71.0 +2026H1,94.9,,52.8,, diff --git a/data/catl/catl_unit_economics.csv b/data/catl/catl_unit_economics.csv new file mode 100644 index 0000000..3be672d --- /dev/null +++ b/data/catl/catl_unit_economics.csv @@ -0,0 +1,7 @@ +period,sales_gwh,revenue_yi,asp,cost_per_wh,gp_per_wh,gross_margin +2021,116.7,914.9,0.784,0.612,0.172,22.00 +2022,242.0,2365.9,0.978,0.810,0.168,17.17 +2023,321.0,2852.5,0.889,0.691,0.198,22.27 +2024,381.0,2530.4,0.664,0.505,0.159,23.94 +2025,541.0,3165.1,0.585,0.446,0.139,23.84 +2026H1,325,1921.2,0.591,0.469,0.122,20.63 diff --git a/data/catl/global_region_share.csv b/data/catl/global_region_share.csv new file mode 100644 index 0000000..adb071c --- /dev/null +++ b/data/catl/global_region_share.csv @@ -0,0 +1,7 @@ +period,china,korea,japan,others,global_total +2021,48.2,30.2,12.0,9.6,301.5 +2022,60.4,23.7,7.3,8.6,517.9 +2023,63.5,23.2,6.1,7.2,705.5 +2024,67.1,18.5,3.9,10.5,894.4 +2025,70.4,15.3,3.7,10.6,1187.0 +2026H1,72.4,11.7,3.7,12.2,608.5 diff --git a/data/catl/global_top10_2026h1.csv b/data/catl/global_top10_2026h1.csv new file mode 100644 index 0000000..3a4f27c --- /dev/null +++ b/data/catl/global_top10_2026h1.csv @@ -0,0 +1,11 @@ +rank,company,region,gwh,share,yoy,global_total +1,宁德时代,中国,242.7,39.9,25.3,608.5 +2,比亚迪,中国,87.7,14.4,1.6,608.5 +3,LG新能源,韩国,52.6,8.6,8.4,608.5 +4,中创新航,中国,31.2,5.1,39.5,608.5 +5,国轩高科,中国,28.0,4.6,43.3,608.5 +6,松下,日本,22.7,3.7,10.2,608.5 +7,亿纬锂能,中国,20.9,3.4,51.7,608.5 +8,SK On,韩国,19.0,3.1,-6.7,608.5 +9,蜂巢能源,中国,15.7,2.6,40.9,608.5 +10,欣旺达,中国,14.5,2.4,13.7,608.5 diff --git a/data/catl/global_top10_install.csv b/data/catl/global_top10_install.csv new file mode 100644 index 0000000..7831187 --- /dev/null +++ b/data/catl/global_top10_install.csv @@ -0,0 +1,7 @@ +period,catl,byd,lges,panasonic,sk_on,samsung_sdi,calb,guoxuan,eve,sunwoda,svolt,farasis,global_total +2021,99.5,26.4,59.4,36.3,17.3,14.5,8.0,6.7,,2.6,,2.4,301.5 +2022,191.6,70.4,70.4,38.0,27.8,24.3,20.0,14.1,,9.2,,7.4,517.9 +2023,259.7,111.4,95.8,42.8,34.7,33.1,33.4,17.1,16.2,10.5,,,705.5 +2024,339.3,153.7,96.3,35.1,39.0,29.6,39.4,28.5,20.3,18.8,,,894.4 +2025,464.7,194.8,108.8,44.2,44.5,28.9,62.8,53.5,31.3,,28.5,,1187.0 +2026H1,242.7,87.7,52.6,22.7,19.0,,31.2,28.0,20.9,14.5,15.7,,608.5 diff --git a/data/catl/global_top10_share.csv b/data/catl/global_top10_share.csv new file mode 100644 index 0000000..93ae203 --- /dev/null +++ b/data/catl/global_top10_share.csv @@ -0,0 +1,7 @@ +period,catl,byd,lges,panasonic,sk_on,samsung_sdi,calb,guoxuan,eve,sunwoda,svolt,farasis,cr3,cr5,cr10,china_top10_count,china_share +2021,33.0,8.7,19.7,12.0,5.7,4.8,2.6,2.2,,0.9,,0.8,64.7,79.1,90.4,6,48.2 +2022,37.0,13.6,13.6,7.3,5.4,4.7,3.9,2.7,,1.8,,1.4,64.2,76.9,91.4,6,60.4 +2023,36.8,15.8,13.6,6.1,4.9,4.7,4.7,2.4,2.3,1.5,,,66.2,77.2,92.8,6,63.5 +2024,37.9,17.2,10.8,3.9,4.4,3.3,4.4,3.2,2.3,2.1,,,65.9,74.7,89.5,6,67.1 +2025,39.2,16.4,9.2,3.7,3.7,2.4,5.3,4.5,2.6,,2.4,,64.8,74.6,89.4,6,70.4 +2026H1,39.9,14.4,8.6,3.7,3.1,,5.1,4.6,3.4,2.4,2.6,,62.9,72.6,87.8,7,72.4 diff --git a/data/sweetspot/catl_price_pe.csv b/data/sweetspot/catl_price_pe.csv new file mode 100644 index 0000000..e30917f --- /dev/null +++ b/data/sweetspot/catl_price_pe.csv @@ -0,0 +1,36 @@ +# 由 scripts/build_sweet_spot_catl.py 生成,请勿手工编辑。 +# 口径:总股本 46.27 亿股 · 净现金 2,263.8 亿元(每股 48.93 元)· 2026E 归母 900/964/1,000 亿元 · 分红率 50% · 现价 316.21 元(2026-09-15) +# PE 与股息率保留两位小数(见脚本 docstring 的精度例外说明) +price,market_cap,pe_bear,pe_base,pe_bull,pe_ex_cash,dividend_yield,net_cash_pct +220.00,10179.4,11.31,10.56,10.18,8.21,4.74,22.2 +240.00,11104.8,12.34,11.52,11.10,9.17,4.34,20.4 +260.00,12030.2,13.37,12.48,12.03,10.13,4.01,18.8 +265.00,12261.6,13.62,12.72,12.26,10.37,3.93,18.5 +280.00,12955.6,14.40,13.44,12.96,11.09,3.72,17.5 +290.00,13418.3,14.91,13.92,13.42,11.57,3.59,16.9 +300.00,13881.0,15.42,14.40,13.88,12.05,3.47,16.3 +303.00,14019.8,15.58,14.54,14.02,12.20,3.44,16.1 +316.21,14631.0,16.26,15.18,14.63,12.83,3.29,15.5 +320.00,14806.4,16.45,15.36,14.81,13.01,3.26,15.3 +340.00,15731.8,17.48,16.32,15.73,13.97,3.06,14.4 +360.00,16657.2,18.51,17.28,16.66,14.93,2.89,13.6 +378.00,17490.1,19.43,18.14,17.49,15.79,2.76,12.9 +380.00,17582.6,19.54,18.24,17.58,15.89,2.74,12.9 +400.00,18508.0,20.56,19.20,18.51,16.85,2.60,12.2 +420.00,19433.4,21.59,20.16,19.43,17.81,2.48,11.6 +440.00,20358.8,22.62,21.12,20.36,18.77,2.37,11.1 +460.00,21284.2,23.65,22.08,21.28,19.73,2.26,10.6 +480.00,22209.6,24.68,23.04,22.21,20.69,2.17,10.2 +500.00,23135.0,25.71,24.00,23.14,21.65,2.08,9.8 +520.00,24060.4,26.73,24.96,24.06,22.61,2.00,9.4 +540.00,24985.8,27.76,25.92,24.99,23.57,1.93,9.1 +560.00,25911.2,28.79,26.88,25.91,24.53,1.86,8.7 +580.00,26836.6,29.82,27.84,26.84,25.49,1.80,8.4 +600.00,27762.0,30.85,28.80,27.76,26.45,1.74,8.2 +605.00,27993.4,31.10,29.04,27.99,26.69,1.72,8.1 +617.00,28548.6,31.72,29.61,28.55,27.27,1.69,7.9 +620.00,28687.4,31.87,29.76,28.69,27.41,1.68,7.9 +640.00,29612.8,32.90,30.72,29.61,28.37,1.63,7.6 +660.00,30538.2,33.93,31.68,30.54,29.33,1.58,7.4 +680.00,31463.6,34.96,32.64,31.46,30.29,1.53,7.2 +700.00,32389.0,35.99,33.60,32.39,31.25,1.49,7.0 diff --git a/data/sweetspot/catl_price_returns.csv b/data/sweetspot/catl_price_returns.csv new file mode 100644 index 0000000..a46470d --- /dev/null +++ b/data/sweetspot/catl_price_returns.csv @@ -0,0 +1,32 @@ +# 由 scripts/build_sweet_spot_catl.py 生成,请勿手工编辑。 +# 口径:总股本 46.27 亿股 · 净现金 2,263.8 亿元(每股 48.93 元)· 2026E 归母 900/964/1,000 亿元 · 分红率 50% · 现价 316.21 元(2026-09-15) +# PE 与股息率保留两位小数(见脚本 docstring 的精度例外说明) +price,market_cap,pe_base,dividend_yield,cagr_price,cagr_total +220.00,10179.4,10.56,4.74,40.1,46.7 +240.00,11104.8,11.52,4.34,36.1,42.0 +260.00,12030.2,12.48,4.01,32.5,37.8 +265.00,12261.6,12.72,3.93,31.7,36.9 +280.00,12955.6,13.44,3.72,29.3,34.1 +290.00,13418.3,13.92,3.59,27.8,32.4 +300.00,13881.0,14.40,3.47,26.3,30.7 +303.00,14019.8,14.54,3.44,25.9,30.3 +316.21,14631.0,15.18,3.29,24.1,28.2 +320.00,14806.4,15.36,3.26,23.7,27.7 +340.00,15731.8,16.32,3.06,21.2,24.9 +360.00,16657.2,17.28,2.89,18.9,22.3 +378.00,17490.1,18.14,2.76,17.0,20.2 +380.00,17582.6,18.24,2.74,16.8,20.0 +400.00,18508.0,19.20,2.60,14.8,17.8 +420.00,19433.4,20.16,2.48,12.9,15.7 +440.00,20358.8,21.12,2.37,11.2,13.8 +450.00,20821.5,21.60,2.31,10.4,12.9 +460.00,21284.2,22.08,2.26,9.6,12.0 +480.00,22209.6,23.04,2.17,8.0,10.4 +500.00,23135.0,24.00,2.08,6.6,8.8 +520.00,24060.4,24.96,2.00,5.2,7.3 +540.00,24985.8,25.92,1.93,3.9,5.9 +560.00,25911.2,26.88,1.86,2.6,4.5 +580.00,26836.6,27.84,1.80,1.4,3.2 +600.00,27762.0,28.80,1.74,0.3,2.0 +605.00,27993.4,29.04,1.72,0.0,1.7 +620.00,28687.4,29.76,1.68,-0.8,0.9 diff --git a/js/charts/catl.js b/js/charts/catl.js new file mode 100644 index 0000000..ea77326 --- /dev/null +++ b/js/charts/catl.js @@ -0,0 +1,1040 @@ +import { col } from '../csv.js'; +import { + PALETTE, FONT, axisCommon, categoryAxis, titleStyle, standardLegend, + lineSeries, barSeries, footnote, watermarks, LAYOUT, tooltipOf, +} from '../theme.js'; + +/** + * 动力电池同业对比图表(宁德时代 / 比亚迪 / LG新能源 / 中创新航 / 国轩高科 / 亿纬锂能) + * + * 三套口径,不要混用: + * + * 【A】规模与产能口径(图 1–3) + * 图 1 / 图 3:SNE Research 全球动力电池装车量与份额(GWh / %)—— + * 这是唯一对五家使用同一统计口径的公开数据,因此跨公司可比性最好。 + * 注意它统计的是"注册电动车实际装载的电池量",不等于厂商出货量/销量: + * 宁德 2025 年动力电池销量 541GWh,SNE 记 464.7GWh,差额来自未装车库存、 + * 非道路与售后等(公司口径销量见 catl_unit_economics.csv)。 + * 没有一家公司口径能覆盖这五家(比亚迪并表不披露、LGES 只有收入), + * 所以图 1、图 3 统一叫"装车量"口径,不叫"销量"。 + * 数据文件:catl_peer_install*.csv(原 catl_peer_sales*.csv,口径是装车量,已更名)。 + * 图 2:各公司自主披露的产能利用率(%)——**这里是三张图里唯一不可比的**。 + * 只有宁德(电池系统)、LG新能源(全球生产设施)、中创新航(生产基地)三家披露, + * 且三家的统计范围与分母定义都不同;比亚迪年报只披露车辆产销、国轩高科以 + * "产能利用率处于高位"定性带过,后两家的值为券商估算(名称带 *、柱体加斜纹)。 + * 因此这张图只能读各自的时间趋势,不能拿 70.5% 与 69.3% 的大小比强弱。 + * 数据文件:catl_peer_utilization.csv。 + * Top5 名单按 2026H1 SNE 装车量排名:宁德 / 比亚迪 / LG新能源 / 中创新航 / 国轩高科。 + * + * 【B】单位经济口径(图 4–11):公司定期报告的收入与毛利率 ÷ 各家销量估算。 + * + * 【C】全球前十口径(图 12–14):SNE Research 全球装车量前十榜单(GWh / 份额)。 + * 存在的理由:Top5 覆盖率从 2021 年 79.1% 掉到 2026H1 的 72.6%,CR10 同期 90.4%→87.8%, + * 两者缺口(= 第 6~10 名合计份额)从 11.3pp 扩大到 15.2pp —— 份额是从头部流向二线, + * 而不是流向长尾。只看 Top5 会把这 15 个百分点整个切掉,也就看不到当前唯一的增量叙事。 + * 样本量口径分工:结构性指标(装车量/份额/增速/集中度)全部用 Top10 —— + * 其中 2026H1 的装车量与同比增速原本是一张独立的 Top5 横截面图, + * 已并入图 13:同一时点、同一批公司拆成两张图只是重复。 + * 单位经济学指标(单 Wh 毛利/ASP/利润率)只能用 Top5 以内 —— + * 蜂巢、欣旺达、松下、三星SDI、SK On 都没有可比的"动力电池分部收入 + 动力电池销量"披露。 + * 数据文件:global_top10_*.csv / global_region_share.csv。 + * 展示顺序由 manifest 决定,图 12–14 位于同业对比 Tab 的第 4–6 位。 + * 口径自洽规则:份额合计一律用**已披露份额相加**,不用 GWh ÷ 总量现算 —— + * 官方公布的分区合计(中企 72.4%、日韩 15.4%)都是这么来的,GWh 现算会各自漂 0.1pp。 + * "第 6~10 名"另用 前十 − 前五 相减得出,保证三段数字相加自洽(72.6 + 15.2 = 87.8)。 + * + * 口径说明: + * - 宁德时代、亿纬锂能:年报分产品披露收入与毛利率,销量可得,单 Wh 值可直接计算 + * - 比亚迪:电池业务并入汽车分部、自供走内部转移定价,单 Wh 毛利为"估算 ASP × 估算毛利率" + * - LG新能源:只有全公司营业利润口径(含 ESS 与小电池、含北美 AMPC 补贴), + * 收入含非动力业务,故不出 ASP,单 Wh 值为营业利润口径而非毛利 + * - 中创新航:收入按动力 / 储能分列披露,销量为出货估算;**未披露分部毛利率**, + * 表中 16.69%(2025) / 16.71%(2026H1) 为公司整体毛利率(含储能系统),此处作动力代理值 + * - 国轩高科:动力收入与分部毛利率均披露,销量为出货估算 + * - 宁德时代 2026H1:半年报销量 434GWh,其中动力 325GWh、储能 109GWh + * 名称后缀 * 表示估算,† 表示营业利润口径。 + * + * 精度例外:单 Wh 值量级只有 0.03–0.20 元,一位小数无法区分(0.1 与 0.12 都是 0.1), + * 故 ASP / 单 Wh 成本 / 单 Wh 毛利统一保留三位小数,毛利率保留两位。 + */ + +/** 六家厂商固定配色:跨图一致,不随图表或年份变化 */ +const PEER_COLORS = { + catl: PALETTE.orange, + byd: PALETTE.navy, + lges: PALETTE.blue, + calb: PALETTE.green, + guoxuan: PALETTE.purple, + eve: PALETTE.coral, +}; + +const PEER_SERIES = [ + { key: 'catl', name: '宁德时代', color: PEER_COLORS.catl, main: true }, + { key: 'byd', name: '比亚迪*', color: PEER_COLORS.byd, dashed: true }, + { key: 'lges', name: 'LG新能源†', color: PEER_COLORS.lges, dashed: true }, + { key: 'calb', name: '中创新航*', color: PEER_COLORS.calb, dashed: true }, + { key: 'guoxuan', name: '国轩高科*', color: PEER_COLORS.guoxuan, dashed: true }, + { key: 'eve', name: '亿纬锂能', color: PEER_COLORS.eve }, +]; + +const SOURCE_NOTE = '数据来源:各公司定期报告、SNE Research;* 为估算口径,† 为营业利润口径'; + +const SOURCE_NOTE_SNE = '数据来源:SNE Research 全球动力电池装车量(2021–2026H1)· 五家同一口径,可直接比较'; + +/** + * 2026H1 横截面图用:公司名 → 语义色,与折线图保持一致。 + * 同时登记带 * / † 后缀的写法,避免两套命名各自维护色值。 + */ +const COMPANY_COLORS = { + '宁德时代': PEER_COLORS.catl, + '比亚迪': PEER_COLORS.byd, + 'LG新能源': PEER_COLORS.lges, + '中创新航': PEER_COLORS.calb, + '国轩高科': PEER_COLORS.guoxuan, + '亿纬锂能': PEER_COLORS.eve, + '比亚迪*': PEER_COLORS.byd, + 'LG新能源†': PEER_COLORS.lges, + '中创新航*': PEER_COLORS.calb, + '国轩高科*': PEER_COLORS.guoxuan, +}; + +/** 期间轴:2021–2025 为年度、2026H1 为半年,两者混排。 + * 不用 theme 的 periodAxis:它会把年度也简写成 21/22,与 26H1 并列时会被误读成半年数据。 + * 这里保留四位年份,只把半年简写为 26H1。 */ +function catlPeriodAxis(rows) { + return { + ...categoryAxis, + data: col(rows, 'period'), + axisLabel: { + ...categoryAxis.axisLabel, + formatter: (value) => String(value).replace(/^20(\d{2})H1$/, '$1H1'), + }, + }; +} + +function companyAxis(rows) { + return { ...categoryAxis, data: col(rows, 'company') }; +} + +const valueAxis = (name, extra = {}) => ({ + ...axisCommon, + type: 'value', + name, + nameLocation: 'middle', + nameGap: 52, + nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, + ...extra, +}); + +const percentAxis = (name, extra = {}) => ({ + ...valueAxis(name), + axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, + splitLine: { show: false }, + ...extra, +}); + +/** + * tooltip 已上移到 theme.js(tooltipOf):它是全站通用的「白心圆点导致默认 marker + * 隐形」修法,不再是本文件私有实现。新增图表直接从 theme.js 引入,不要再抄一份。 + */ + +const GRID = { left: 78, right: 78, top: LAYOUT.gridTop, bottom: 80 }; + +function baseOption({ + title, subtitle, xAxis, yAxis, series, legend, width, height, + tooltipFormatter, tooltipFormatters, extraNote, sourceNote, grid, +}) { + const notes = [sourceNote ?? SOURCE_NOTE]; + if (extraNote) notes.push(extraNote); + return { + backgroundColor: '#fff', + title: { ...titleStyle, text: title, subtext: subtitle }, + legend: standardLegend(legend, { itemGap: 20 }), + // 横向条形图需要更宽的左侧公司名留白与右侧数值标签留白,用 grid 覆盖; + // 其余图表沿用 GRID,保证与全站图表的绘图区间距一致。 + grid: { ...GRID, ...grid }, + tooltip: tooltipOf(series, tooltipFormatter, tooltipFormatters), + xAxis, + yAxis, + series, + graphic: [...watermarks(width, height), footnote(notes.join('\n') + ' | @Joey知行记')], + }; +} + +/** 只保留 CSV 中真实存在的厂商列,避免缺列画出空线 */ +function availableSeries(rows) { + return PEER_SERIES.filter((item) => item.key in (rows[0] || {})); +} + +/** 多厂商折线:宁德为主线并标注数值(一图一主角),其余靠图例与 tooltip */ +function peerLines(rows) { + return availableSeries(rows).map((item) => { + const width = item.main ? 3.5 : 2.5; + const line = lineSeries({ + name: item.name, + data: col(rows, item.key), + color: item.color, + width, + formatter: item.main ? (p) => (Number.isFinite(p.value) ? p.value.toFixed(3) : '') : undefined, + }); + return { + ...line, + lineStyle: { color: item.color, width, type: item.dashed ? 'dashed' : 'solid' }, + label: item.main ? line.label : { show: false }, + }; + }); +} + +/** + * 装机量口径的厂商序列(SNE Research)。 + * 与 PEER_SERIES 的区别:SNE 对五家使用同一统计口径(注册 EV 的电池装车量), + * 不存在估算或替代口径,因此名称不带 * / † 后缀,折线全部实线。 + */ +const SNE_SERIES = [ + { key: 'catl', name: '宁德时代', color: PEER_COLORS.catl, main: true }, + { key: 'byd', name: '比亚迪', color: PEER_COLORS.byd }, + { key: 'lges', name: 'LG新能源', color: PEER_COLORS.lges }, + { key: 'calb', name: '中创新航', color: PEER_COLORS.calb }, + { key: 'guoxuan', name: '国轩高科', color: PEER_COLORS.guoxuan }, +]; + +/** + * 图 2 用:产能利用率序列。 + * + * 与 SNE_SERIES 的分工:SNE 是"外部机构对五家同一口径",这里恰恰相反 —— + * 没有统一口径可用,每家是自己定义的分母。因此: + * - 披露产能利用率的三家(宁德 / LG新能源 / 中创新航)保持素名,柱体实心; + * - 不披露的两家取券商估算值,名称加 * 且柱体加白色斜纹。 + * 图例里的 * 太容易被忽略,而"披露值"与"估算值"并列在同一张图上是最容易误导的 + * 一类图表错误,所以再加一层斜纹做强制视觉区分。 + * 颜色仍取自 PEER_COLORS,与装车量 / 份额图保持跨图一致(同一家公司永远同一色)。 + */ +const UTIL_SERIES = [ + { key: 'catl', name: '宁德时代', color: PEER_COLORS.catl }, + { key: 'byd', name: '比亚迪*', color: PEER_COLORS.byd, estimated: true }, + { key: 'lges', name: 'LG新能源', color: PEER_COLORS.lges }, + { key: 'calb', name: '中创新航', color: PEER_COLORS.calb }, + { key: 'guoxuan', name: '国轩高科*', color: PEER_COLORS.guoxuan, estimated: true }, +]; + +const SOURCE_NOTE_UTIL = + '数据来源:各公司年报 / 中报、信用评级报告、券商研报(2021–2026H1)· * 为券商估算,非公司披露'; + +const oneDecimal = (value) => (Number.isFinite(value) ? value.toFixed(1) : '—'); + +/** + * 图 12 用:集中度边界配色。 + * + * CR3 ⊂ CR5 ⊂ CR10 是**嵌套**关系,不是三个并列分类 —— 所以用同一色系的三个明度层级 + * 编码,而不是三个不同色相。选琥珀/棕系的原因:宁德=橙、比亚迪=藏青、LG=蓝、 + * 中创新航=绿、国轩=紫,公司色谱已占满常用色相,再用绿/蓝会和紧邻的份额图撞语义。 + * 同时按线宽分出主次:CR5 加粗,因为"Top5 够不够用"正是这张图要回答的问题。 + */ +const CR_SERIES = [ + { key: 'cr3', name: 'CR3(前三家)', color: '#6b4a2b', width: 2.5 }, + { key: 'cr5', name: 'CR5(前五家)', color: '#a8762c', width: 3.5, main: true }, + { key: 'cr10', name: 'CR10(前十家)', color: '#cfae7e', width: 2.5 }, +]; + +/** + * 图 13/图 14 用:国别语义色。 + * + * 两张图(横截面排名 + 历史归一化堆叠)共用这一套色,构成"同一编码 + 位置紧邻"的一对; + * 中国沿用站内既有的 #3F7FBE(与泡泡玛特分地区图的"中国"一致),保证跨公司跨页不换色。 + * 其余为"其他",是残差项,按规范用低饱和中性色。 + * + * 注意:横截面图按国别着色、不按公司着色 —— 全站按公司着色的图(单 Wh 毛利 / ASP / 毛利率) + * 用的是另一套色系,故两张国别图在 manifest 中排在相邻位置、并与公司着色图隔开一张 + * (图 12 的琥珀色 CR 系列夹在中间,正好充当这条隔离带)。 + */ +const REGION_COLORS = { + 中国: '#3F7FBE', + 韩国: '#E0A458', + 日本: '#C97B84', +}; + +const REGION_SERIES = [ + { key: 'china', name: '中国', color: REGION_COLORS['中国'], insideText: '#fff' }, + { key: 'korea', name: '韩国', color: REGION_COLORS['韩国'], insideText: PALETTE.ink }, + { key: 'japan', name: '日本', color: REGION_COLORS['日本'], insideText: '#fff' }, + { key: 'others', name: '其他', color: '#C9C2B6', insideText: PALETTE.ink }, +]; + +const SOURCE_NOTE_GLOBAL = '数据来源:SNE Research 全球动力电池装车量(前十榜单,2021–2026H1)'; + +/** 段高不足约 14px 时放不下 11px 段内标签,需外置 */ +const MIN_REGION_INSIDE_PX = 14; + +/** + * 图 1 用:装车量分组柱,**逐柱**标注数值(不是只标宁德)。 + * + * 5 家 × 6 期 = 30 根柱,一格内并排 5 根、每根约 20px 宽, + * 而 "464.7" 横排约 32px —— 直排必然互相压住,故标签旋转 90°。 + * 标签取各自系列色:一图一主角的规矩下,宁德的橙色数值仍是最先被看到的。 + */ +function volumeBars(rows) { + return SNE_SERIES + .filter((item) => item.key in (rows[0] || {})) + .map((item) => ({ + ...barSeries({ + name: item.name, + data: col(rows, item.key), + color: item.color, + formatter: (p) => oneDecimal(p.value), + }), + label: { + show: true, + position: 'top', + rotate: 90, + align: 'left', + verticalAlign: 'middle', + distance: 4, + fontFamily: FONT, + fontSize: 11, + color: item.color, + formatter: (p) => oneDecimal(p.value), + overflow: 'none', + }, + })); +} + +/** + * 图 2 用:产能利用率分组柱,逐柱标注数值。 + * + * 与 volumeBars 同构(5 家 × 6 期 = 30 根柱,一格内并排 5 根、每根约 20px), + * 因此沿用 rotate:90 + align:'left' + verticalAlign:'middle' 的竖排标签 —— + * 横排的 "96.9" 约 24px、会比柱还宽而互相压住,竖排只占约 9px。 + * 差别只有两处:格式化成百分比;估算序列叠一层白色斜纹(见 UTIL_SERIES 注释)。 + * 数值都是三位以内有效数字,"96.9" 竖排高度比装车量图的 "464.7" 更短,余量更宽松。 + */ +function utilBars(rows) { + const pct = (p) => (Number.isFinite(p.value) ? p.value.toFixed(1) : ''); + return UTIL_SERIES + .filter((item) => item.key in (rows[0] || {})) + .map((item) => ({ + ...barSeries({ + name: item.name, + data: col(rows, item.key), + color: item.color, + formatter: pct, + }), + // 估算序列加斜纹底纹:与"公司披露"并列在同一张图上时必须一眼可分 + ...(item.estimated + ? { + itemStyle: { + color: item.color, + decal: { + symbol: 'rect', + dashArrayX: [1, 0], + dashArrayY: [3, 4], + rotation: -Math.PI / 4, + color: 'rgba(255,255,255,0.85)', + }, + }, + } + : {}), + label: { + show: true, + position: 'top', + rotate: 90, + align: 'left', + verticalAlign: 'middle', + distance: 4, + fontFamily: FONT, + fontSize: 11, + color: item.color, + formatter: pct, + overflow: 'none', + }, + })); +} + +/** + * 图 3 用:份额折线的逐点标签。 + * + * 朝向按"离最近邻居更远的一侧"定(先例:ppmt_margins.js 的 labeledData, + * 避免奇偶交替让相邻两条线相向靠拢)。 + * + * 在其上补一层错位:2022 年比亚迪与 LG新能源同为 13.6%,值并列时上下间隙也相同, + * 只按间隙定向会让两个标签落在同一点完全重合;对并列项逐级加大 distance 拉开。 + * label.position 不接受函数,只能逐点覆盖 data。 + */ +function shareLabels(rows, keys) { + return rows.map((row) => { + const ordered = keys + .filter((key) => Number.isFinite(row[key])) + .map((key) => ({ key, value: row[key] })) + .sort((a, b) => b.value - a.value); + + const byKey = {}; + ordered.forEach((item, index) => { + const gapUp = index > 0 ? ordered[index - 1].value - item.value : Infinity; + const gapDown = index < ordered.length - 1 ? item.value - ordered[index + 1].value : Infinity; + const tied = ordered.filter((other, i) => i < index && other.value === item.value).length; + byKey[item.key] = { + value: item.value, + label: { + position: gapUp >= gapDown ? 'top' : 'bottom', + distance: 8 + tied * 16, + }, + }; + }); + return byKey; + }); +} + +/** 图 3 用:份额多厂商折线,全部系列逐点标注数值,宁德主线加粗 */ +function shareLines(rows) { + const keys = SNE_SERIES.filter((item) => item.key in (rows[0] || {})).map((item) => item.key); + const labels = shareLabels(rows, keys); + const pct = (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(1)}%` : ''); + + return SNE_SERIES + .filter((item) => keys.includes(item.key)) + .map((item) => { + const width = item.main ? 3.5 : 2.5; + const line = lineSeries({ + name: item.name, + data: rows.map((_, index) => labels[index][item.key] ?? null), + color: item.color, + width, + formatter: pct, + }); + return { + ...line, + lineStyle: { color: item.color, width, type: 'solid' }, + // 5 条线挤在 2–5% 区间,字号降到 12 换取标签之间的净空;白描边已在 labelStyle 里 + label: { ...line.label, fontSize: 12 }, + }; + }); +} + +const three = (value) => (Number.isFinite(value) ? value.toFixed(3) : '—'); + +/** + * 图 1:Top5 动力电池装车量走势 —— 行业格局的绝对量基础 + * + * 口径:SNE Research 全球动力电池装车量(电池被注册电动车实际装载的量)。 + * 它不等于公司年报的出货量/销量:宁德 2025 年动力销量 541GWh,SNE 记 464.7GWh, + * 差额来自未装车库存、非道路与售后等。选它是因为只有它对五家使用同一统计口径。 + * 因此标题写"装车量"而不是"销量"。 + */ +export function peerInstallChart(rows, { width, height }) { + return baseOption({ + title: 'Top5 动力电池装车量走势', + subtitle: '单位:GWh · SNE Research 全球装车量口径(≠ 公司销量)· 2026H1 为半年数据', + // 轴顶取 500(最高柱 464.7 占 93%)而不是再留一档:竖排标签约需 40px, + // 500 的余量刚好够、且不把数值顶进图例区;留到 600 会让上半张图空掉一成四。 + yAxis: valueAxis('动力电池装车量(GWh)', { min: 0, max: 500, interval: 100 }), + xAxis: catlPeriodAxis(rows), + series: volumeBars(rows), + legend: SNE_SERIES.map((item) => item.name), + width, + height, + sourceNote: SOURCE_NOTE_SNE, + extraNote: '2021 年宁德时代为 SNE 修订后口径 99.5GWh;全球装车总量 2021–2025 分别为 301.5 / 517.9 / 705.5 / 894.4 / 1187.0GWh,2026H1 为 608.5GWh', + tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(1)} GWh`), + }); +} + +/** + * 图 2:Top5 动力电池产能利用率 —— "谁在满产、谁在闲置" + * + * 口径见文件头【A】组:这是同业对比 Tab 里唯一**不可横比**的一张。 + * 只有宁德(电池系统,含储能)、LG新能源(全球生产设施,按生产金额加权)、 + * 中创新航(生产基地)三家披露,三家的分母定义本来就不同;比亚迪年报只披露 + * 车辆产销,国轩高科以"产能利用率处于高位"定性带过,两家的值是券商估算。 + * 所以这张图的读法定位为"各家自己的时间趋势",不是"谁的利用率更高"。 + * 缺披露的年份留空(不是 0)—— 空白本身也是信息:披露质量本身就是竞争力差异。 + * + * 轴顶取 110 而不是 100:最高柱 96.9% 的竖排标签还要约 34px, + * 顶到 100 会把标签顶出绘图区(与装车量图取 500 而非 464.7 同一理由)。 + * 也没有画 100% 参考线 —— 它正好会穿过宁德的标签,不如脚注一句话清楚。 + */ +export function peerUtilizationChart(rows, { width, height }) { + const keys = UTIL_SERIES.filter((item) => item.key in (rows[0] || {})); + return baseOption({ + title: 'Top5 动力电池产能利用率', + subtitle: '单位:% · 实心为公司披露(产量÷产能)· 斜纹 * 为券商估算', + xAxis: catlPeriodAxis(rows), + yAxis: percentAxis('产能利用率(%)', { min: 0, max: 110, interval: 10 }), + series: utilBars(rows), + legend: keys.map((item) => item.name), + width, + height, + // 来源 + 两行口径说明 = 三行脚注,默认 bottom:80 只够两行,会把横轴刻度压住 + grid: { bottom: 118 }, + sourceNote: SOURCE_NOTE_UTIL, + extraNote: '口径:宁德=电池系统(含储能)、LG新能源=全球生产设施、中创新航=生产基地,三家统计范围不同,不可精确横比\n' + + '比亚迪只披露车辆产销、国轩高科为定性表述,故 * 值取自券商估算;空白 = 公司未披露该期数据', + tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(1)}%`), + }); +} + +/** 图 3:Top5 全球市占率走势 —— 份额在向头部集中还是被二线稀释 */ +export function peerShareChart(rows, { width, height }) { + return baseOption({ + title: 'Top5 动力电池全球市占率走势', + subtitle: '单位:% · 按 SNE Research 装车量计算 · 各期数值全标注 · 2026H1 为半年数据', + xAxis: catlPeriodAxis(rows), + yAxis: percentAxis('全球市占率(%)', { min: 0, max: 45, interval: 5 }), + series: shareLines(rows), + legend: SNE_SERIES.map((item) => item.name), + width, + height, + sourceNote: SOURCE_NOTE_SNE, + extraNote: '宁德份额 2021→2026H1 由 33.0% 升至 39.9%;LG新能源由 19.7% 降至 8.6%', + tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(1)}%`), + }); +} + +/** 图 4:Top6 动力电池单 Wh 毛利走势 —— 护城河是否收敛的直接读数 */ +export function peerGpPerWhChart(rows, { width, height }) { + return baseOption({ + title: 'Top6 动力电池单 Wh 毛利走势', + subtitle: '单位:元/Wh · 虚线为估算或营业利润口径 · 实线为披露口径', + xAxis: catlPeriodAxis(rows), + yAxis: valueAxis('单 Wh 毛利(元/Wh)', { min: -0.04, max: 0.24, interval: 0.04 }), + series: peerLines(rows), + legend: availableSeries(rows).map((item) => item.name), + width, + height, + tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(3)} 元/Wh`), + }); +} + +/** 图 5:Top6 动力电池 ASP —— 验证宁德是"高价高利"还是"低价走量" */ +export function peerAspChart(rows, { width, height }) { + return baseOption({ + title: 'Top6 动力电池 ASP 对比', + subtitle: '单位:元/Wh · 虚线为估算口径 · LG新能源收入含 ESS 与小电池,故不列入', + xAxis: catlPeriodAxis(rows), + yAxis: valueAxis('ASP(元/Wh)', { min: 0.3, max: 1.1, interval: 0.1 }), + series: peerLines(rows), + legend: availableSeries(rows).map((item) => item.name), + width, + height, + tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(3)} 元/Wh`), + }); +} + +/** 图 6:Top6 毛利率 / 营业利润率走势 */ +export function peerMarginChart(rows, { width, height }) { + return baseOption({ + title: 'Top6 动力电池毛利率 / 营业利润率', + subtitle: '单位:% · 中创新航为公司整体毛利率(分部未披露)· LG新能源为营业利润率(含北美 AMPC 补贴)', + xAxis: catlPeriodAxis(rows), + yAxis: percentAxis('毛利率 / 营业利润率(%)', { min: -5, max: 30, interval: 5 }), + series: peerLines(rows), + legend: availableSeries(rows).map((item) => item.name), + width, + height, + tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(2)}%`), + }); +} + +/** 图 7:2026H1 横截面 —— 柱为单 Wh 毛利,折线为毛利率 */ +export function peer2026h1Chart(rows, { width, height }) { + const bars = col(rows, 'gp_per_wh').map((value, index) => ({ + value, + itemStyle: { color: COMPANY_COLORS[rows[index].company] || PALETTE.grey }, + })); + + return baseOption({ + title: '2026H1 单 Wh 毛利与盈利能力', + subtitle: '柱:元/Wh(左轴)· 折线:毛利率或营业利润率(右轴)', + xAxis: companyAxis(rows), + yAxis: [ + valueAxis('单 Wh 毛利(元/Wh)', { min: -0.03, max: 0.15, interval: 0.03 }), + percentAxis('毛利率 / 营业利润率(%)', { min: -5, max: 26, interval: 5 }), + ], + series: [ + { + ...barSeries({ + name: '单Wh毛利', + data: bars, + color: PEER_COLORS.catl, + formatter: (p) => (Number.isFinite(p.value) ? p.value.toFixed(3) : '—'), + }), + barWidth: 34, + }, + { + ...lineSeries({ + name: '毛利率/营业利润率', + data: col(rows, 'margin_data'), + color: PALETTE.red, + formatter: (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(1)}%` : ''), + }), + yAxisIndex: 1, + z: 10, + }, + ], + legend: ['单Wh毛利', '毛利率/营业利润率'], + width, + height, + tooltipFormatter: (value) => (value == null ? '—' : String(value)), + }); +} + +/** 图 8:宁德 vs 二线均值 —— 单 Wh 毛利差距是否收敛 */ +export function peerGpGapChart(rows, { width, height }) { + return baseOption({ + title: '宁德时代 vs 二线厂均值:单 Wh 毛利差距', + subtitle: '柱:元/Wh(左轴)· 折线:宁德 ÷ 二线均值倍数(右轴)', + xAxis: catlPeriodAxis(rows), + yAxis: [ + valueAxis('单 Wh 毛利(元/Wh)', { min: 0, max: 0.24, interval: 0.04 }), + valueAxis('倍数(×)', { min: 0, max: 3, interval: 0.5, splitLine: { show: false } }), + ], + series: [ + { ...barSeries({ name: '宁德时代', data: col(rows, 'catl'), color: PEER_COLORS.catl, formatter: (p) => three(p.value) }), barWidth: 30 }, + { ...barSeries({ name: '二线厂均值', data: col(rows, 'peer_avg'), color: PALETTE.sage, formatter: (p) => three(p.value) }), barWidth: 30 }, + { + ...lineSeries({ + name: '宁德 / 二线均值', + data: col(rows, 'gap_multiple'), + color: PALETTE.red, + formatter: (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(2)}×` : ''), + }), + yAxisIndex: 1, + z: 10, + }, + ], + legend: ['宁德时代', '二线厂均值', '宁德 / 二线均值'], + width, + height, + extraNote: '二线均值含中创新航、国轩高科,2025 年起含亿纬锂能', + tooltipFormatter: (value) => (value == null ? '—' : String(value)), + }); +} + +/** 图 9:2026H1 vs 2023 —— 剔除锂价红利后的真实变化 */ +export function peerGpChangeChart(rows, { width, height }) { + const changes = rows.map((row) => { + const base = Number(row.gp_2023); + const latest = Number(row.gp_2026h1); + if (!Number.isFinite(base) || !Number.isFinite(latest) || base === 0) return null; + return Number(((latest - base) / Math.abs(base) * 100).toFixed(1)); + }); + + return baseOption({ + title: '动力电池单 Wh 毛利:2026H1 vs 2023', + subtitle: '柱:元/Wh(左轴)· 折线:较 2023 年变化幅度(右轴)', + xAxis: companyAxis(rows), + yAxis: [ + valueAxis('单 Wh 毛利(元/Wh)', { min: -0.03, max: 0.22, interval: 0.05 }), + percentAxis('较 2023 变化(%)', { min: -120, max: 20, interval: 20 }), + ], + series: [ + { ...barSeries({ name: '2023', data: col(rows, 'gp_2023'), color: PALETTE.sand, formatter: (p) => three(p.value) }), barWidth: 24 }, + { ...barSeries({ name: '2026H1', data: col(rows, 'gp_2026h1'), color: PEER_COLORS.catl, formatter: (p) => three(p.value) }), barWidth: 24 }, + { + ...lineSeries({ + name: '较2023变化', + data: changes, + color: PALETTE.red, + formatter: (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(0)}%` : ''), + }), + yAxisIndex: 1, + z: 10, + }, + ], + legend: ['2023', '2026H1', '较2023变化'], + width, + height, + extraNote: '亿纬锂能 2023 年数据未披露', + tooltipFormatter: (value) => (value == null ? '—' : String(value)), + }); +} + +/** 图 10:宁德时代自身单位经济 —— ASP、单 Wh 成本与单 Wh 毛利 */ +export function catlUnitEconomicsChart(rows, { width, height }) { + return baseOption({ + title: '宁德时代动力电池单 Wh 经济性', + subtitle: '单位:元/Wh · ASP(Average Selling Price,不含税平均售价)= 动力电池系统收入 ÷ 动力电池销量', + xAxis: catlPeriodAxis(rows), + yAxis: [ + valueAxis('ASP / 单 Wh 成本(元/Wh)', { min: 0, max: 1.1, interval: 0.1 }), + valueAxis('单 Wh 毛利(元/Wh)', { min: 0, max: 0.24, interval: 0.04, splitLine: { show: false } }), + ], + series: [ + { ...barSeries({ name: 'ASP', data: col(rows, 'asp'), color: PALETTE.navy, formatter: (p) => three(p.value) }), barWidth: 26 }, + { ...barSeries({ name: '单Wh成本', data: col(rows, 'cost_per_wh'), color: PALETTE.sand, formatter: (p) => three(p.value) }), barWidth: 26 }, + { + ...lineSeries({ + name: '单Wh毛利', + data: col(rows, 'gp_per_wh'), + color: PEER_COLORS.catl, + formatter: (p) => (Number.isFinite(p.value) ? p.value.toFixed(3) : ''), + }), + yAxisIndex: 1, + z: 10, + }, + ], + legend: ['ASP', '单Wh成本', '单Wh毛利'], + width, + height, + extraNote: 'ASP 与成本均按不含税口径;单 Wh 成本 = ASP − 单 Wh 毛利 · 2026H1 为半年数据', + tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(3)} 元/Wh`), + }); +} + +/** 图 11:销量与单 Wh 毛利 —— 规模扩张是否以单位盈利为代价 */ +export function catlVolumeGpChart(rows, { width, height }) { + return baseOption({ + title: '宁德时代动力电池销量与单 Wh 毛利', + subtitle: '柱:销量(GWh,左轴)· 折线:单 Wh 毛利(元/Wh,右轴)· 2026H1 为半年数据', + xAxis: catlPeriodAxis(rows), + yAxis: [ + valueAxis('销量(GWh)', { min: 0, max: 600, interval: 100 }), + valueAxis('单 Wh 毛利(元/Wh)', { min: 0, max: 0.24, interval: 0.04, splitLine: { show: false } }), + ], + series: [ + { ...barSeries({ name: '动力电池销量', data: col(rows, 'sales_gwh'), color: PALETTE.sage, formatter: (p) => (Number.isFinite(p.value) ? p.value.toFixed(0) : '—') }), barWidth: 40 }, + { + ...lineSeries({ + name: '单Wh毛利', + data: col(rows, 'gp_per_wh'), + color: PEER_COLORS.catl, + formatter: (p) => (Number.isFinite(p.value) ? p.value.toFixed(3) : ''), + }), + yAxisIndex: 1, + z: 10, + }, + ], + legend: ['动力电池销量', '单Wh毛利'], + width, + height, + tooltipFormatter: (value) => (value == null ? '—' : String(value)), + }); +} + +/** + * 图 12:全球动力电池装车量集中度 CR3 / CR5 / CR10。 + * + * 存在的理由:Top5 覆盖率从 2021 年 79.2% 掉到 2026H1 的 72.7%,CR10 同期 90.4%→87.8%, + * 两者的缺口(= 第 6~10 名合计份额)从 11.2pp 扩大到 15.1pp。也就是说份额是从头部 + * 流向二线,而不是流向长尾 —— 只看 Top5 会把这 15 个百分点整个切掉。 + * 这条曲线就是"该不该把样本从 Top5 扩到 Top10"的读数依据。 + */ +export function globalCrChart(rows, { width, height }) { + return baseOption({ + title: '全球动力电池装车量集中度', + subtitle: '单位:% · CRn = 前 n 家厂商合计份额,按 SNE Research 装车量计算 · 2026H1 为半年数据', + xAxis: catlPeriodAxis(rows), + // 下限取 60 而非 0:三条线都在 62–93% 区间,从 0 起画会把全部变化压成一条平线 + yAxis: valueAxis('前 n 家合计份额(%)', { + min: 60, + max: 100, + interval: 5, + axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, + }), + series: CR_SERIES.map((item) => lineSeries({ + name: item.name, + data: col(rows, item.key), + color: item.color, + width: item.width, + formatter: (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(1)}%` : ''), + })), + legend: CR_SERIES.map((item) => item.name), + width, + height, + sourceNote: SOURCE_NOTE_GLOBAL, + extraNote: 'CR5 与 CR10 缺口(= 第 6~10 名合计)由 2021 年 11.3pp 升至 2026H1 15.2pp,份额由头部流向二线而非长尾', + tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(1)}%`), + }); +} + +/** + * 图 13:2026H1 全球装车量前十排名,带同比增速。 + * + * 按国别着色而不是按公司着色:这张图要回答的是"中国 7 席 vs 日韩 3 席", + * 及"Top5 边界切在哪里、切掉的是什么"。公司名已在纵轴上,颜色不必再承担识别功能。 + * (因此它与图 14 共用国别色谱,并在 manifest 中与公司着色的图隔开一张。) + * + * 同比增速用**顶部百分比轴的折线**承载(原独立横截面图「2026H1 装车量与同比增速」已并入本图): + * 同一批公司、同一个 2026H1 时点拆成两张图只是重复;合并后一行读完"量、份额、增速"。 + * + * 增速数值写进柱标签第二行(红字),而不是标在折线点上 —— 横向排名图里折线会横穿所有柱体, + * 点标签容易与柱标签、Top5 分界文字抢位置;写进柱标签则与编号同处一行,结构性不打架。 + * 顶部轴下限取 -10 而非 0:SK On 是前十中唯一的负增长(-6.7%),从 0 起画会被直接截掉, + * 另配一条 0% 虚线参考线,否则读者分不清"点偏左"是低增速还是负增长。 + */ +export function globalTop10Chart(rows, { width, height }) { + const grid = { left: 96, right: 170, top: LAYOUT.gridTop, bottom: 118 }; + const sum = (list, key) => list.reduce((acc, row) => acc + (Number(row[key]) || 0), 0); + const total = Number(rows[0]?.global_total) || 0; + // 份额用 CSV 里的 share 列(SNE 已披露值)相加,不用 GWh ÷ 总量现算: + // 官方公布的分区合计(中企 72.4%、日韩 15.4%)都是按已披露份额相加得到的, + // GWh 现算会各自多漂 0.1pp,反而对不上官方口径。 + // 且"第 6~10 名"用 前十 − 前五 相减得出,保证三段数字自洽(72.6 + 15.2 = 87.8)。 + const top5 = rows.slice(0, 5); + const rest = rows.slice(5); + const top5Gwh = sum(top5, 'gwh'); + const restGwh = sum(rest, 'gwh'); + const top5Share = sum(top5, 'share'); + const top10Share = sum(rows, 'share'); + const restShare = top10Share - top5Share; + + // 分界线落在第 5、6 名之间的类别边界:boundaryGap 下的类别中心为 (i+0.5)×band, + // 故边界 = top + 5×band。用像素算而不是 markLine,避免依赖 ECharts 序数轴插值的实现细节。 + const band = (height - grid.top - grid.bottom) / rows.length; + const dividerY = grid.top + 5 * band; + + const GROWTH_MIN = -10; + const GROWTH_MAX = 60; + const VOLUME_MAX = 300; + const plotWidth = width - grid.left - grid.right; + const zeroX = grid.left + ((0 - GROWTH_MIN) / (GROWTH_MAX - GROWTH_MIN)) * plotWidth; + + // 柱宽按行高取比例,而不是钉一个像素值:改高度时柱宽跟着走,不会突然变得很细或顶满。 + const barWidth = Math.round(band * 0.62); + + // 增速圆点的像素位置:两个横轴共用同一块画布,圆点按百分比轴换算。 + const pxOfGrowth = (value) => grid.left + + ((Number(value) - GROWTH_MIN) / (GROWTH_MAX - GROWTH_MIN)) * plotWidth; + + const DOT_R = 4.5; // theme 里增速圆点 symbolSize 9,半宽即避让半径 + const LABEL_GAP = 8; // 标签距柱端的间距(label.distance) + const DOT_CLEAR = 6; // 标签与圆点之间的最小净空 + + const signedPct = (value) => (Number.isFinite(value) + ? `${value > 0 ? '+' : ''}${oneDecimal(value)}%` + : '—'); + + const bars = rows.map((row) => ({ + value: row.gwh, + itemStyle: { color: REGION_COLORS[row.region] || PALETTE.grey }, + })); + + // 增速圆点的像素位置,交给下面的 labelLayout 做碰撞避让。 + const growthDots = rows.map((row) => pxOfGrowth(row.yoy)); + + // 柱标签两行:第一行量 + 份额,第二行同比增速。 + // 第二行用折线同款红色(rich 分段着色),把"这个数字属于那条折线"这层语义带上。 + const barLabel = (p) => { + const row = rows[p.dataIndex]; + return `{vol|${oneDecimal(row.gwh)} GWh(${oneDecimal(row.share)}%)}` + + `\n{growth|同比 ${signedPct(row.yoy)}}`; + }; + + const option = baseOption({ + title: '2026H1 全球装车量前十与同比增速', + subtitle: '柱:装车量(GWh,含全球份额)· 圆点:同比增速(%,上轴)', + xAxis: [ + { + ...axisCommon, + type: 'value', + min: 0, + max: VOLUME_MAX, + interval: 50, + axisLabel: { ...axisCommon.axisLabel, formatter: '{value}' }, + }, + { + ...percentAxis('同比增速(%)', { + min: GROWTH_MIN, + max: GROWTH_MAX, + interval: 10, + position: 'top', + // 上轴无轴线,轴名贴到右端;nameGap 要大于轴标签的 margin + 字高(约 21px), + // 否则轴名会压在刻度标签上。右端同时避开了居中的图例。 + nameLocation: 'end', + nameGap: 30, + }), + }, + ], + yAxis: { ...categoryAxis, data: col(rows, 'company'), inverse: true }, + series: [ + { + ...barSeries({ + name: '装车量', + data: bars, + color: REGION_COLORS['中国'], + formatter: barLabel, + }), + barWidth, + label: { + show: true, + position: 'right', + distance: LABEL_GAP, + overflow: 'none', + rich: { + vol: { fontFamily: FONT, fontSize: 12, color: PALETTE.ink, lineHeight: 17 }, + growth: { fontFamily: FONT, fontSize: 12, color: PALETTE.red, lineHeight: 17 }, + }, + formatter: barLabel, + }, + // 柱端值标签与增速圆点共用同一块画布、却是两套横轴换算,短柱的标签会向右 + // 伸进低增速圆点的落点 —— 实测 LG(+8.4%)的圆点压在自己标签的「(8.6%)」上, + // 而圆点是白心填充,等于把那几个字直接挖掉。 + // 用 labelLayout 而不是自己算:这里能拿到 ECharts 真实测量出的标签矩形 + // (宽 104–122px,按字号估算会差十几个像素,估宽了就会无谓地推、估窄了还是压字)。 + labelLayout: (params) => { + const dotX = growthDots[params.dataIndex]; + const rect = params.labelRect; + if (!Number.isFinite(dotX) || !rect) return undefined; + // 圆点与标签矩形不相交就不动 —— 否则长柱标签会被无谓地推到圆点右侧 + const clear = dotX + DOT_R <= rect.x || dotX - DOT_R >= rect.x + rect.width; + if (clear) return undefined; + return { dx: dotX + DOT_R + DOT_CLEAR - rect.x }; + }, + }, + { + // 只留圆点、不连线。 + // + // 横向排名图的纵轴是"名次"而不是时间或连续量,把 10 家依次连起来等于宣称 + // 第 1 名的增速会"过渡"到第 2 名 —— 而实际读数是 25.3 → 1.6 → 8.4 → 39.5 … + // 一条横穿全部柱体的锯齿线,既不表意又会盖住柱标签和 Top5 分界文字(实测截图确认)。 + // 圆点各自落在自己的行上,位置本身就已经完成了横向比较。 + // + // 数值不标在点上:柱标签第二行已经写了(红字),点标签只会与柱标签抢位置。 + // 仍用 line 系列而不是 scatter,是为了沿用 theme 的空心圆样式与 tooltip 取色 + // (scatter 的 itemStyle.color 是 #fff,tooltip 的色点会变成白色而隐形)。 + ...lineSeries({ + name: '同比增速', + data: col(rows, 'yoy'), + color: PALETTE.red, + width: 2.5, + formatter: (p) => signedPct(p.value), + }), + lineStyle: { color: PALETTE.red, width: 0, opacity: 0 }, + xAxisIndex: 1, + z: 10, + label: { show: false }, + }, + ], + legend: ['装车量', '同比增速'], + width, + height, + grid, + sourceNote: SOURCE_NOTE_GLOBAL, + extraNote: `2026H1 全球装车 ${oneDecimal(total)}GWh(+20.0%)· 前十合计占 ${oneDecimal(top10Share)}% · SK On 是前十中唯一负增长(-6.7%)\n中国 7 席(欣旺达重回榜单)· 韩国 2 席 · 日本 1 席;增速前三:亿纬 +51.7%、国轩 +43.3%、蜂巢 +40.9%`, + tooltipFormatters: { + '装车量': (value) => `${Number(value).toFixed(1)} GWh`, + '同比增速': (value) => signedPct(value), + }, + }); + + option.graphic.push( + { + // 0% 参考线。画在柱体之上、增速折线之下(z 5 < 10), + // 否则负增长与"低正增长"在视觉上没有分界。 + type: 'line', + silent: true, + z: 5, + shape: { x1: zeroX, y1: grid.top, x2: zeroX, y2: height - grid.bottom }, + style: { stroke: PALETTE.axisLine, lineWidth: 1, lineDash: [4, 4] }, + }, + { + type: 'line', + silent: true, + z: 100, + shape: { x1: grid.left, y1: dividerY, x2: width - grid.right, y2: dividerY }, + style: { stroke: PALETTE.red, lineWidth: 1.5, lineDash: [6, 5] }, + }, + { + type: 'text', + silent: true, + z: 100, + left: 360, + top: dividerY - 26, + style: { + text: `Top5 合计 ${oneDecimal(top5Gwh)} GWh · 占全球 ${oneDecimal(top5Share)}%`, + fill: PALETTE.red, + fontSize: 13, + fontFamily: FONT, + }, + }, + { + type: 'text', + silent: true, + z: 100, + left: 360, + top: dividerY + 8, + style: { + text: `第 6~10 名合计 ${oneDecimal(restGwh)} GWh · 占 ${oneDecimal(restShare)}%`, + fill: PALETTE.grey, + fontSize: 13, + fontFamily: FONT, + }, + }, + ); + return option; +} + +/** + * 图 14:中日韩厂商全球份额结构(归一化堆叠)。 + * + * 口径:各期全球装车量前十榜单中,该地区上榜企业的合计份额。中国 5/6/6/6/6/7 家, + * 韩国恒为 3 家(2026H1 起 2 家)、日本恒为松下 1 家。 + * + * 为什么仍保留"其他"段:只堆中韩日三段,柱高在 2021 年只有 90.4%,不是真 100%。 + * 加上残差段才是完整的归一化占比,也才能看出"其他"(含未进前十厂商)在 2026H1 抬头。 + * 但 2026H1 的"其他"里含掉出前十的三星SDI(约 2%),是口径断点,已在脚注标明。 + */ +export function globalRegionShareChart(rows, { width, height }) { + const periods = col(rows, 'period'); + const totals = rows.map((row) => Number(row.global_total) || 0); + const pxPerPct = (height - GRID.top - GRID.bottom) / 100; + const pctOf = (row, key) => { + const all = REGION_SERIES.reduce((acc, item) => acc + (Number(row[item.key]) || 0), 0); + return all > 0 ? ((Number(row[key]) || 0) / all) * 100 : 0; + }; + + const segment = (row, item, keyIndex) => { + const pct = pctOf(row, item.key); + if (pct * pxPerPct >= MIN_REGION_INSIDE_PX) { + return { value: pct, label: { position: 'inside', color: item.insideText } }; + } + // 矮段:上方还有别的段时外置到柱体右侧,否则标到柱顶上方 + const hasAbove = REGION_SERIES.slice(keyIndex + 1).some((r) => (Number(row[r.key]) || 0) > 0); + return { + value: pct, + label: hasAbove + ? { position: 'right', color: PALETTE.ink, distance: 4, textBorderColor: '#fff', textBorderWidth: 2 } + : { position: 'top', color: PALETTE.ink, distance: 4 }, + }; + }; + + return baseOption({ + title: '中日韩厂商全球份额结构', + subtitle: '归一化堆叠,每根柱为当期全球装车量=100% · 按前十榜单内该地区企业合计', + xAxis: catlPeriodAxis(rows), + yAxis: { + ...axisCommon, + type: 'value', + min: 0, + max: 100, + interval: 20, + axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, + }, + series: REGION_SERIES.map((item, keyIndex) => barSeries({ + name: item.name, + data: rows.map((row) => segment(row, item, keyIndex)), + color: item.color, + barWidth: 46, + stack: 'region-share', + label: { + show: true, + position: 'inside', + fontSize: 12, + fontFamily: FONT, + fontWeight: 500, + formatter: (p) => `${p.value.toFixed(1)}%`, + }, + emphasis: { focus: 'series' }, + })), + legend: REGION_SERIES.map((item) => item.name), + width, + height, + // 脚注三行(来源 + 两行口径说明)共约 54px,默认 bottom:80 会被横轴标签压住 + grid: { bottom: 118 }, + sourceNote: SOURCE_NOTE_GLOBAL, + extraNote: '中国 48.2%→72.4%,韩国 30.2%→11.7%,日本 12.0%→3.7%\n2026H1 三星SDI 掉出前十、份额计入「其他」,故「其他」段抬升含口径断点', + tooltipFormatters: Object.fromEntries(REGION_SERIES.map((item) => [ + item.name, + (value) => `${Number(value).toFixed(1)}%`, + ])), + }); +} + diff --git a/js/charts/index.js b/js/charts/index.js index 91c60e0..e5f3f38 100644 --- a/js/charts/index.js +++ b/js/charts/index.js @@ -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) { diff --git a/js/charts/sweet_spot.js b/js/charts/sweet_spot.js new file mode 100644 index 0000000..3a7d858 --- /dev/null +++ b/js/charts/sweet_spot.js @@ -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 元', + }); +} diff --git a/js/theme.js b/js/theme.js index c61dcdc..486bf2d 100644 --- a/js/theme.js +++ b/js/theme.js @@ -361,6 +361,62 @@ export function stackedSharePoint(value, { pxPerUnit, labelColor, dy = 0, minIns }; } +/** + * tooltip 彩色圆点标记。 + * + * 为什么不直接用 ECharts 的默认 marker:折线系列的 visualDrawType 恒为 'fill' + * (Series.js 原型默认值,只有轨迹图 / 箱线图改成 'stroke'),而空心圆折线的 + * itemStyle.color 必须是 #fff —— tooltip 取到的 marker 就是白色,在白底提示框上 + * 等于隐形。这里按系列语义色自绘标记,不依赖 ECharts 内部取色。 + */ +export function tooltipMarker(color) { + return ``; +} + +/** + * 标准 tooltip:每一项前面带该系列语义色的圆点。 + * + * 不能用 ECharts 默认 marker:折线系列 visualDrawType 为 'fill', + * 而空心圆折线的 itemStyle.color 是 #fff,默认 marker 会被取成白色而在白底上隐形。 + * 取色规则: + * - 柱:优先用该数据点自身的颜色(支持逐项着色,如按公司或国别上色) + * - 折线:用 lineStyle.color(空心圆的 itemStyle.color 是白色,不能作语义色) + * + * @param {Array} series 已构造好的系列数组 + * @param {(value: number) => string} formatValue 默认数值格式化 + * @param {Object string>} [formatBySeries] 按系列名覆盖 + * —— 柱线组合图两条序列单位不同(如 GWh 与 %)时必需 + */ +export function tooltipOf(series, formatValue, formatBySeries) { + const seriesColors = {}; + series.forEach((item) => { + const color = item.lineStyle?.color ?? item.itemStyle?.color; + if (item.name && color) seriesColors[item.name] = color; + }); + + return { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + textStyle: { fontFamily: FONT, fontSize: 13 }, + formatter: (params) => { + const list = Array.isArray(params) ? params : [params]; + if (list.length === 0) return ''; + const head = `` + + `${list[0].axisValueLabel ?? list[0].name ?? ''}`; + const body = list.map((item) => { + const fallback = seriesColors[item.seriesName] ?? PALETTE.grey; + const color = item.seriesType === 'bar' ? (item.color || fallback) : fallback; + const fmt = formatBySeries?.[item.seriesName] ?? formatValue; + const raw = item.value; + const value = raw === null || raw === undefined || raw === '' ? '—' : fmt(raw); + return `${tooltipMarker(color)}${item.seriesName}:${value}`; + }); + return [head, ...body].join('
'); + }, + }; +} + /** 底部注释 */ export function footnote(text) { return { diff --git a/js/ui.js b/js/ui.js index 6750768..991d1cb 100644 --- a/js/ui.js +++ b/js/ui.js @@ -5,8 +5,31 @@ function element(tag, className, text) { return el; } +/** + * 右上角「重新加载数据」按钮的图标。 + * 内联 SVG 而不是外部文件/字体:省一次请求,且 fill 能直接继承按钮的 currentColor。 + * 原始导出里的 t / p-id 等 iconfont 冗余属性已剔除,只保留 viewBox + path。 + */ +const RELOAD_ICON_PATH = + 'M249.836 210.77c5.558-3.827 10.73-7.527 15.812-10.695 4.967-3.314 9.807-6.121 14.208-8.785 2.18-1.362 4.374-2.515 6.425-3.673l5.873-3.281c0.945-0.551 1.789-0.953 2.625-1.377l2.425-1.226 4.381-2.233 1.954-1.004 2.219-1.093 3.918-1.924 7.184-3.527c2.883-1.44 5.996-3.272 8.694-5.22 26.969-19.458 33.057-57.094 13.599-84.063-19.458-26.968-57.094-33.057-84.063-13.599l-6.49 4.682-3.54 2.554-2.071 1.499-2.62 1.947-5.824 4.351a7267.87 7267.87 0 0 0-3.193 2.399c-1.091 0.824-2.25 1.676-3.282 2.533a33346.62 33346.62 0 0 1-6.735 5.441c-2.343 1.915-4.833 3.859-7.308 6.046-4.991 4.313-10.423 8.905-15.952 14.121-5.645 5.071-11.353 10.792-17.394 16.739-5.886 6.125-12.079 12.515-18.185 19.505-12.303 13.875-24.827 29.53-36.839 46.835a494.61 494.61 0 0 0-33.896 56.497 491.342 491.342 0 0 0-27.329 64.159c-1.02 2.77-1.888 5.605-2.789 8.428l-2.688 8.477c-1.692 5.691-3.266 11.429-4.857 17.152l-2.135 8.669c-0.683 2.896-1.479 5.756-2.051 8.682l-1.84 8.729-0.914 4.358-0.785 4.387a479.254 479.254 0 0 0-7.583 69.967c-0.302 5.759-0.239 11.514-0.279 17.214l-0.017 4.267c-0.001 1.42 0.069 2.841 0.101 4.257l0.25 8.464 0.127 4.204 0.264 4.188 0.531 8.317 0.278 4.451 0.399 3.933 0.792 7.821c0.14 1.313 0.244 2.561 0.412 3.918l0.527 4.098 1.056 8.115c0.185 1.337 0.33 2.695 0.549 4.002l0.648 3.91 1.289 7.744c1.825 10.246 3.911 20.188 6.263 29.766a463.195 463.195 0 0 0 16.342 52.81l2.255 5.904 1.108 2.899 1.186 2.836 4.611 10.976c3.247 7.033 6.19 13.723 9.353 19.831 6.048 12.328 12.043 22.661 17.007 31.096 5.121 8.359 9.212 14.84 12.197 19.141l4.472 6.638-3.293-7.292c-2.209-4.713-5.148-11.776-8.748-20.761-3.443-9.059-7.545-20.055-11.346-32.919-2.041-6.376-3.771-13.301-5.726-20.516l-2.612-11.204-0.667-2.878-0.583-2.935-1.183-5.957c-3.105-16.055-5.568-33.549-6.889-52.052-0.668-9.251-1.027-18.753-1.1-28.437l0.03-7.3 0.014-3.67c0.002-1.227 0.084-2.416 0.122-3.629l0.284-7.263 0.139-3.644c0.053-1.229 0.166-2.577 0.247-3.863a35704.717 35704.717 0 0 1 0.798-11.787l0.017-0.247c0.013 0.588 0.018 0.028 0.027 0.098l0.05-0.472 0.101-0.945 0.2-1.891 0.8-7.581 0.4-3.801 0.535-3.801 1.069-7.62c0.187-1.271 0.335-2.545 0.555-3.815l0.67-3.811c0.909-5.082 1.708-10.185 2.859-15.253a424.734 424.734 0 0 1 17.057-60.03l1.316-3.663 1.437-3.623 2.859-7.225c0.907-2.417 2.03-4.761 3.033-7.135l3.079-7.082c2.208-4.653 4.37-9.295 6.624-13.867l3.508-6.783c1.167-2.252 2.294-4.513 3.565-6.702a414.503 414.503 0 0 1 31.89-49.502 407.116 407.116 0 0 1 35.476-41.25 406.598 406.598 0 0 1 35.793-32.288c5.751-4.687 11.515-8.857 16.918-12.861zM1023.966 510.486l-0.001-0.005-0.875-7.954-1.051-9.554-0.687-6.125-1.153-8.152c-0.828-5.729-1.741-12.089-2.813-18.907l-4.259-20.887c-0.691-3.726-1.738-7.517-2.729-11.425l-3.102-12.024c-0.558-2.048-1.019-4.142-1.677-6.221l-1.958-6.314-4.07-13.021-1.049-3.333-1.211-3.324c-0.814-2.223-1.638-4.467-2.469-6.732-1.673-4.522-3.34-9.131-5.115-13.781l-5.938-13.981-3.046-7.123c-1.037-2.384-2.247-4.72-3.375-7.102l-6.997-14.388c-0.569-1.22-1.222-2.408-1.877-3.598l-1.958-3.583-3.95-7.211-3.995-7.265c-1.354-2.422-2.881-4.773-4.324-7.175l-8.875-14.427c-12.567-18.998-26.533-37.793-42.018-55.643-15.662-17.71-32.638-34.534-50.581-50.045a527.617 527.617 0 0 0-56.726-41.602c-19.622-12.201-39.702-22.647-59.484-31.64l-14.925-6.188c-2.47-0.995-4.881-2.072-7.356-2.979l-7.402-2.651-7.306-2.609-3.615-1.289c-1.198-0.434-2.394-0.863-3.618-1.21l-14.37-4.353c-2.366-0.691-4.68-1.466-7.035-2.069l-7.02-1.754-13.669-3.402c-4.524-0.941-8.985-1.788-13.341-2.655l-6.47-1.271-3.184-0.621-3.186-0.465-12.373-1.797-5.96-0.862c-1.957-0.298-3.928-0.403-5.85-0.61l-11.239-1.057c-3.638-0.329-7.151-0.735-10.598-0.81l-19.18-0.823-16.258 0.11-6.935 0.057c-2.177 0.091-4.219 0.237-6.115 0.34l-9.619 0.566-7.982 0.472 7.959 0.875 9.529 1.05c1.872 0.215 3.884 0.411 6.023 0.683l6.797 1.101 15.801 2.578 18.393 3.951c3.283 0.628 6.614 1.603 10.051 2.514l10.571 2.838c1.801 0.509 3.644 0.92 5.469 1.526l5.542 1.793 11.432 3.697 2.927 0.946 2.914 1.104 5.902 2.242c3.965 1.517 8.007 3.017 12.084 4.613l12.243 5.366 6.241 2.733c2.088 0.929 4.128 2.029 6.212 3.044l12.59 6.277c1.068 0.506 2.106 1.094 3.145 1.685l3.131 1.761 6.302 3.542 6.353 3.568c2.118 1.209 4.165 2.586 6.263 3.878l12.601 7.922c16.565 11.247 32.949 23.681 48.48 37.44a473.639 473.639 0 0 1 43.435 44.87 467.917 467.917 0 0 1 35.892 50.115c10.476 17.304 19.389 34.971 27.062 52.322 1.761 4.404 3.506 8.772 5.234 13.098 0.843 2.166 1.771 4.273 2.532 6.444l2.224 6.49 2.201 6.396 1.093 3.162c0.368 1.047 0.734 2.092 1.019 3.166l3.633 12.572c0.578 2.068 1.242 4.086 1.738 6.146l1.441 6.137 2.829 11.931 2.152 11.645 1.045 5.64 0.514 2.772 0.362 2.779 1.423 10.784 0.697 5.188c0.245 1.704 0.3 3.423 0.458 5.098l0.794 9.786c0.249 3.167 0.584 6.22 0.595 9.223l0.516 16.686-0.346 13.152-0.109 5.579-0.343 6.014-0.567 9.595-0.472 7.988c-0.169 3.219-0.107 6.829 0.256 10.138 3.638 33.056 33.383 56.904 66.438 53.267 33.056-3.637 56.905-33.382 53.268-66.438zM885.176 762.451l-4.868 5.897c-1.711 2.052-3.44 4.238-5.4 6.399-3.858 4.365-7.946 9.128-12.608 13.957-4.517 4.944-9.632 9.919-14.922 15.205-5.467 5.131-11.147 10.547-17.374 15.864-12.349 10.726-26.262 21.624-41.613 32.046a434.435 434.435 0 0 1-50.026 29.303 431.157 431.157 0 0 1-56.59 23.39c-2.436 0.874-4.935 1.6-7.419 2.361l-7.455 2.279c-5.006 1.425-10.053 2.742-15.078 4.091-2.536 0.593-5.075 1.187-7.617 1.78-2.545 0.566-5.051 1.25-7.622 1.714l-7.663 1.523-3.822 0.763-3.851 0.64a419.278 419.278 0 0 1-61.304 5.938c-5.039 0.219-10.081 0.088-15.068 0.072l-3.733-0.022c-1.238-0.012-2.392-0.1-3.587-0.145l-7.067-0.313-3.505-0.146-3.997-0.27-7.961-0.537-1.981-0.133-0.989-0.067-0.494-0.033c0.221-0.008-0.844-0.005 0.433-0.022l-0.226-0.025-3.593-0.396-7.127-0.78a406.62 406.62 0 0 1-53.494-10.013 402.48 402.48 0 0 1-45.808-14.792l-5.111-2.022-2.511-0.99-2.452-1.066-9.494-4.124c-6.074-2.917-11.861-5.522-17.131-8.352-10.648-5.379-19.552-10.718-26.826-15.11l-1.338-0.807a1085.391 1085.391 0 0 0-3.457-2.276l-4.116-2.687c-0.638-0.414-1.252-0.813-1.842-1.194l-2.021-1.354-3.621-2.439-6.637-4.472c-2.685-1.782-5.825-3.567-8.857-4.938-30.307-13.689-65.973-0.218-79.662 30.089-13.689 30.308-0.218 65.974 30.089 79.663l7.293 3.294 3.979 1.797 2.369 1.063 2.994 1.302c2.073 0.896 4.3 1.857 6.676 2.885l3.672 1.574 1.917 0.819 1.9 0.745c10.22 4.014 22.646 8.772 37.189 13.242 7.215 2.375 15.042 4.443 23.216 6.727l12.687 3.1 3.261 0.787 3.325 0.701 6.75 1.415c18.195 3.715 38.044 6.719 59.054 8.439a494.284 494.284 0 0 0 65.863 0.997l8.535-0.519 4.285-0.266 0.268-0.017c1.44-0.02 0.541-0.02 0.927-0.03l0.492-0.051 0.986-0.1 1.977-0.201 7.938-0.807 3.991-0.406 4.502-0.554 9.015-1.133c1.492-0.2 3.034-0.365 4.486-0.598l4.344-0.709c5.795-0.965 11.61-1.832 17.399-3.066a485.178 485.178 0 0 0 68.692-18.701l4.2-1.463 4.157-1.588 8.292-3.176c2.771-1.016 5.468-2.251 8.194-3.37l8.134-3.434c5.351-2.451 10.685-4.869 15.941-7.39l7.805-3.918c2.591-1.307 5.188-2.576 7.712-3.991a474.344 474.344 0 0 0 57.071-35.83 467.174 467.174 0 0 0 47.72-40.068 466.596 466.596 0 0 0 37.51-40.586c5.455-6.542 10.335-13.082 14.998-19.24 4.483-6.313 8.789-12.218 12.512-18 3.868-5.667 7.179-11.177 10.297-16.204 1.589-2.492 2.957-4.989 4.318-7.332 1.349-2.348 2.635-4.586 3.854-6.711 4.686-8.611 8.271-15.384 10.509-20.116l3.527-7.185-4.682 6.489c-2.985 4.266-7.65 10.33-13.635 17.935z'; + +function refreshIcon() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'reload-icon'); + svg.setAttribute('viewBox', '0 0 1024 1024'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', RELOAD_ICON_PATH); + svg.append(path); + return svg; +} + function button(label, { active = false, onClick, className = '' } = {}) { - const el = element('button', className, label); + const el = document.createElement('button'); + if (className) el.className = className; + if (label instanceof Node) el.append(label); + else if (label !== undefined) el.textContent = label; el.type = 'button'; el.dataset.active = String(active); if (onClick) el.addEventListener('click', onClick); @@ -16,7 +39,7 @@ function button(label, { active = false, onClick, className = '' } = {}) { export function createAppShell(root, { onReload }) { root.replaceChildren(); - const reloadButton = button('↻', { onClick: onReload, className: 'reload-button' }); + const reloadButton = button(refreshIcon(), { onClick: onReload, className: 'reload-button' }); reloadButton.title = '重新加载数据'; reloadButton.setAttribute('aria-label', '重新加载数据'); diff --git a/manifest.json b/manifest.json index 9611bfe..fe1c360 100644 --- a/manifest.json +++ b/manifest.json @@ -88,6 +88,42 @@ { "id": "price-shareholder", "name": "价格与股东回报", "chartRefs": ["moutai-flying-price", "moutai-price-adjustment", "moutai-dividend"] } ] }, + { + "id": "catl", + "name": "宁德时代", + "chartCatalog": { + "catl-unit-economics": { "key": "catl-unit-economics", "title": "动力电池单 Wh 经济性", "csv": "data/catl/catl_unit_economics.csv", "height": 700, "filename": "宁德时代动力电池单Wh经济性" }, + "catl-volume-gp": { "key": "catl-volume-gp", "title": "动力电池销量与单 Wh 毛利", "csv": "data/catl/catl_unit_economics.csv", "height": 700, "filename": "宁德时代销量与单Wh毛利" }, + "peer-install": { "key": "peer-install", "title": "Top5 动力电池装车量走势", "csv": "data/catl/catl_peer_install.csv", "height": 700, "filename": "Top5动力电池装车量走势" }, + "peer-utilization": { "key": "peer-utilization", "title": "Top5 动力电池产能利用率", "csv": "data/catl/catl_peer_utilization.csv", "height": 700, "filename": "Top5动力电池产能利用率" }, + "peer-share": { "key": "peer-share", "title": "Top5 动力电池全球市占率走势", "csv": "data/catl/catl_peer_install_share.csv", "height": 700, "filename": "Top5动力电池全球市占率走势" }, + "peer-gp-per-wh": { "key": "peer-gp-per-wh", "title": "Top6 动力电池单 Wh 毛利走势", "csv": "data/catl/catl_peer_gp_per_wh.csv", "height": 720, "filename": "Top6动力电池单Wh毛利走势" }, + "peer-asp": { "key": "peer-asp", "title": "Top6 动力电池 ASP 对比", "csv": "data/catl/catl_peer_asp.csv", "height": 700, "filename": "Top6动力电池ASP对比" }, + "peer-margin": { "key": "peer-margin", "title": "Top6 毛利率与营业利润率", "csv": "data/catl/catl_peer_margin.csv", "height": 700, "filename": "Top6毛利率与营业利润率" }, + "peer-2026h1": { "key": "peer-2026h1", "title": "2026H1 单 Wh 毛利与盈利能力", "csv": "data/catl/catl_peer_2026h1.csv", "height": 700, "filename": "2026H1单Wh毛利与盈利能力" }, + "peer-gp-gap": { "key": "peer-gp-gap", "title": "宁德 vs 二线厂均值:单 Wh 毛利差距", "csv": "data/catl/catl_peer_gp_gap.csv", "height": 700, "filename": "宁德vs二线厂单Wh毛利差距" }, + "peer-gp-change": { "key": "peer-gp-change", "title": "单 Wh 毛利:2026H1 vs 2023", "csv": "data/catl/catl_peer_gp_change.csv", "height": 700, "filename": "单Wh毛利2026H1对比2023" }, + "global-cr": { "key": "global-cr", "title": "全球装车量集中度 CR3 / CR5 / CR10", "csv": "data/catl/global_top10_share.csv", "height": 700, "filename": "全球动力电池装车量集中度" }, + "global-top10": { "key": "global-top10", "title": "2026H1 全球装车量前十与同比增速", "csv": "data/catl/global_top10_2026h1.csv", "height": 720, "filename": "2026H1全球装车量前十与同比增速" }, + "global-region-share": { "key": "global-region-share", "title": "中日韩厂商全球份额结构", "csv": "data/catl/global_region_share.csv", "height": 700, "filename": "中日韩厂商全球份额结构" } + }, + "tabs": [ + { "id": "unit-economics", "name": "单位经济", "chartRefs": ["catl-unit-economics", "catl-volume-gp"] }, + { "id": "peer", "name": "同业对比", "chartRefs": ["peer-install", "peer-utilization", "peer-share", "global-cr", "global-top10", "global-region-share", "peer-gp-per-wh", "peer-asp", "peer-margin", "peer-2026h1", "peer-gp-gap", "peer-gp-change"] } + ] + }, + { + "id": "sweet-spot", + "name": "甜蜜击球区", + "chartCatalog": { + "sweet-catl-price-pe": { "key": "sweet-catl-price-pe", "title": "股价 → 2026E PE 对照", "csv": "data/sweetspot/catl_price_pe.csv", "height": 740, "filename": "宁德时代股价PE对照" }, + "sweet-catl-net-cash": { "key": "sweet-catl-net-cash", "title": "净现金让 PE 便宜了多少", "csv": "data/sweetspot/catl_price_pe.csv", "height": 740, "filename": "宁德时代净现金与PE" }, + "sweet-catl-any-price": { "key": "sweet-catl-any-price", "title": "任意股价试算:三年预期年化收益", "csv": "data/sweetspot/catl_price_returns.csv", "height": 740, "filename": "宁德时代任意股价试算" } + }, + "tabs": [ + { "id": "catl", "name": "宁德时代", "chartRefs": ["sweet-catl-price-pe", "sweet-catl-net-cash", "sweet-catl-any-price"] } + ] + }, { "id": "brk", "name": "巴菲特致股东信", diff --git a/scripts/build_sweet_spot_catl.py b/scripts/build_sweet_spot_catl.py new file mode 100644 index 0000000..391edc7 --- /dev/null +++ b/scripts/build_sweet_spot_catl.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""甜蜜击球区 · 宁德时代(300750):股价 → 估值指标 投影表生成器 + +数据层定位:本脚本是 data/sweetspot/*.csv 的唯一生成入口(CSV 是运行时投影, +禁止手工编辑)。上游底稿是资料库节点「宁德时代_股价PE对照表」 +(https://www.workbuddy.cn/space/d/EsAWMaLKt4ECKPDvESKKvT,2026-09-15 版)。 + +计算基准(改这几个常量就能整表重算): + 总股本 46.27 亿股 + 净现金 2,263.8 亿元 (每股 48.93 元) + 2026E 归母 悲观 900 / 中性 964 / 乐观 1,000 亿元(65 家机构一致预期) + 分红率 50%(2023–2025 年实际值) + 现价 316.21 元(2026-09-15 收盘) + 2028E 目标 1,400 亿 × 20x → 605 元(老唐法三年后合理估值,用于试算年化收益) + 无风险利率 1.68%(10 年期国债,仅用于股息率倍数的脚注说明) + +精度例外:PE 与股息率保留两位小数。CLAUDE.md 默认一位,但 PE 一位会把 +15.18x 与 15.2x 揉成同一个读数,而「现价到底落在哪个区间」正是这张表要回答的 +问题(买入区上沿 15.5x 与合理区下沿贴得极近),故按行业惯例取两位。 + +用法: + python3 scripts/build_sweet_spot_catl.py +""" + +from pathlib import Path + +SHARES = 46.27 # 亿股 +NET_CASH = 2263.8 # 亿元 +NOW = 316.21 # 元 +PAYOUT = 0.50 +NI = {"bear": 900.0, "base": 964.0, "bull": 1000.0} +TARGET_2028 = 605.0 # 元(2028E 1,400 亿 × 20x ÷ 46.27 亿股) + +OUT_DIR = Path(__file__).resolve().parent.parent / "data" / "sweetspot" + + +def price_grid(top, step=20, keys=()): + """等距网格 + 关键价位去重后升序,保证关键价位本身就是数据点(可被打标签)。""" + prices = {float(p) for p in range(220, top + 1, step)} + prices.update(float(k) for k in keys) + return sorted(prices) + + +def pct(value): + return value * 100 + + +def main(): + OUT_DIR.mkdir(parents=True, exist_ok=True) + + header = ( + "# 由 scripts/build_sweet_spot_catl.py 生成,请勿手工编辑。\n" + "# 口径:总股本 46.27 亿股 · 净现金 2,263.8 亿元(每股 48.93 元)· " + "2026E 归母 900/964/1,000 亿元 · 分红率 50% · 现价 316.21 元(2026-09-15)\n" + "# PE 与股息率保留两位小数(见脚本 docstring 的精度例外说明)\n" + ) + + # ---------- 1) 股价 → PE 对照(图 1、图 2 共用) ---------- + rows = price_grid(700, 20, (265, 290, 303, NOW, 378, 605, 617)) + lines = [ + "price,market_cap,pe_bear,pe_base,pe_bull,pe_ex_cash,dividend_yield,net_cash_pct" + ] + for px in rows: + cap = px * SHARES + lines.append( + ",".join([ + f"{px:.2f}", + f"{cap:.1f}", + f"{cap / NI['bear']:.2f}", + f"{cap / NI['base']:.2f}", + f"{cap / NI['bull']:.2f}", + f"{(cap - NET_CASH) / NI['base']:.2f}", + f"{pct(NI['base'] * PAYOUT / cap):.2f}", + f"{pct(NET_CASH / cap):.1f}", + ]) + ) + path = OUT_DIR / "catl_price_pe.csv" + path.write_text(header + "\n".join(lines) + "\n", encoding="utf-8") + print(f"{path.relative_to(OUT_DIR.parent.parent)} {len(rows)} 行") + + # ---------- 2) 股价 → 试算(图 3) ---------- + rows = price_grid(620, 20, (265, 290, 303, NOW, 378, 450, TARGET_2028)) + lines = [ + "price,market_cap,pe_base,dividend_yield,cagr_price,cagr_total" + ] + for px in rows: + cap = px * SHARES + dy = NI["base"] * PAYOUT / cap + cagr = (TARGET_2028 / px) ** (1 / 3) - 1 + # 总收益按 (1 + 价差年化) × (1 + 股息率) − 1:股息以买入价口径年年复投, + # 与资料库试算器的「未含分红」口径并列展示,两者差额即分红的贡献。 + lines.append( + ",".join([ + f"{px:.2f}", + f"{cap:.1f}", + f"{cap / NI['base']:.2f}", + f"{pct(dy):.2f}", + f"{pct(cagr):.1f}", + f"{pct((1 + cagr) * (1 + dy) - 1):.1f}", + ]) + ) + path = OUT_DIR / "catl_price_returns.csv" + path.write_text(header + "\n".join(lines) + "\n", encoding="utf-8") + print(f"{path.relative_to(OUT_DIR.parent.parent)} {len(rows)} 行") + + +if __name__ == "__main__": + main()