feat: 增加宁德时代的图表

This commit is contained in:
R524809
2026-09-24 17:14:03 +08:00
parent 3a3d19d5dd
commit f646ee6718
15 changed files with 1459 additions and 240 deletions
+54
View File
@@ -29,6 +29,41 @@ pnpm test # 全量,只在改了 t
它会断言 `legend.top`、`grid.top`、折线 `symbolSize`/`borderWidth`,并检查 SVG 字体引号。回归测试直接遍历 `manifest.json` 的 `chartCatalog`,新增图只要完成 registry 与 manifest 登记就会被测到。
**带参数的图要另外跑参数测试。** 通用回归永远按默认值渲染,跑不到手输参数那条路径:
```bash
pnpm test:param # 甜蜜击球区「现价」参数:标线位置 + 插值结果(两家公司 6 张图)
```
**手输参数的图还要量几何、而且必须扫全价位。** 参数测试只查数值对不对,查不出「标签撞在一起」;而碰撞窗口常常只有几个价位宽 —— 甜蜜击球区实测:现价贴数据区下沿时顶部标注会撞左上角的区域带标签(宁德只有 220–240 会撞,抽查 310/400/700 全绿),现价落进关键价位旁一个数据格时两个数据标签会叠(宁德只在 265±20、腾讯只在 404.5±17):
```bash
cp ~/.workbuddy/skills/smart-fin-chart-add-chart/scripts/geometry_check.mjs tmp_geometry.mjs
node tmp_geometry.mjs --sweep sweet-tencent-price-ev sweet-catl-any-price # 全价位扫描
node tmp_geometry.mjs --w=940 sweet-tencent-price-ev # 换画布宽度
rm tmp_geometry.mjs
```
## 图表参数(tab 级控件)
可手输的参数在 `manifest.json` 的 **tab** 上声明,不是在卡片上 —— 同一 tab 里的图共用一套口径(现价对三张击球区图是同一个事实),卡片级会变成三个各说各话的输入框:
```json
"tabs": [{ "id": "catl", "name": "宁德时代",
"controls": [{ "id": "price", "type": "number", "label": "现价", "unit": "元",
"value": 316.21, "min": 220, "max": 700, "step": 1 }],
"chartRefs": ["..."] }]
```
- 链路:`renderTabControls`(ui.js,纯 DOM)→ `applyTabParam`(main.js,校验 + 防抖)→ `refreshChartParams`(复用实例 `setOption`,不 dispose 重建)→ 图表 builder 的第三个参数 `params`。
- **位置**:`shell.params` 是页面里独立的一行,钉在 tab 栏正下方,与 tab 栏同处一个 sticky 容器 `.tab-header`(两行各自 sticky 就得给参数行算「tab 栏高度」的偏移,而 tab 栏会换行、窄屏还横向滚动,高度不固定)。没有 `controls` 的 tab 由 JS 置 `hidden`,整行含虚线分隔一并消失。改动这里要连带核对 `.card` 的 `scroll-margin-top`(桌面 128px / ≤880px 160px,= 页头底边 + 18px 呼吸)。
- builder 签名写成 `(rows, { width, height, params })`,取值用 `resolvePrice(params)` 之类带兜底的解析函数:**没有 params 时必须回落到默认值**,否则 SSR 回归测试与脚本会拿到 NaN。
- **可输区间 = 数据区,不是坐标轴**:手输值靠相邻行线性插值取值,落在数据区外只能得到 NaN。轴可以比数据区宽一点(留白),输入区间不行。
- 手输值通常不是数据行,所以那些「只标关键价位」的 `labeledPrices` 要按数据行命中与否决定是否带上它,并做避让(见 `sweet_spot.js` 的 `refLabels`):现价与某个关键价位落在**同一个数据格**内时只留现价 —— 两个标签数值几乎相同、包围盒会实打实叠在一起。阈值取数据格步长(20)而不是现算像素,因为标签宽约 43px、每元约 1.8px,像素阈值会随轴范围漂。
- **「现价」这类跟随输入变化的 graphic 标注要画在绘图区上方那条留白带里**(`top: LAYOUT.gridTop - 30`),不要贴绘图区顶边(`gridTop + 4`):贴边时它和绘图区顶行的文字抢位置,手输值落在低价端必然相撞(宁德 220 元时与「卖出区 >30x」重叠 35×8px)。画在外面只剩居中的图例在它上方,永不相交,顺带消除了「换个价、标注就上下跳」。
- **纵轴下限要给「区域带名字」那一条留位置。** 纵向区域带的名字画在绘图区底边上方 12–25px,若数据线能压到接近下限,线标签就会和带名字重叠(宁德图 3 的「仅价差年化」在 700 元处跌到 -4.7%,轴下限 -10 只留 5.3pp 余量,手输 680 元时「-3.8%」压住「卖出区」22×7px)。改轴下限比挪标签便宜:图 3 的纵轴两家现在都是 -20 起。
- 默认值的同源方要写在注释里。现价这套:`sweet_spot.js` 的 `profile.priceDefault`、manifest 的 `controls[].value`、`build_sweet_spot_catl.py` / `build_sweet_spot_tencent.py` 三处一起改。
**右侧纵向图例的图会报 `legend.top=middle (期望85)`,这是预期的,不要去"修"。** 那条断言的 85px 基准是给顶部横向图例定的,对 `orient: 'vertical'` + `top: 'middle'` 的图不适用(`ppmt_ip_share`、`ppmt_ip_share_history` 都会报)。
## 踩过的坑
@@ -61,6 +96,25 @@ pnpm test # 全量,只在改了 t
**桥图/瀑布图要验算闭合。** 各项占营收比是税前口径,加总到净利率前须乘税盾 `(1 - 有效税率)`;税率变化单独成项。恒等式 `净利率 = 税前利润率 × (1-税率)`,分解为 `Δ净 = Δ税前×(1-t₁) − 税前₂×Δt`。改完核对首尾误差应 < 0.05pct。
## 甜蜜击球区板块(股价 ↔ 估值)
菜单 id `sweet-spot`,两个 tab(`catl` 宁德时代 / `tencent` 腾讯控股),各三张图,**共用一套通用 builder**:
| | 宁德时代 | 腾讯控股 |
| --- | --- | --- |
| 纵轴口径 | `PE = 市值 ÷ 2026E 归母净利` | `EV/EBIT =(市值 − 非经营资产)÷ TTM EBIT` |
| 扣什么 | 类现金 2,263.8 亿(纯现金,准现金) | 净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9 = 5,841.8 亿(股权,会波动) |
| 甜蜜区锚 | 13 / 15.5 / 25 / 30x(资料库原表五档) | 8 / 10 / 12x(巴菲特原始锚,白送 / 甜点 / 上沿) |
| 数据区 / 输入区 | 220–700 元 | 240–640 港元 |
| 默认现价 | 316.21 元(2026-09-15) | 426.8 港元(2026-09-17) |
**两家的口径差异不是随意选的**:宁德的类现金是现金,扣掉不改变分子的性质,PE 口径成立;腾讯的「类现金」主体是 5,260 亿投资组合(股权会波动),只扣净现金 581.9 亿几乎不改变倍数(少 0.23x),必须连投资组合一起扣才是经营资产的真实价格 —— 图 2 的三层线(不扣 / 只扣净现金 / 全扣)就是为把「漏扣投资组合会得出超上沿的假阴性」这件事画出来。
- 代码结构:`js/charts/sweet_spot.js` 里 `PROFILES = { catl, tencent }` 两个配置块 + 三个通用 builder(`buildMultipleChart` / `buildAssetTierChart` / `buildAnyPriceChart`),导出 6 个图表函数。**加第三家公司不要复制粘贴 builder**,照着配置块的字段补齐即可。
- CSV 由 `scripts/build_sweet_spot_catl.py` / `build_sweet_spot_tencent.py` 生成,禁止手工编辑。改股本、汇率、利润预期 → 改脚本常量 → 重跑 → 图表自动跟着走(区域边界是从 CSV 反解的,没写死)。
- 区域边界的**股价**必须从数据反解(`multToPrice`),缺席的阈值**留 `null` 占位不要 filter 掉**,否则后面档位整体前移一格(表现为「偏贵」色带铺到轴右端、最贵那档退化成零宽,且不报错)。
- 验证:`node test/sweet_spot_param.mjs`(42 项,独立复算公式)+ `geometry_check.mjs --sweep`(全价位量几何)。
## 配色
**正式方向:轻快消费。** 柱线组合图的现金流、利润与同比使用 `FINANCIAL_COLORS`,渠道结构使用 `CHANNEL_COLORS`,瀑布图使用 `BRIDGE_COLORS`(蓝色基准、青绿色正向、珊瑚红负向);不要在图表文件里为相同财务/渠道语义重写十六进制颜色。柱状系列负责分类,折线只保留一条高辨识度强调色。
+105 -14
View File
@@ -323,30 +323,109 @@ button {
.content { min-width: 0; }
.tabs {
/* ── 页头:tab 栏 + 参数行 ─────────────────────────
sticky 只挂在容器上,里面两行一起钉住。底色比过去(.6)更实、并加一层模糊:
现在压在卡片上方的是两行内容,透过去的字会干扰读数。 */
.tab-header {
position: sticky;
top: 20px;
z-index: 5;
margin-bottom: 18px;
padding: 5px;
background: rgba(255, 255, 255, .82);
border: 1px solid var(--line);
border-radius: 9px;
backdrop-filter: blur(10px);
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 18px;
padding: 5px;
background: rgba(255, 255, 255, .6);
border: 1px solid var(--line);
border-radius: 9px;
}
.tab-button { padding: 7px 11px; }
/* ── tab 级参数控件(现价) ─────────────────────────
独立一行,贴在 tab 栏下沿;一条虚线分隔说明「这不是导航,是改口径的输入」。
没有参数的 tab 会被 JS 置 hidden,此时整行(含分隔线)不占位。 */
.tab-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 5px;
padding: 6px 3px 1px;
border-top: 1px dashed var(--line);
}
/* hidden 属性会被上面的 display:flex 盖掉,显式收回来 */
.tab-controls[hidden] { display: none; }
.param-field {
display: flex;
align-items: baseline;
gap: 5px;
padding: 4px 8px;
background: #f4f2ee;
border: 1px solid transparent;
border-radius: 6px;
transition: border-color .15s, background .15s;
}
.param-field:hover { border-color: var(--line); }
.param-field:focus-within { background: var(--surface); border-color: var(--accent); }
.param-label { color: var(--muted); font-size: 12px; white-space: nowrap; }
/* 输入框只留一条下划线:数字是这里的主角,不要给它加边框抢视线 */
.param-input {
width: 94px;
padding: 1px 2px;
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 13px;
font-variant-numeric: tabular-nums;
font-weight: 600;
text-align: right;
}
.param-input:focus { outline: none; border-bottom-color: var(--accent); }
.param-input[data-invalid="true"] { color: #a63f32; border-bottom-color: #a63f32; }
/* 上下箭头在 Chrome 里只在悬停/聚焦时出现,保留(步进 1 元比手敲快) */
.param-input::-webkit-inner-spin-button { opacity: .45; }
.param-unit { color: var(--muted); font-size: 12px; }
/* 合法区间常驻:输入越界时它变红,不用猜上下限 */
.param-range { color: var(--muted); font-size: 11px; white-space: nowrap; }
.param-input[data-invalid="true"] ~ .param-range { color: #a63f32; }
.param-reset {
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.param-reset:hover:not(:disabled) { color: var(--accent); border-color: var(--line); }
.param-reset:disabled { opacity: .35; cursor: default; }
.cards { display: grid; gap: 28px; }
/* scroll-margin-top = 页头底边(sticky top 20 + 页头实测高 89)+ 18px 呼吸:
页头是 tab 栏 + 参数行两行,比过去的单行 tab 栏高约 43px,锚点跳转时标题才不会被盖住。
页头高度会随 tab 换行浮动,这里按单行 tab 的最常见形态取值。 */
.card {
overflow: hidden;
background: var(--surface);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
scroll-margin-top: 84px;
scroll-margin-top: 128px;
}
.card-bar {
@@ -417,24 +496,36 @@ button {
}
.company-sidebar .sidebar-title { display: none; }
.company-list { display: flex; overflow-x: auto; }
/* 隐藏横向滚动条(与 .tabs 同处理):classic 滚动条会占掉 15px 高度,
把贴在公司栏下方的页头压住 —— 而页头的 sticky top 是写死的 50px。 */
.company-list { scrollbar-width: none; }
.company-list::-webkit-scrollbar { display: none; }
.company-button { flex: 0 0 auto; width: auto; }
.tabs {
.tab-header {
top: 50px;
z-index: 6;
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
margin: 0 -12px 18px;
padding: 5px 12px;
padding: 5px 12px 7px;
border-width: 0 0 1px;
border-radius: 0;
background: rgba(245, 244, 241, .96);
backdrop-filter: blur(10px);
}
/* tab 按钮多时横向滚动 —— sticky 容器不能自己滚(会把参数行一起带走),
所以滚动只加在 .tabs 上,并用负 margin 抵消容器横向 padding 让滚动区撑到屏幕边 */
.tabs {
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
margin: 0 -12px;
padding: 0 12px;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab-button { flex: 0 0 auto; }
.card { scroll-margin-top: 120px; }
/* 参数行自成一行,不再跟 tab 栏抢横向空间:窄屏直接换行,分隔线通到屏幕两边 */
.tab-controls { margin: 5px -12px 0; padding: 7px 12px 1px; }
/* 公司栏 50 + 页头 91(tab 栏 33 + 参数行 41 及间距)+ 18px 呼吸 */
.card { scroll-margin-top: 160px; }
.card-bar { align-items: flex-start; flex-wrap: wrap; }
.cart-button { margin-left: auto; }
.cart-bar { left: 12px; right: 12px; transform: none; flex-wrap: wrap; }
+4
View File
@@ -30,3 +30,7 @@ price,market_cap,pe_base,dividend_yield,cagr_price,cagr_total
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
640.00,29612.8,30.72,1.63,-1.9,-0.3
660.00,30538.2,31.68,1.58,-2.9,-1.3
680.00,31463.6,32.64,1.53,-3.8,-2.3
700.00,32389.0,33.60,1.49,-4.7,-3.3
1 # 由 scripts/build_sweet_spot_catl.py 生成,请勿手工编辑。
30 600.00,27762.0,28.80,1.74,0.3,2.0
31 605.00,27993.4,29.04,1.72,0.0,1.7
32 620.00,28687.4,29.76,1.68,-0.8,0.9
33 640.00,29612.8,30.72,1.63,-1.9,-0.3
34 660.00,30538.2,31.68,1.58,-2.9,-1.3
35 680.00,31463.6,32.64,1.53,-3.8,-2.3
36 700.00,32389.0,33.60,1.49,-4.7,-3.3
+30
View File
@@ -0,0 +1,30 @@
# 由 scripts/build_sweet_spot_tencent.py 生成,请勿手工编辑。
# 口径:总股本 90.95 亿股 · 汇率 0.86154 · 非经营资产 5,841.8 亿元(净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9)· TTM EBIT 2,585.43 亿元 · 三情景 EBIT 2,400/2,585/2,900 亿元 · 现价 426.8 港元(2026-09-17)
# 倍数与年化收益保留两位小数(与宁德两份表同精度;倍数一位会把 10.68x 与 10.7x 揉成一个读数)
price,market_cap,mult_bear,mult_base,mult_bull,mult_net_cash,mult_gross,non_op_pct
240.00,18805.7,5.40,5.01,4.47,7.05,7.27,31.1
260.00,20372.8,6.05,5.62,5.01,7.65,7.88,28.7
280.00,21940.0,6.71,6.23,5.55,8.26,8.49,26.6
300.00,23507.1,7.36,6.83,6.09,8.87,9.09,24.9
320.00,25074.3,8.01,7.44,6.63,9.47,9.70,23.3
338.50,26523.9,8.62,8.00,7.13,10.03,10.26,22.0
340.00,26641.4,8.67,8.04,7.17,10.08,10.30,21.9
360.00,28208.5,9.32,8.65,7.71,10.69,10.91,20.7
371.50,29109.6,9.69,9.00,8.02,11.03,11.26,20.1
380.00,29775.7,9.97,9.26,8.25,11.29,11.52,19.6
400.00,31342.8,10.63,9.86,8.79,11.90,12.12,18.6
404.50,31695.4,10.77,10.00,8.92,12.03,12.26,18.4
420.00,32910.0,11.28,10.47,9.33,12.50,12.73,17.8
426.80,33442.8,11.50,10.68,9.52,12.71,12.94,17.5
440.00,34477.1,11.93,11.08,9.87,13.11,13.34,16.9
460.00,36044.2,12.58,11.68,10.41,13.72,13.94,16.2
470.50,36867.0,12.93,12.00,10.70,14.03,14.26,15.8
480.00,37611.4,13.24,12.29,10.96,14.32,14.55,15.5
500.00,39178.5,13.89,12.89,11.50,14.93,15.15,14.9
520.00,40745.7,14.54,13.50,12.04,15.53,15.76,14.3
540.00,42312.8,15.20,14.11,12.58,16.14,16.37,13.8
560.00,43880.0,15.85,14.71,13.12,16.75,16.97,13.3
580.00,45447.1,16.50,15.32,13.66,17.35,17.58,12.9
600.00,47014.2,17.16,15.92,14.20,17.96,18.18,12.4
620.00,48581.4,17.81,16.53,14.74,18.57,18.79,12.0
640.00,50148.5,18.46,17.14,15.28,19.17,19.40,11.6
1 # 由 scripts/build_sweet_spot_tencent.py 生成,请勿手工编辑。
2 # 口径:总股本 90.95 亿股 · 汇率 0.86154 · 非经营资产 5,841.8 亿元(净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9)· TTM EBIT 2,585.43 亿元 · 三情景 EBIT 2,400/2,585/2,900 亿元 · 现价 426.8 港元(2026-09-17)
3 # 倍数与年化收益保留两位小数(与宁德两份表同精度;倍数一位会把 10.68x 与 10.7x 揉成一个读数)
4 price,market_cap,mult_bear,mult_base,mult_bull,mult_net_cash,mult_gross,non_op_pct
5 240.00,18805.7,5.40,5.01,4.47,7.05,7.27,31.1
6 260.00,20372.8,6.05,5.62,5.01,7.65,7.88,28.7
7 280.00,21940.0,6.71,6.23,5.55,8.26,8.49,26.6
8 300.00,23507.1,7.36,6.83,6.09,8.87,9.09,24.9
9 320.00,25074.3,8.01,7.44,6.63,9.47,9.70,23.3
10 338.50,26523.9,8.62,8.00,7.13,10.03,10.26,22.0
11 340.00,26641.4,8.67,8.04,7.17,10.08,10.30,21.9
12 360.00,28208.5,9.32,8.65,7.71,10.69,10.91,20.7
13 371.50,29109.6,9.69,9.00,8.02,11.03,11.26,20.1
14 380.00,29775.7,9.97,9.26,8.25,11.29,11.52,19.6
15 400.00,31342.8,10.63,9.86,8.79,11.90,12.12,18.6
16 404.50,31695.4,10.77,10.00,8.92,12.03,12.26,18.4
17 420.00,32910.0,11.28,10.47,9.33,12.50,12.73,17.8
18 426.80,33442.8,11.50,10.68,9.52,12.71,12.94,17.5
19 440.00,34477.1,11.93,11.08,9.87,13.11,13.34,16.9
20 460.00,36044.2,12.58,11.68,10.41,13.72,13.94,16.2
21 470.50,36867.0,12.93,12.00,10.70,14.03,14.26,15.8
22 480.00,37611.4,13.24,12.29,10.96,14.32,14.55,15.5
23 500.00,39178.5,13.89,12.89,11.50,14.93,15.15,14.9
24 520.00,40745.7,14.54,13.50,12.04,15.53,15.76,14.3
25 540.00,42312.8,15.20,14.11,12.58,16.14,16.37,13.8
26 560.00,43880.0,15.85,14.71,13.12,16.75,16.97,13.3
27 580.00,45447.1,16.50,15.32,13.66,17.35,17.58,12.9
28 600.00,47014.2,17.16,15.92,14.20,17.96,18.18,12.4
29 620.00,48581.4,17.81,16.53,14.74,18.57,18.79,12.0
30 640.00,50148.5,18.46,17.14,15.28,19.17,19.40,11.6
+30
View File
@@ -0,0 +1,30 @@
# 由 scripts/build_sweet_spot_tencent.py 生成,请勿手工编辑。
# 口径:总股本 90.95 亿股 · 汇率 0.86154 · 非经营资产 5,841.8 亿元(净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9)· TTM EBIT 2,585.43 亿元 · 三情景 EBIT 2,400/2,585/2,900 亿元 · 现价 426.8 港元(2026-09-17)
# 倍数与年化收益保留两位小数(与宁德两份表同精度;倍数一位会把 10.68x 与 10.7x 揉成一个读数)
price,market_cap,mult_base,cagr_12x,cagr_10x,cagr_12x_dep
240.00,18805.7,5.01,25.16,19.01,20.38
260.00,20372.8,5.62,21.86,15.87,17.21
280.00,21940.0,6.23,18.89,13.05,14.35
300.00,23507.1,6.83,16.18,10.48,11.75
320.00,25074.3,7.44,13.71,8.12,9.37
338.50,26523.9,8.00,11.60,6.12,7.34
340.00,26641.4,8.04,11.44,5.96,7.18
360.00,28208.5,8.65,9.33,3.96,5.16
371.50,29109.6,9.00,8.19,2.88,4.06
380.00,29775.7,9.26,7.38,2.11,3.28
400.00,31342.8,9.86,5.56,0.37,1.53
404.50,31695.4,10.00,5.17,0.00,1.15
420.00,32910.0,10.47,3.86,-1.24,-0.11
426.80,33442.8,10.68,3.30,-1.77,-0.64
440.00,34477.1,11.08,2.26,-2.76,-1.64
460.00,36044.2,11.68,0.76,-4.19,-3.09
470.50,36867.0,12.00,-0.00,-4.91,-3.82
480.00,37611.4,12.29,-0.66,-5.54,-4.45
500.00,39178.5,12.89,-2.01,-6.82,-5.75
520.00,40745.7,13.50,-3.28,-8.03,-6.97
540.00,42312.8,14.11,-4.49,-9.18,-8.13
560.00,43880.0,14.71,-5.64,-10.28,-9.24
580.00,45447.1,15.32,-6.74,-11.32,-10.30
600.00,47014.2,15.92,-7.78,-12.31,-11.30
620.00,48581.4,16.53,-8.79,-13.27,-12.27
640.00,50148.5,17.14,-9.75,-14.18,-13.19
1 # 由 scripts/build_sweet_spot_tencent.py 生成,请勿手工编辑。
2 # 口径:总股本 90.95 亿股 · 汇率 0.86154 · 非经营资产 5,841.8 亿元(净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9)· TTM EBIT 2,585.43 亿元 · 三情景 EBIT 2,400/2,585/2,900 亿元 · 现价 426.8 港元(2026-09-17)
3 # 倍数与年化收益保留两位小数(与宁德两份表同精度;倍数一位会把 10.68x 与 10.7x 揉成一个读数)
4 price,market_cap,mult_base,cagr_12x,cagr_10x,cagr_12x_dep
5 240.00,18805.7,5.01,25.16,19.01,20.38
6 260.00,20372.8,5.62,21.86,15.87,17.21
7 280.00,21940.0,6.23,18.89,13.05,14.35
8 300.00,23507.1,6.83,16.18,10.48,11.75
9 320.00,25074.3,7.44,13.71,8.12,9.37
10 338.50,26523.9,8.00,11.60,6.12,7.34
11 340.00,26641.4,8.04,11.44,5.96,7.18
12 360.00,28208.5,8.65,9.33,3.96,5.16
13 371.50,29109.6,9.00,8.19,2.88,4.06
14 380.00,29775.7,9.26,7.38,2.11,3.28
15 400.00,31342.8,9.86,5.56,0.37,1.53
16 404.50,31695.4,10.00,5.17,0.00,1.15
17 420.00,32910.0,10.47,3.86,-1.24,-0.11
18 426.80,33442.8,10.68,3.30,-1.77,-0.64
19 440.00,34477.1,11.08,2.26,-2.76,-1.64
20 460.00,36044.2,11.68,0.76,-4.19,-3.09
21 470.50,36867.0,12.00,-0.00,-4.91,-3.82
22 480.00,37611.4,12.29,-0.66,-5.54,-4.45
23 500.00,39178.5,12.89,-2.01,-6.82,-5.75
24 520.00,40745.7,13.50,-3.28,-8.03,-6.97
25 540.00,42312.8,14.11,-4.49,-9.18,-8.13
26 560.00,43880.0,14.71,-5.64,-10.28,-9.24
27 580.00,45447.1,15.32,-6.74,-11.32,-10.30
28 600.00,47014.2,15.92,-7.78,-12.31,-11.30
29 620.00,48581.4,16.53,-8.79,-13.27,-12.27
30 640.00,50148.5,17.14,-9.75,-14.18,-13.19
+138
View File
@@ -0,0 +1,138 @@
# 图表导出为图片:实现说明
> 记录 `smart-fin-chart` 的图片下载是如何实现的、命名从哪来、以及一个重要的对照结论:
> **同一段下载代码,在真实浏览器顶层页面能下载,在被 sandbox 限制的 iframe 里会被静默拦截。**
---
## 一、两条导出路径
项目里有两种导出方式,都落在浏览器原生能力上,**没有任何第三方库**(没有 file-saver、没有 html2canvas)。
### 路径 A:单张图表导出
| 环节 | 位置 |
|---|---|
| 按钮 | `js/ui.js:197` —— 卡片右上角 `导出 PNG` |
| 取图 | `js/main.js:265 exportPNG()` —— `instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' })` |
| 下载 | 同一个函数里 `anchor.href = dataURL` → `anchor.download = '${filename}.png'` → `anchor.click()` |
关键点:
- 直接用 **ECharts 实例自带的 `getDataURL()`**,等于把画布按 2 倍像素密度重新导出一遍,**不截屏、不重绘**,所以导出结果和处理中的图完全一致,且天然是高清的(`pixelRatio: 2`)。
- `backgroundColor: '#fff'` 是必须的 —— 否则透明底在公众号里会变黑/变灰。
### 路径 B:多张合并成长图
入口是卡片上的 `➕ 加入合并`(`js/ui.js:191`),形成一个「合并队列」,然后走三步:
1. **入队即存快照**(`js/main.js:282 toggleCart`)
```js
cart.set(chart.instanceId, {
instanceId, title,
filename: chart.filename || chart.title,
dataUrl: instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }),
});
```
存的是**入队那一刻的 dataURL**,不是 ECharts 实例引用。这样即使用户随后切换视图(全部/年度/半年)、改了参数、甚至窗口 resize,队列里的图也不会变。`captureSnapshot()`(`js/main.js:275`)负责在重渲染后刷新快照,保证「所见即所得」。
2. **Canvas 纵向拼接**(`js/merge.js:23 mergeImages`)
- 宽度取各图最大值,宽度不一致的等比缩放对齐;
- 高度 = 各图高度之和 + `gap × (张数 - 1)`,`gap = 96`(@2x 下视觉 48px);
- 先铺白底,再逐张 `ctx.drawImage()`。
- 为什么要纵向拼:公众号在手机上以竖版窗口预览,横版单图显示过小;两张拼成 2400×~2800 接近满屏。
3. **Blob 下载**(`js/merge.js:48 downloadCanvas`)
```js
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename.endsWith('.png') ? filename : `${filename}.png`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}, 'image/png');
```
代码注释里写明了理由:**Blob URL 比超长 dataURL 下载更稳** —— 合并长图的 dataURL 字符串可能到几 MB 量级,塞进 `href` 在部分浏览器上会失败;转成 Blob 再由浏览器托管,稳定得多。用完 10 秒后释放,避免内存泄漏。
注意:**单图路径用的是 dataURL,合并路径用的是 Blob URL** —— 这不是不一致,是刻意分工(单图 dataURL 只有几百 KB,够用且更简单)。
### 合并预览弹窗
`showMergePreview()`(`js/ui.js:276`)只是在 canvas 上直接显示 + 挂两个下载按钮,**不产生第二份数据**:`entry.canvas` 就是拼好的那块 canvas,点下载才走 `toBlob`。
- 配对模式:队列两两合并(`buildCompositions()`,`js/main.js:324`),奇数张时最后一张单独导出并提示;
- 「全部下载」用 `setTimeout(..., index * 350)` 错峰触发(`js/main.js:353`)—— 连续同步触发多个下载会被浏览器判定为「自动批量下载」而拦截。
---
## 二、文件名从哪来
**统一来自 `manifest.json` 的 `filename` 字段,回退到 `chart.title`。**
- 挂载点:`js/ui.js:198` → `onExport(chart.instanceId, chart.filename || chart.title)`
- `manifest.json` 里共 **85 处** `filename`,例如:
```json
{ "key": "ppmt-profit", "title": "盈利能力", ..., "filename": "泡泡玛特盈利能力" }
```
- 也就是说**展示名和下载名是两套**:卡片标题是短的(「盈利能力」),下载名是完整的(「泡泡玛特盈利能力」)。这样屏幕上不啰嗦,存下来的一堆图又不会重名。
- 合并导出时用 `clipName()` 截断到 24 字,多张用 `+` 连接(`js/main.js:319, 338`)。
---
## 三、对照结论:为什么同一段代码在别处就下载不了
**这个项目能下载,根本原因是它跑在真实浏览器的顶层页面**(`start.command` → `http://localhost:8899/` → `open "$URL"`),**不在任何 iframe 沙箱里**。
对照实验(无头 Chromium + CDP 显式 `Browser.setDownloadBehavior: allow`,两组跑**完全同一段**下载代码):
| 场景 | 结果 | CDP 事件 |
|---|---|---|
| 顶层页面 | ✅ 文件落盘 | `Browser.downloadWillBegin` → `downloadProgress` ×6 |
| `sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals"` 的 iframe | ❌ 未落盘 | **连 `downloadWillBegin` 都没有** |
**第二条是要害**:沙箱拦截时**不报错、不弹提示、不发事件** —— 下载请求在发起前就被丢弃。页面上看不出任何异常,控制台也没有告警,所以表现成「按钮点了没反应」,而且回溯起来会误以为是代码写错了。
结论:
1. **沙箱 iframe 缺 `allow-downloads`** 是唯一原因,和 `toBlob` / `createObjectURL` / `a.click()` 的写法无关;
2. 补救方向是让下载动作**离开沙箱**:宿主允许 `allow-popups-to-escape-sandbox` 时,从 iframe 里 `window.open()` 出一个弹窗(弹窗不受沙箱约束),在弹窗里挂 `<a download>` 触发下载。
3. 注意 `window.open()` **必须有真实用户手势**(真实点击),脚本内直接调用会被弹窗拦截器挡掉。
---
## 四、可复用的最小实现
```js
// canvas → PNG 下载(Blob URL 版本,适合大图)
function downloadCanvas(canvas, filename) {
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.png') ? filename : `${filename}.png`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}, 'image/png');
}
// ECharts 实例 → PNG 下载(高清,dataURL 版本)
function downloadChart(chartInstance, filename) {
const a = document.createElement('a');
a.href = chartInstance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
a.download = `${filename}.png`;
a.click();
}
```
前提:**这段代码必须在非沙箱的顶层页面里执行**。若运行在 iframe 中,先确认宿主给了 `allow-downloads`,否则要走弹窗逃逸。
---
## 五、相关文件索引
| 文件 | 职责 |
|---|---|
| `js/merge.js` | `mergeImages()` 纵向拼接、`downloadCanvas()` Blob 下载 |
| `js/main.js` | `exportPNG()` 单图导出、`toggleCart()` 队列快照、`buildCompositions()` 配对、`openMergePreview()` |
| `js/ui.js` | `renderCards()` 挂导出按钮、`showMergePreview()` 预览弹窗 |
| `manifest.json` | `filename` 字段(下载命名,85 处) |
| `css/style.css` | `.export-button` / `.cart-*` / `.modal-*` / `.preview-*` 样式 |
+4
View File
@@ -54,6 +54,7 @@ import {
} from './catl.js';
import {
sweetCatlPricePeChart, sweetCatlNetCashChart, sweetCatlAnyPriceChart,
sweetTencentPriceEvChart, sweetTencentNonOpChart, sweetTencentAnyPriceChart,
} from './sweet_spot.js';
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
@@ -140,6 +141,9 @@ export const chartRegistry = {
'sweet-catl-price-pe': sweetCatlPricePeChart,
'sweet-catl-net-cash': sweetCatlNetCashChart,
'sweet-catl-any-price': sweetCatlAnyPriceChart,
'sweet-tencent-price-ev': sweetTencentPriceEvChart,
'sweet-tencent-non-op': sweetTencentNonOpChart,
'sweet-tencent-any-price': sweetTencentAnyPriceChart,
};
export function getChartBuilder(key) {
+539 -199
View File
@@ -4,60 +4,258 @@ import {
} from '../theme.js';
/**
* 甜蜜击球区 · 宁德时代(300750)—— 股价 ↔ 2026E 估值 的换算图表
* 甜蜜击球区 · 股价 ↔ 估值 的换算图表(宁德时代 300750 / 腾讯控股 00700.HK)
*
* 上游底稿:资料库节点「宁德时代_股价PE对照表」
* https://www.workbuddy.cn/space/d/EsAWMaLKt4ECKPDvESKKvT(2026-09-15 版)
* 数据由 scripts/build_sweet_spot_catl.py 投影成 data/sweetspot/*.csv。
* 同一套框架、两套口径。三张图的结构完全一致,差异全部下沉到 PROFILES 的两个配置块里:
* 图 1 股价 → 倍数对照(三档情景 + 倍数轴上的区域横带)
* 图 2 非经营资产让倍数便宜了多少(含/扣各层级 + 中间那层沙色带)
* 图 3 任意股价试算(三年后退出,年化收益 + 股价轴上的纵向区域带)
*
* 【口径】所有 PE 都是**静态预期 PE**:市值 ÷ 2026E 归母净利(悲观 900 / 中性 964 /
* 乐观 1,000 亿元,65 家机构一致预期)。扣现金 PE =(市值 − 净现金 2,263.8 亿元)÷
* 2026E 净利,即"只给主营电池业务定价"。股息率按 2023–2025 年实际分红率 50% 测算。
* 【为什么纵轴口径两家不同】这不是随意的:
* 宁德 纵轴 PE = 市值 ÷ 2026E 归母净利;它扣的「类现金」是 2,263.8 亿趴在账上的现金,
* 是准现金,扣掉后不改变分子的性质,所以 PE 口径成立。
* 腾讯 纵轴 EV/EBIT;它的「类现金」是 5,259.9 亿投资组合(股权、会波动),
* 只扣净现金 581.9 亿几乎不改变倍数(少 0.23x),必须连投资组合一起扣才是经营资产
* 的真实价格;同时分母换成经营盈利 EBIT,分子分母成对(分子不含投资收益)。
* 两家的甜蜜区锚也不同:宁德的五档来自资料库原表(13/15.5/25/30x);
* 腾讯用巴菲特原始锚 8x 白送 · 10x 甜点 · 12x 上沿。
*
* 【甜蜜区框架】8x 白送 · 10x 甜点 · 12x 上沿 是巴菲特锚;资料库原表用的是五档
* 更宽的区域(按中性情景 PE 划分),因为它的分子是"含净现金的市值"。两套档位不要
* 混读:本文所有区域带一律用原表的五档,定义集中在 PE_ZONES,改档位只改这一处。
* 【上游底稿】宁德:资料库「宁德时代_股价PE对照表」EsAWMaLKt4ECKPDvESKKvT(2026-09-15);
* 腾讯:资料库「腾讯控股(00700.HK)甜蜜击球区检验」Ey5NeiPls71FC12zn0ieSX(2026-09-17)。
* 数据由 scripts/build_sweet_spot_catl.py / build_sweet_spot_tencent.py 投影成 CSV。
*
* 【图 1】股价 → PE 对照(PE 轴上的横向区域带)
* 【图 2】含现金 PE vs 扣现金 PE(沙色带 = 净现金的厚度)
* 【图 3】任意股价试算(股价轴上的纵向区域带 + 三年预期年化收益)
* 【硬编码说明】区域边界的**股价**不是写死的,而是从 CSV 的基准倍数反解(multToPrice),
* 所以改股本/汇率/利润预期后纵向区域带会自动跟着走。配置块里只有三类手工常量:
* 现价默认值、非经营资产(仅用于文案)、以及区域档位本身。
*
* 精度例外:PE 保留两位小数。项目默认一位,但"现价落在买入区还是合理区"取决于
* 15.18x 与 15.5x 这条边界,一位小数会把 15.18 读成 15.2、与 15.5 分不清。
*
* 硬编码说明:区域边界的**股价**不是写死的,而是从 CSV 的 pe_base 反解(peToPrice),
* 所以改股本或利润预期后纵向区域带会自动跟着走。本文只有两个手工常量:现价 NOW 与
* 净现金 NET_CASH(仅用于文案),都与 build_sweet_spot_catl.py 同源。
* 【现价可手输】三张图的「现价」标线不是常量,而是 tab 级参数 `params.price`
* (在 manifest.json 各 tab 的 controls 里声明,见 js/main.js)。profile.priceDefault
* 是无参数调用(SSR 回归测试、脚本)的兜底。
*/
/** 现价(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;
/* ============================================================
* 配置:宁德时代(300750)
* ============================================================ */
const CATL = {
id: 'catl',
company: '宁德时代',
currency: '元',
/** 默认现价 = 2026-09-15 收盘价。三处同源,改它要一起改:
* 本文件 · manifest.json 的 controls[].value · scripts/build_sweet_spot_catl.py */
priceDefault: 316.21,
/** 非经营资产(亿元)。仅用于文案;沙色带的几何量取自 CSV 的倍率差。 */
nonOp: 2263.8,
/** 数据区(= 输入区间)与坐标轴窗口(比数据区各宽 20 元,标线不贴边) */
dataMin: 220,
dataMax: 700,
/** CSV 的价格网格步长(= scripts/build_sweet_spot_catl.py 里 price_grid 的 step)。
* 现价与关键价位的标签避让阈值就用它,见 refLabels()。 */
dataStep: 20,
axis: { min: 200, max: 720, name: '股价(元)', metricMin: 8, metricMax: 38, metricInterval: 5 },
metric: { name: 'PE(倍)', unit: 'x', decimals: 2 },
/** 三档情景的基准是「2026E 归母净利」,65 家机构一致预期 */
sourceNote: '数据来源:宁德时代 2026 中报(本地财报库)· 65 家机构 2026E 一致预期',
/**
* 五档估值区域(按中性情景 964 亿 PE 的上沿划分)。
* 原表把 15.5–25x 细分为「合理偏低」与「合理」两档、但共用同一个配色,
* 这里并为一条色带,避免在 PE 轴上叠出两块无差别的底色。
*/
const PE_ZONES = [
zones: [
{ name: '击球区', upTo: 13, color: PALETTE.green, note: '<13x' },
{ name: '买入区', upTo: 15.5, color: PALETTE.blue, note: '13–15.5x' },
{ name: '合理', upTo: 25, color: PALETTE.orange, note: '15.5–25x' },
{ name: '偏贵', upTo: 30, color: PALETTE.red, note: '25–30x' },
{ name: '卖出区', upTo: Infinity, color: PALETTE.purple, note: '>30x' },
];
],
multiple: {
title: '股价 → 2026E PE 对照',
subtitle: (ctx) => `单位:倍 · 三档利润情景 · 区域底带按中性 964 亿划分 · 现价 ${fmtPrice(ctx.now)} 元`,
labels: [265, 480, 605],
nowLabel: '中性',
nowDecimals: 2,
nowUnit: 'x',
notes: () => [
'PE = 市值 ÷ 2026E 归母净利,与股价严格成正比;三档情景只差 ±6% 利润,落到 PE 上不足 1.7x',
'原表把 15.5–25x 细分为「合理偏低」与「合理」,此处并为一条色带;扣现金 PE 见图 2',
],
/** 从上到下:第一条标 2 个关键价位、其余只画线(三条线几乎平行,全标会糊) */
scenarios: [
{ key: 'pe_bear', name: '悲观 900 亿', color: FINANCIAL_COLORS.operatingCashFlow, width: 2.4 },
{ key: 'pe_base', name: '中性 964 亿', color: FINANCIAL_COLORS.netProfit, width: 3.5, hero: true, label: 'right' },
{ key: 'pe_bull', name: '乐观 1,000 亿', color: FINANCIAL_COLORS.investingCashFlow, width: 2.4 },
],
legend: ['悲观 900 亿', '中性 964 亿', '乐观 1,000 亿'],
},
tiers: {
title: '净现金让 PE 便宜了多少',
subtitle: (ctx) => `单位:倍 · 中性 964 亿口径 · 沙色带即净现金 ${ctx.nonOpText} 亿元对应的 PE`,
/** 从上到下。层间的差就是非经营资产的厚度,栈底 = 最后一条(扣得最狠的口径)。 */
lines: [
{ name: '含现金 PE', key: 'pe_base', color: FINANCIAL_COLORS.investingCashFlow, width: 3 },
{ name: '扣现金 PE', key: 'pe_ex_cash', color: PALETTE.brown, width: 3.5 },
],
/** 沙色带(自下而上),名字不进图例(线宽 0,图例色块会是空的) */
bands: [{ name: '净现金差额', color: PALETTE.sand }],
pctKey: 'net_cash_pct',
nowLabel: '扣现金',
nowDecimals: 2,
nowUnit: 'x',
notes: (ctx) => [
'含现金 PE = 市值 ÷ 2026E 净利;扣现金 PE =(市值 − 净现金)÷ 2026E 净利,即只给主营电池业务定价',
`两条线严格平行:差额恒为 ${ctx.bands[0].gap.toFixed(2)}x,但占市值比例从 ${ctx.dataMin} 元的 `
+ `${ctx.minPct}% 收窄到 ${ctx.dataMax} 元的 ${ctx.maxPct}% —— 股价越低,这层折价越厚`,
],
},
anyPrice: {
title: '任意股价试算:三年预期年化收益',
subtitle: () => '单位:% · 目标价 605 元(2028E 1,400 亿 × 20x)· 纵带为估值区域',
lines: [
{
name: '含分红年化', key: 'cagr_total', color: FINANCIAL_COLORS.netProfit, width: 3.5,
hero: true, labels: [265, 450, 605], position: 'top', distance: 10,
},
{
name: '仅价差年化', key: 'cagr_price', color: FINANCIAL_COLORS.investingCashFlow, width: 2.6,
labels: [265, 450], position: 'bottom', distance: 10,
},
],
legend: ['含分红年化', '仅价差年化'],
nowLabel: '含分红年化',
nowDecimals: 1,
nowUnit: '%',
/**
* 纵轴下限 -20(不是 -10):顶端 700 元处「仅价差年化」已跌到 -4.7%,-10 只留 5.3pp 余量,
* 而纵带的名字(「卖出区」等)就画在绘图区底边上方 12–25px 那条带上 —— 手输 680 元时
* 「-3.8%」标签与「卖出区」实打实重叠 22×7px(2026-09-22 全价位扫描量出)。
* 留到 -20 后底部那条带彻底空出来。腾讯那份本来就是 -20 起,两家口径统一。
*/
yAxis: { min: -20, max: 50, interval: 10 },
notes: (ctx) => [
'年化收益 =(605 ÷ 股价)^(1/3) − 1;含分红口径再乘 (1 + 股息率),股息按当前价每年派现、不复投',
`纵带边界(${ctx.currency}):${ctx.boundary}`,
],
},
};
const PE_MIN = 8;
const PE_MAX = 38;
const PRICE_MIN = 200;
const PRICE_MAX = 720;
/* ============================================================
* 配置:腾讯控股(00700.HK)
* ============================================================ */
const SOURCE_NOTE = '数据来源:宁德时代 2026 中报(本地财报库)· 65 家机构 2026E 一致预期';
const TENCENT = {
id: 'tencent',
company: '腾讯控股',
currency: '港元',
/** 默认现价 = 2026-09-17 收盘价(与底稿同源,三处同:本文件 · manifest · 生成脚本) */
priceDefault: 426.8,
/**
* 非经营资产 = 净现金 581.9 + 上市投资公允价值 4,872.0 + 非上市投资账面 387.9
* = 5,841.8 亿元(占市值 17.5%)。这是腾讯与宁德口径上最大的差异:
* 宁德的类现金是纯现金,腾讯的是股权组合(其中投资组合 5,259.9 亿),会波动。
*/
nonOp: 5841.8,
dataMin: 240,
dataMax: 640,
/** CSV 的价格网格步长,见 refLabels()。两家同为 20 —— 不是巧合,是两份生成脚本
* 都用了 price_grid(top, 20, key_prices)。改步长要连这里的避让阈值一起想。 */
dataStep: 20,
axis: { min: 220, max: 660, name: '股价(港元)', metricMin: 3, metricMax: 19, metricInterval: 2 },
metric: { name: 'EV/EBIT(倍)', unit: 'x', decimals: 2 },
sourceNote: '数据来源:腾讯控股 2026 中期报告及 2026Q2 业绩公告 · TTM = 2025 全年 − 2025H1 + 2026H1',
/**
* 巴菲特原始锚:8x 白送 · 10x 甜点 · 12x 上沿。
* 超过 12x 就是出区,所以这里的两档「贵」不需要像宁德那样细化 —— 框架里没有卖出档,
* 只有一个「够不够便宜」的判断。
*/
zones: [
{ name: '白送', upTo: 8, color: PALETTE.green, note: '<8x' },
{ name: '甜蜜区', upTo: 10, color: PALETTE.blue, note: '8–10x' },
{ name: '上沿', upTo: 12, color: PALETTE.orange, note: '10–12x' },
{ name: '超出上沿', upTo: 15, color: PALETTE.red, note: '12–15x' },
{ name: '偏贵', upTo: Infinity, color: PALETTE.purple, note: '>15x' },
],
multiple: {
title: '股价 → EV/EBIT 对照',
subtitle: (ctx) => `单位:倍 · 三档 EBIT 情景 · 区域带按 8x/10x/12x 划分 · 现价 ${fmtPrice(ctx.now)} 港元`,
/** 甜蜜区标尺:8x 白送 / 10x 甜点 / 12x 上沿 */
labels: [338.5, 404.5, 470.5],
nowLabel: '基准',
nowDecimals: 2,
nowUnit: 'x',
notes: () => [
'EV/EBIT =(市值 − 非经营资产)÷ TTM EBIT;三档 EBIT 只差 ±10%,落到倍数上放大到近 2x',
'非经营资产 5,841.8 亿 = 净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9(扣法见图 2)',
],
scenarios: [
{ key: 'mult_bear', name: '悲观 2,400 亿', color: FINANCIAL_COLORS.operatingCashFlow, width: 2.4 },
{ key: 'mult_base', name: '基准 2,585 亿', color: FINANCIAL_COLORS.netProfit, width: 3.5, hero: true, label: 'right' },
{ key: 'mult_bull', name: '乐观 2,900 亿', color: FINANCIAL_COLORS.investingCashFlow, width: 2.4 },
],
legend: ['悲观 2,400 亿', '基准 2,585 亿', '乐观 2,900 亿'],
},
tiers: {
title: '扣掉非经营资产后,倍数低了多少',
subtitle: (ctx) => `单位:倍 · 基准 EBIT 2,585 亿 · 沙色带即非经营资产 ${ctx.nonOpText} 亿元对应的倍数`,
/** 三层口径:不扣(市值)→ 只扣净现金 → 连投资组合一起扣。中间那层是「假阴性」的来源。 */
lines: [
{ name: '不扣任何资产', key: 'mult_gross', color: FINANCIAL_COLORS.netCashIncrease, width: 2.6 },
{ name: '只扣净现金', key: 'mult_net_cash', color: PALETTE.grey, width: 2.6 },
{ name: '扣净现金 + 投资组合', key: 'mult_base', color: PALETTE.brown, width: 3.5 },
],
bands: [
{ name: '投资组合差额', color: PALETTE.sand },
{ name: '净现金差额', color: PALETTE.sand },
],
pctKey: 'non_op_pct',
nowLabel: '全扣',
nowDecimals: 2,
nowUnit: 'x',
notes: (ctx) => [
`只扣净现金只挪动 ${ctx.bands[1].gap.toFixed(2)}x;真正厚的是投资组合(${ctx.bands[0].gap.toFixed(2)}x)—— `
+ '这是腾讯与宁德最大的口径差异',
'漏扣投资组合会得出「超上沿」的假阴性:它是股权不是现金、会波动,打 7 折后基准倍数回到 11.29x',
],
},
anyPrice: {
title: '任意股价试算:三年退出年化收益',
subtitle: () => '单位:% · 三年后退出在甜蜜区上沿 12x / 甜点 10x · 纵带为估值区域',
/**
* 目标价三年不变(不给 EBIT 增长),三条线的差别只在「退出倍数」与「分母是否被折旧侵蚀」:
* 12x 上沿 470.5 港元(= 报告「三·甜蜜区股价标尺」的 12x 一档)
* 10x 甜点 404.5 港元
* 折旧后 12x 418.7 港元(5 年直线折旧,稳态 EBIT 2,247 亿 —— 报告「四·分母的定时炸弹」)
*/
lines: [
{
name: '退出 12x 上沿', key: 'cagr_12x', color: FINANCIAL_COLORS.netProfit, width: 3.5,
hero: true, labels: [371.5, 404.5, 470.5], position: 'top', distance: 10,
},
{
name: '退出 10x 甜点', key: 'cagr_10x', color: FINANCIAL_COLORS.investingCashFlow, width: 2.6,
labels: [404.5], position: 'bottom', distance: 12,
},
{
name: '12x 上沿 · 折旧后', key: 'cagr_12x_dep', color: FINANCIAL_COLORS.netCashIncrease, width: 2.6,
labels: [],
},
],
legend: ['退出 12x 上沿', '退出 10x 甜点', '12x 上沿 · 折旧后'],
nowLabel: '退出 12x 上沿',
nowDecimals: 1,
nowUnit: '%',
yAxis: { min: -20, max: 30, interval: 10 },
notes: (ctx) => [
'目标价 =(倍数 × EBIT + 非经营资产)÷ 90.95 亿股 ÷ 0.86154;EBIT 不给增长(三年后与今日同口径)',
`纵带边界(${ctx.currency}):${ctx.boundary}`,
],
},
};
const PROFILES = { catl: CATL, tencent: TENCENT };
/* ============================================================
* 通用工具(两家公司共用)
* ============================================================ */
/** 十六进制转 rgba —— 区域带要低透明度叠在网格上,不能直接铺饱和色 */
function tint(hex, alpha) {
@@ -65,13 +263,58 @@ function tint(hex, alpha) {
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;
const hit = rows.find((item) => item.price === price);
if (hit) return hit[key];
for (let i = 1; i < rows.length; i += 1) {
const low = rows[i - 1];
const high = rows[i];
if (price > low.price && price < high.price) {
const ratio = (price - low.price) / (high.price - low.price);
return low[key] + ratio * (high[key] - low[key]);
}
}
return Number.NaN;
}
/** 数值轴(PE / 收益率共用),与 catl.js 的 valueAxis 同一套样式 */
/**
* 解析 tab 参数里的现价。手输价格通常不落在 CSV 的价位上(价位是 240/260/…/426.8/440/…),
* 所以取值一律走 metricAt 的插值:倍数与股价严格成正比(截距是非经营资产)、
* 年化收益是幂函数,相邻两行最多差 20 元,插值误差远小于显示精度。
*/
function resolvePrice(profile, params) {
const value = Number(params?.price);
return Number.isFinite(value) && value > 0 ? value : profile.priceDefault;
}
/** 价格文案:316.21 → '316.21'、320 → '320'(手输整数不带小数点) */
function fmtPrice(value) {
return String(Number(value.toFixed(2)));
}
/**
* 关键价位标签 + 现价标签。
*
* 数据点标签只能落在数据行上(`label.position` 不接受函数,只能逐点覆盖 data),
* 手输的现价不一定正好是数据行,所以只有命中数据行时才把它也标出来 ——
* 命不中也不亏,现价的数值由 nowGraphics 的标注直接给出。
*
* 【避让】现价与某个关键价位落在同一个数据格内时,**关键价位让位**:两者数值几乎
* 相同,两个标签的包围盒会实打实叠在一起(宁德输 260 元时,现价 12.48x 与关键价
* 265 元的 12.72x 重叠 34×11px;图 3 上「37.8% × 36.9%」「32.5% × 31.7%」各一处,
* 2026-09-22 用 geometry_check 量出)。现价永远保留 —— 它是这次交互的主角。
*
* 阈值取「一个数据格」而不是现算像素:标签宽约 43px、每元约 1.8px,像素阈值会随
* 轴范围漂;而两家的 CSV 网格步长都是 20,20 元本身就约等于一个标签的宽度,
* 越过一格就必然分得开。实测两家真实阈值 20.3(宁德)/ 17.2(腾讯),取 20 两边都够。
*/
function refLabels(profile, rows, now, refs) {
if (!rows.some((row) => row.price === now)) return refs;
return [...refs.filter((ref) => Math.abs(ref - now) > profile.dataStep), now];
}
/** 数值轴(倍数 / 年化收益共用),与 catl.js 的 valueAxis 同一套样式 */
const valueAxis = (name, extra = {}) => ({
...axisCommon,
type: 'value',
@@ -87,14 +330,14 @@ const valueAxis = (name, extra = {}) => ({
* 不画竖向网格(规范只保留浅色横线)—— 图 3 的纵向区域带本身就是参考线,
* 图 1、图 2 靠「现价」竖线定位。
*/
const priceAxis = (extra = {}) => ({
const priceAxis = (profile, extra = {}) => ({
...axisCommon,
type: 'value',
name: '股价(元)',
name: profile.axis.name,
nameLocation: 'middle',
nameGap: 34,
min: PRICE_MIN,
max: PRICE_MAX,
min: profile.axis.min,
max: profile.axis.max,
axisLine: { show: true, lineStyle: { color: PALETTE.axisLine } },
splitLine: { show: false },
...extra,
@@ -102,18 +345,18 @@ const priceAxis = (extra = {}) => ({
const GRID = { left: 78, right: 78, top: LAYOUT.gridTop, bottom: 80 };
function baseOption({
function baseOption(profile, {
title, subtitle, xAxis, yAxis, series, legend, width, height,
tooltipFormatter, extraNote, sourceNote, grid, extraGraphics = [],
tooltipFormatter, extraNote, grid, extraGraphics = [],
}) {
const notes = [sourceNote ?? SOURCE_NOTE];
const notes = [profile.sourceNote];
if (extraNote) notes.push(extraNote);
return {
backgroundColor: '#fff',
title: { ...titleStyle, text: title, subtext: subtitle },
legend: standardLegend(legend, { itemGap: 22 }),
// 三行脚注(来源 + 两行口径)会顶到 height-66,而横轴刻度底边在 height-52。
// 本项目这几张图的横轴带轴名(「股价(元)」),它比刻度再低约 32px;实测
// 本项目这几张图的横轴带轴名(「股价(元)/(港元)」),它比刻度再低约 32px;实测
// grid.bottom=118 时轴名与脚注只剩 7px 净空(同站三行脚注的图通常有 23px),
// 所以统一提到 134 —— 改脚注行数时这里要跟着重算。
grid: { ...GRID, ...grid },
@@ -125,13 +368,13 @@ function baseOption({
};
}
/** 反解 PE 阈值对应的股价:在 pe_base 单调递增的序列上线性插值 */
function peToPrice(rows, pe) {
/** 反解倍数阈值对应的股价:在基准倍数单调递增的序列上线性插值 */
function multToPrice(rows, mult, key) {
for (let i = 1; i < rows.length; i += 1) {
const prev = rows[i - 1];
const cur = rows[i];
if ((prev.pe_base - pe) * (cur.pe_base - pe) <= 0) {
const ratio = (pe - prev.pe_base) / (cur.pe_base - prev.pe_base);
if ((prev[key] - mult) * (cur[key] - mult) <= 0) {
const ratio = (mult - prev[key]) / (cur[key] - prev[key]);
return prev.price + ratio * (cur.price - prev.price);
}
}
@@ -139,13 +382,14 @@ function peToPrice(rows, pe) {
}
/**
* PE 轴上的横向区域带(图 1、图 2 共用)。
* 倍数轴上的横向区域带(图 1、图 2 共用)。
* 必须挂在**第一个系列**上:markArea 在系列自己的分组里、先于该系列图元绘制,
* 而系列分组按声明顺序绘制 —— 挂在后面的系列上会盖住前一条线。
*/
function peBandMarkAreas() {
let low = PE_MIN;
return PE_ZONES.map((zone) => {
function multBandMarkAreas(profile) {
const { metricMin, metricMax } = profile.axis;
let low = metricMin;
return profile.zones.map((zone) => {
const band = [{
yAxis: low,
itemStyle: { color: tint(zone.color, 0.07) },
@@ -159,22 +403,32 @@ function peBandMarkAreas() {
fontWeight: 600,
fontFamily: FONT,
},
}, { yAxis: Math.min(zone.upTo, PE_MAX) }];
}, { yAxis: Math.min(zone.upTo, metricMax) }];
low = zone.upTo;
return band;
});
}
/**
* 各档阈值对应的股价边界(最后一档 upTo 是 Infinity,不参与)。
*
* 缺席的阈值必须**留 null 占位**,不能 filter 掉:filter 会让后面的档位整体前移一格,
* 把「偏贵」的上沿当成「卖出区」的下沿。数据区只到 620 元时(偏贵上沿 625 元在区外)
* 就踩过这个坑,表现为「偏贵」色带一路铺到轴右端、而「卖出区」退化成零宽。
*/
function zoneEdges(profile, rows) {
const key = profile.multiple.scenarios.find((item) => item.hero).key;
return profile.zones.slice(0, -1).map((zone) => multToPrice(rows, zone.upTo, key));
}
/**
* 股价轴上的纵向区域带(图 3 用)。
* 标签贴底:顶部留给折线标签与「现价」竖线的标注,贴底才不会撞。
*/
function priceBandMarkAreas(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],
function priceBandMarkAreas(profile, rows) {
const edges = [profile.axis.min, ...zoneEdges(profile, rows), profile.axis.max];
return profile.zones.map((zone, index) => [{
xAxis: edges[index] ?? profile.axis.max,
itemStyle: { color: tint(zone.color, 0.08) },
label: {
show: true,
@@ -186,16 +440,18 @@ function priceBandMarkAreas(rows) {
fontWeight: 600,
fontFamily: FONT,
},
}, { xAxis: edges[index + 1] }]);
}, { xAxis: edges[index + 1] ?? profile.axis.max }]);
}
/**
* 股价 → 指标折线。
* 只在 labeledPrices 上的点标数值:30+ 个数据点全标会糊成一片,
* 只在 labels 上的点标数值:20+ 个数据点全标会糊成一片,
* 而这几个点正好是决策价位。
* 不设 z:同图各条线在窗口内不相交(分子随股价线性增长、共享同一个横轴截距),
* 声明顺序即绘制顺序,多设一层 z 只会让 SVG 分组变形。
*/
function priceLine({
name, rows, key, color, width = 2.6, labeledPrices = [],
name, rows, key, color, width = 2.6, labels = [],
unit = '', decimals = 2, position = 'right', distance = 8,
}) {
const line = lineSeries({ name, data: [], color, width });
@@ -204,7 +460,7 @@ function priceLine({
label: { show: false },
data: rows.map((row) => ({
value: [row.price, row[key]],
label: labeledPrices.includes(row.price)
label: labels.includes(row.price)
? {
...line.label,
show: true,
@@ -218,204 +474,288 @@ function priceLine({
}
/**
* 现价竖线:三张图共用。
* 现价竖线:所有图共用。
* label 一律关掉 —— markLine 的标签会**跟着竖线旋转 90°**,长串中文变成一列竖排字,
* 极难读。文字改用 nowGraphics() 以 graphic 单独排,保持水平。
*/
function nowMarkLine() {
function nowMarkLine(now) {
return {
silent: true,
symbol: 'none',
lineStyle: { type: 'dashed', width: 1.6, color: PALETTE.brown },
label: { show: false },
data: [{ xAxis: NOW }],
data: [{ xAxis: now }],
};
}
/** 现价竖线上方的水平标注,位置由 grid 反推(图表尺寸变化时会重算) */
function nowGraphics(width, text) {
const x = GRID.left
+ ((NOW - PRICE_MIN) / (PRICE_MAX - PRICE_MIN)) * (width - GRID.left - GRID.right);
/**
* 现价竖线上方的水平标注,位置由 grid 反推(图表尺寸变化时会重算)。
* 现价贴近右边界时改到竖线左侧右对齐,否则文字会顶出画布 —— 手输价格可能落在整个
* 轴窗口的任意位置,不能像过去只按固定现价那样假设右边一定有地方。
*
* 【纵向位置:画在绘图区**外面**那条留白带里(图例底 ≈107、绘图区顶 145),
* 不是贴绘图区顶边。】贴边时它和绘图区顶行的文字抢位置,低价端必然撞:
* 宁德输 220 元时与「卖出区 >30x」重叠 35×8px、腾讯输 240 港元时与「偏贵 >15x」
* 重叠 16×8px(2026-09-22 用 geometry_check 量出,两家的默认价都恰好躲过了)。
* 挪到绘图区外,顶部就只剩图例(居中、85–107),与本条永不相交;
* 顺带解决了「换个手输价、标注就上下跳」的问题 —— 现在它始终在同一高度。
*/
function nowGraphics(profile, width, now, text) {
const { min, max } = profile.axis;
const x = GRID.left + ((now - min) / (max - min)) * (width - GRID.left - GRID.right);
const flip = x + 8 + text.length * 13 > width - GRID.right;
return [{
type: 'text',
left: Math.round(x + 8),
top: LAYOUT.gridTop + 4,
left: Math.round(flip ? x - 8 : x + 8),
top: LAYOUT.gridTop - 30,
silent: true,
style: { text, fill: PALETTE.brown, fontSize: 12, fontWeight: 600, fontFamily: FONT },
style: {
text,
fill: PALETTE.brown,
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
...(flip ? { textAlign: 'right' } : {}),
},
}];
}
/**
* 图 1:股价 → 2026E PE 对照 —— 资料库原表第 1 节的图形版
*
* 一条直线就是一个情景:PE 与股价严格成正比,所以三档利润在图上表现为三条**平行**的
* 窄带(±6% 的利润预期落到 PE 上不足 1.7x)。这张图能回答"现价落在哪个区域",
* 但回答不了"贵还是便宜"—— 那取决于利润能不能兑现,属于分母问题,不在本图射程内。
* 现价标注文案。各图格式一致,且指标取不到时不打印 "NaN" —— 那说明现价落在数据区
* 之外(插值无从取值),把原因写出来比一个 NaN 有用。输入框本身也会拦下这种值。
*/
export function sweetCatlPricePeChart(rows, { width, height }) {
const nowPe = metricAt(rows, NOW, 'pe_base');
const hero = priceLine({
name: '中性 964 亿',
function nowNote(profile, now, label, value, decimals, unit) {
const head = `现价 ${fmtPrice(now)} ${profile.currency}`;
return Number.isFinite(value) ? `${head} = ${label} ${value.toFixed(decimals)}${unit}` : `${head}(超出数据区)`;
}
/** 图 1、图 2 的 tooltip:倍数,两位小数 */
const multipleFormatter = (profile) => (value) => {
const number = Number(Array.isArray(value) ? value[1] : value);
return `${number.toFixed(profile.metric.decimals)}${profile.metric.unit}`;
};
/* ============================================================
* 三个通用 builder
* ============================================================ */
/**
* 图 1:股价 → 倍数对照
*
* 一条直线就是一个情景:分子随股价线性增长(截距是负的非经营资产),所以三档情景在图上
* 是从同一个横轴截距发散出来的一组射线,窗口内近似平行。这张图能回答"现价落在哪个区域",
* 但回答不了"贵还是便宜"—— 那取决于分母(利润/EBIT)能不能兑现,属于图 3 与脚注的事。
*/
function buildMultipleChart(profile, rows, { width, height, params }) {
const now = resolvePrice(profile, params);
const cfg = profile.multiple;
const heroIndex = cfg.scenarios.findIndex((item) => item.hero);
const nowValue = metricAt(rows, now, cfg.scenarios[heroIndex].key);
const lines = cfg.scenarios.map((scenario) => {
const labels = scenario.hero ? refLabels(profile, rows, now, cfg.labels) : [];
const line = priceLine({
name: scenario.name,
rows,
key: 'pe_base',
color: FINANCIAL_COLORS.netProfit,
width: 3.5,
labeledPrices: [265, NOW, 480, 605],
unit: 'x',
position: 'right',
key: scenario.key,
color: scenario.color,
width: scenario.width,
labels,
unit: profile.metric.unit,
position: scenario.label ?? 'right',
});
hero.markLine = nowMarkLine();
if (scenario.hero) line.markLine = nowMarkLine(now);
return line;
});
// markArea 挂在第一个系列上(见 multBandMarkAreas 注释)
lines[0].markArea = { silent: true, data: multBandMarkAreas(profile) };
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 亿'],
return baseOption(profile, {
title: cfg.title,
subtitle: cfg.subtitle({ now }),
xAxis: priceAxis(profile),
yAxis: valueAxis(profile.metric.name, {
min: profile.axis.metricMin,
max: profile.axis.metricMax,
interval: profile.axis.metricInterval,
}),
series: lines,
legend: cfg.legend,
width,
height,
grid: { bottom: 134 },
extraGraphics: nowGraphics(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',
extraGraphics: nowGraphics(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: multipleFormatter(profile),
extraNote: cfg.notes({ now, rows }).join('\n'),
});
}
/**
* 图 2:净现金让 PE 便宜了多少 —— 资料库原表第 2 节右图的图形版
* 图 2:非经营资产让倍数便宜了多少
*
* 含现金 PE 与扣现金 PE 是两条**严格平行**的线,间距恒为 净现金 ÷ 2026E 净利
* (约 2.35x)—— 间距与股价无关,但折价占市值的比例随股价下降而放大
* (220 元 22.2% → 700 元 7.0%)。所以这张图要看的不是两条线的高低,
* 各层级口径是**严格平行**的一组线,层间距恒为 该项资产 ÷ 分母 —— 间距与股价无关,
* 但折价占市值的比例随股价下降而放大。所以这张图要看的不是几条线的高低,
* 而是中间那层沙色:它是一块不产生经营利润、但实实在在归股东的资产。
* 宁德只有一层(净现金);腾讯有两层,薄的是净现金、厚的是投资组合 ——
* 只扣现金几乎不动,正是"假阴性"的来源。
*
* 实现说明:ECharts 没有"两个系列之间填充",只能用 stack —— 下段是扣现金 PE、
* 上段是差额,差额段线宽 0、面积填沙色,上沿即含现金 PE。stack 的叠放顺序不能改
* (改了基准就错),于是用 z 控制绘制层级,防止沙色覆盖掉扣现金那条实线。
* 实现说明:ECharts 没有"两个系列之间填充",只能用 stack —— 栈底是最低那条,
* 往上每一层是相邻两层之差;差额段线宽 0、面积填沙色,上沿即上一层口径。
* 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');
function buildAssetTierChart(profile, rows, { width, height, params }) {
const now = resolvePrice(profile, params);
const cfg = profile.tiers;
const bottom = cfg.lines[cfg.lines.length - 1];
const nowValue = metricAt(rows, now, bottom.key);
const cashLine = {
...lineSeries({ name: '扣现金 PE', data: [], color: PALETTE.brown, width: 3.5 }),
const base = {
...lineSeries({ name: bottom.name, data: [], color: bottom.color, width: bottom.width }),
label: { show: false },
stack: 'pe',
stack: 'tier',
z: 3,
areaStyle: { opacity: 0 },
data: rows.map((row) => [row.price, row.pe_ex_cash]),
data: rows.map((row) => [row.price, row[bottom.key]]),
};
cashLine.markLine = nowMarkLine();
cashLine.markArea = { silent: true, data: peBandMarkAreas() };
base.markLine = nowMarkLine(now);
base.markArea = { silent: true, data: multBandMarkAreas(profile) };
const band = {
...lineSeries({ name: '净现金差额', data: [], color: PALETTE.sand, width: 0 }),
// 自下而上堆差:第 i 层带 = lines[i] − lines[i+1](lines 从上到下)
const bands = [];
for (let i = cfg.lines.length - 2; i >= 0; i -= 1) {
const upper = cfg.lines[i];
const lower = cfg.lines[i + 1];
const style = cfg.bands[cfg.lines.length - 2 - i];
bands.push({
...lineSeries({ name: style.name, data: [], color: style.color, width: 0 }),
label: { show: false },
stack: 'pe',
stack: 'tier',
z: 2,
lineStyle: { color: PALETTE.sand, width: 0, opacity: 0 },
itemStyle: { color: PALETTE.sand },
lineStyle: { color: style.color, width: 0, opacity: 0 },
itemStyle: { color: style.color },
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]),
};
areaStyle: { color: style.color, opacity: 0.7 },
data: rows.map((row) => [row.price, row[upper.key] - row[lower.key]]),
});
}
const gross = {
...lineSeries({ name: '含现金 PE', data: [], color: FINANCIAL_COLORS.investingCashFlow, width: 3 }),
// 栈顶以上的每一层都要自己的实线(栈只有上沿,没有中间的线)
const overlays = cfg.lines.slice(0, -1).map((tier, index) => ({
...lineSeries({ name: tier.name, data: [], color: tier.color, width: tier.width }),
label: { show: false },
z: 4,
data: rows.map((row) => [row.price, row.pe_base]),
};
z: 4 + index,
data: rows.map((row) => [row.price, row[tier.key]]),
}));
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'],
const pctAt = (price) => metricAt(rows, price, cfg.pctKey);
const bandCtx = bands.map((band, index) => ({
name: band.name,
// bands 自下而上,配置里的 bands 也是自下而上 → 顺序一致
gap: rows[0] ? rows[0][cfg.lines[cfg.lines.length - 2 - index].key] - rows[0][cfg.lines[cfg.lines.length - 1 - index].key] : 0,
}));
return baseOption(profile, {
title: cfg.title,
subtitle: cfg.subtitle({
nonOpText: profile.nonOp.toLocaleString('zh-CN'),
bands: bandCtx,
}),
xAxis: priceAxis(profile),
yAxis: valueAxis(profile.metric.name, {
min: profile.axis.metricMin,
max: profile.axis.metricMax,
interval: profile.axis.metricInterval,
}),
// 栈底 → 差额带 → 各层实线;「差额」不进图例(线宽 0,图例色块会是空的)
series: [base, ...bands, ...overlays],
legend: cfg.lines.map((tier) => tier.name),
width,
height,
grid: { bottom: 134 },
extraGraphics: nowGraphics(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% —— 股价越低,这层折价越厚`,
extraGraphics: nowGraphics(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: multipleFormatter(profile),
extraNote: cfg.notes({
bands: bandCtx,
dataMin: profile.dataMin,
dataMax: profile.dataMax,
minPct: pctAt(profile.dataMin).toFixed(1),
maxPct: pctAt(profile.dataMax).toFixed(1),
rows,
}).join('\n'),
});
}
/**
* 图 3:任意股价试算 —— 资料库原表第 5 节计算器的图形版
* 图 3:任意股价试算
*
* 计算器要输入一个价格才出结果,图表反过来:220–700 元全档都画出来,任意价位
* 沿竖线直接读。两条线是同一个目标价(605 元 = 2028E 1,400 亿 × 20x)下的三年
* 年化收益,差额即 50% 分红率的贡献。纵轴穿过 0 的位置就是"三年白干"—— 正好
* 是 605 元这条卖点线,所以零线本身就是一条结论。
*
* 纵向底带把估值区域直接画在股价轴上:横带看 PE、竖带看股价,是同一套档位。
* 击球区上沿 270.9 元、买入区上沿 322.9 元、合理区上沿 520.9 元(由 pe_base 反解)。
* 计算器要输入一个价格才出结果,图表反过来:整档价格都画出来,任意价位沿竖线直接读。
* 纵轴穿过 0 的位置就是"三年白干"—— 它正好落在退出价上,所以零线本身就是一条结论。
* 纵向底带把估值区域直接画在股价轴上:横带看倍数、竖带看股价,是同一套档位。
*/
export function sweetCatlAnyPriceChart(rows, { width, height }) {
const hero = priceLine({
name: '含分红年化',
function buildAnyPriceChart(profile, rows, { width, height, params }) {
const now = resolvePrice(profile, params);
const cfg = profile.anyPrice;
const heroIndex = cfg.lines.findIndex((item) => item.hero);
const heroSpec = cfg.lines[heroIndex];
const nowValue = metricAt(rows, now, heroSpec.key);
const edges = zoneEdges(profile, rows);
const lines = cfg.lines.map((item) => {
const line = priceLine({
name: item.name,
rows,
key: 'cagr_total',
color: FINANCIAL_COLORS.netProfit,
width: 3.5,
labeledPrices: [265, NOW, 450, TARGET_2028],
key: item.key,
color: item.color,
width: item.width,
labels: refLabels(profile, rows, now, item.labels ?? []),
unit: '%',
decimals: 1,
position: 'top',
distance: 10,
position: item.position ?? 'right',
distance: item.distance ?? 8,
});
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,
if (item.hero) {
line.markLine = nowMarkLine(now);
// markArea 挂在主角系列上,否则会盖住同图上先声明的折线
line.markArea = { silent: true, data: priceBandMarkAreas(profile, rows) };
}
return line;
});
return baseOption({
title: '任意股价试算:三年预期年化收益',
subtitle: '单位:% · 目标价 605 元(2028E 1,400 亿 × 20x)· 纵带为估值区域',
xAxis: priceAxis(),
yAxis: valueAxis('年化收益(%)', { min: -10, max: 50, interval: 10 }),
series: [hero, priceOnly],
legend: ['含分红年化', '仅价差年化'],
return baseOption(profile, {
title: cfg.title,
subtitle: cfg.subtitle({ now }),
xAxis: priceAxis(profile),
yAxis: valueAxis('年化收益(%)', cfg.yAxis),
series: lines,
legend: cfg.legend,
width,
height,
grid: { bottom: 134 },
extraGraphics: nowGraphics(width, `现价 ${NOW} 元 = 含分红年化 ${metricAt(rows, NOW, 'cagr_total').toFixed(1)}%`),
extraGraphics: nowGraphics(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: (value) => `${Number(Array.isArray(value) ? value[1] : value).toFixed(1)}%`,
extraNote: `年化收益 =(${TARGET_2028} ÷ 股价)^(1/3) − 1;含分红口径再乘 (1 + 股息率),股息按当前价每年派现、不复投\n`
+ '纵带边界由 pe_base 反解:击球区上沿 270.9 元 | 买入区上沿 322.9 元 | 合理区上沿 520.9 元',
extraNote: cfg.notes({
currency: profile.currency,
// 边界从数据反解、不写死:改股本/汇率/利润预期后这里跟着走
boundary: profile.zones.slice(0, -1)
.map((zone, index) => (edges[index] === null ? null : `${zone.name} ${edges[index].toFixed(1)}`))
.filter(Boolean).join(' | '),
rows,
}).join('\n'),
});
}
/* ============================================================
* 导出:每家三张图
* ============================================================ */
export const sweetCatlPricePeChart = (rows, options) => buildMultipleChart(CATL, rows, options);
export const sweetCatlNetCashChart = (rows, options) => buildAssetTierChart(CATL, rows, options);
export const sweetCatlAnyPriceChart = (rows, options) => buildAnyPriceChart(CATL, rows, options);
export const sweetTencentPriceEvChart = (rows, options) => buildMultipleChart(TENCENT, rows, options);
export const sweetTencentNonOpChart = (rows, options) => buildAssetTierChart(TENCENT, rows, options);
export const sweetTencentAnyPriceChart = (rows, options) => buildAnyPriceChart(TENCENT, rows, options);
export const SWEET_SPOT_PROFILES = PROFILES;
+114 -5
View File
@@ -7,6 +7,7 @@ import {
renderCards,
renderCartBar,
renderCompanies,
renderTabControls,
renderTabs,
showChartError,
showMergePreview,
@@ -15,6 +16,12 @@ import {
const instances = new Map();
const activeViews = new Map();
const chartRenderVersions = new Map();
// tab 级参数(现价等):`${companyId}/${tabId}` → { price: 342.5 }
// 存进 Map 而不是 DOM,是为了切 tab / 改窗口尺寸重渲染后还能带着值回来
const tabParams = new Map();
// CSV 缓存:source → Promise<rows>。缓存 Promise,同一 tab 的多张图共用一次请求,
// 手输现价连续重渲染也不会重复拉数据;「重新加载数据」换 cacheToken 即整体失效。
const rowCache = new Map();
// 合并队列:instanceId → { instanceId, title, filename, dataUrl }
// dataUrl 是加购(及后续每次重渲染)时的快照,切换 tab 后实例被 dispose 也能照常导出
const cart = new Map();
@@ -25,6 +32,7 @@ let shell;
let state;
let renderVersion = 0;
let cacheToken = Date.now();
let paramTimer;
function validateManifest(value) {
if (!Array.isArray(value?.companies) || value.companies.length === 0) {
@@ -51,6 +59,7 @@ function validateManifest(value) {
if (!tab.id || tabIds.has(tab.id)) throw new Error(`公司 ${company.id} 的 tab id 重复或为空`);
if (!Array.isArray(tab.chartRefs)) throw new Error(`公司 ${company.id} 的 ${tab.id} 缺少 chartRefs`);
tabIds.add(tab.id);
validateControls(company.id, tab);
const refs = new Set();
tab.chartRefs.forEach((chartId) => {
if (!company.chartCatalog[chartId]) throw new Error(`栏目 ${tab.id} 引用了不存在的图表:${chartId}`);
@@ -61,6 +70,24 @@ function validateManifest(value) {
});
}
/** tab.controls 是图表参数的声明处(现价等),这里挡住会导致输入框静默失效的配置 */
function validateControls(companyId, tab) {
const seen = new Set();
(tab.controls ?? []).forEach((control) => {
const where = `公司 ${companyId} 的 ${tab.id} 控件 ${control.id || '(空)'}`;
if (!control.id || seen.has(control.id)) throw new Error(`${where} 的 id 重复或为空`);
seen.add(control.id);
if (control.type !== 'number') throw new Error(`${where} 的 type 不受支持:${control.type}`);
['value', 'min', 'max'].forEach((field) => {
if (!Number.isFinite(control[field])) throw new Error(`${where} 的 ${field} 必须是数字`);
});
if (control.min > control.max) throw new Error(`${where} 的 min > max`);
if (control.value < control.min || control.value > control.max) {
throw new Error(`${where} 的默认值 ${control.value} 不在 ${control.min}–${control.max} 内`);
}
});
}
function getCompany(companyId) {
return manifest.companies.find((company) => company.id === companyId) || manifest.companies[0];
}
@@ -69,12 +96,23 @@ function getTab(company, tabId) {
return company.tabs.find((tab) => tab.id === tabId) || company.tabs[0];
}
/** tab 参数:首次访问时用 manifest 的默认值播种 */
function paramValues(company, tab) {
const key = `${company.id}/${tab.id}`;
if (!tabParams.has(key)) {
tabParams.set(key, Object.fromEntries((tab.controls ?? []).map((control) => [control.id, control.value])));
}
return tabParams.get(key);
}
function chartsForTab(company, tab) {
const params = paramValues(company, tab);
return tab.chartRefs.map((chartId) => ({
...company.chartCatalog[chartId],
id: chartId,
instanceId: `${company.id}--${tab.id}--${chartId}`,
viewKey: `${company.id}--${chartId}`,
params,
}));
}
@@ -115,22 +153,39 @@ function chartSource(chart) {
return chart.views[view];
}
/**
* 读数据(带缓存)。缓存的是 Promise 本身,所以同一轮渲染里多张图引用同一份 CSV
* 只会发一次请求;失败时立刻把缓存删掉,免得下次重渲染读到同一个坏结果。
*/
function loadRows(source) {
const key = `${source}?t=${cacheToken}`;
if (!rowCache.has(key)) {
const pending = loadCSV(key);
pending.catch(() => rowCache.delete(key));
rowCache.set(key, pending);
}
return rowCache.get(key);
}
/** 图表 option:唯一构造入口,整屏渲染与「只换参数、不重建实例」的局部刷新共用 */
function buildOption(chart, rows, width, height) {
const view = chart.views ? activeViews.get(chart.viewKey) || chart.defaultView : undefined;
return getChartBuilder(chart.key)(rows, { width, height, view, params: chart.params });
}
async function renderChart(chart, version, chartVersion) {
const el = document.getElementById(`chart-${chart.instanceId}`);
if (!el) return;
const source = chartSource(chart);
const view = chart.views
? activeViews.get(chart.viewKey) || chart.defaultView
: undefined;
try {
const rows = await loadCSV(`${source}?t=${cacheToken}`);
const rows = await loadRows(source);
if (version !== renderVersion || chartVersion !== chartRenderVersions.get(chart.instanceId) || !document.body.contains(el)) return;
const instance = echarts.init(el, null, { renderer: 'canvas' });
instances.set(chart.instanceId, instance);
const { width, height } = el.getBoundingClientRect();
instance.setOption(getChartBuilder(chart.key)(rows, { width, height, view }), true);
instance.setOption(buildOption(chart, rows, width, height), true);
captureSnapshot(chart);
} catch (error) {
if (version === renderVersion) showChartError(chart.instanceId, error.message);
@@ -138,6 +193,56 @@ async function renderChart(chart, version, chartVersion) {
}
}
/**
* 只换参数(现价等)时的局部刷新:复用已有实例 setOption,不 dispose 重建。
* 重建要重新走一遍初始化与布局,连续输入时肉眼可见地卡;而 option 是全量重建的,
* setOption(option, true) 直接替换即可,没有残留状态。
*/
async function refreshChartParams() {
const company = getCompany(state.companyId);
const tab = getTab(company, state.tabId);
const version = renderVersion;
await Promise.all(chartsForTab(company, tab).map(async (chart) => {
const el = document.getElementById(`chart-${chart.instanceId}`);
const instance = instances.get(chart.instanceId);
if (!el || !instance) return;
try {
const rows = await loadRows(chartSource(chart));
// 期间若发生了整屏重渲染(切 tab / 改尺寸),这个实例已经被 dispose
if (version !== renderVersion || instance.isDisposed?.()) return;
const { width, height } = el.getBoundingClientRect();
instance.setOption(buildOption(chart, rows, width, height), true);
captureSnapshot(chart);
} catch (error) {
showChartError(chart.instanceId, error.message);
console.error(`刷新图表参数失败:${chart.id}`, error);
}
}));
}
/**
* 应用一次控件输入。返回 false 表示这次输入不合法(壳层据此把输入框标红)。
* 校验放在这里而不是把 min/max 丢给 HTML:type=number 的 min/max 只影响表单校验与
* 上下箭头,手敲越界值照样能进 value,必须自己挡。
*/
function applyTabParam(controlId, rawValue, { immediate = false } = {}) {
const company = getCompany(state.companyId);
const tab = getTab(company, state.tabId);
const control = (tab.controls ?? []).find((item) => item.id === controlId);
const value = Number.parseFloat(rawValue);
if (!control || !Number.isFinite(value) || value < control.min || value > control.max) return false;
const params = paramValues(company, tab);
if (params[controlId] === value) return true;
params[controlId] = value;
// 连续输入(例如敲 3、3、8)只重画最后一次:每次重画要重建三张图的图形
clearTimeout(paramTimer);
if (immediate) refreshChartParams();
else paramTimer = setTimeout(refreshChartParams, 140);
return true;
}
function renderSingleChart(chart) {
const previous = instances.get(chart.instanceId);
previous?.dispose();
@@ -298,6 +403,8 @@ function renderCurrent() {
const tab = getTab(company, state.tabId);
const charts = chartsForTab(company, tab);
const version = ++renderVersion;
// 整屏重渲染会带上最新参数把图都画一遍,这里再触发一次参数刷新纯属重复劳动
clearTimeout(paramTimer);
disposeAll();
renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => {
@@ -306,6 +413,7 @@ function renderCurrent() {
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
updateHash({ companyId: company.id, tabId });
});
renderTabControls(shell.params, tab.controls, paramValues(company, tab), applyTabParam);
renderCards(shell.cards, charts, activeViews, {
onExport: exportPNG,
isCarted: (instanceId) => cart.has(instanceId),
@@ -335,6 +443,7 @@ async function start() {
shell = createAppShell(document.getElementById('app'), {
onReload: () => {
cacheToken = Date.now();
rowCache.clear();
renderCurrent();
},
});
+73 -2
View File
@@ -46,16 +46,23 @@ export function createAppShell(root, { onReload }) {
const layout = element('div', 'app-layout');
const companyNav = element('aside', 'sidebar company-sidebar');
const content = element('main', 'content');
// tab 栏与参数行共处一个 sticky 容器:参数行是独立的一行(不是 tab 栏的一部分),
// 但要和 tab 栏一起钉在顶部。若让两行各自 sticky,参数行就得设「tab 栏高度」的 top
// 偏移,而 tab 栏会换行、窄屏还会横向滚动,高度并不固定;绑在一个容器里天然不会错位。
const tabHeader = element('div', 'tab-header');
const tabs = element('nav', 'tabs');
const params = element('div', 'tab-controls');
params.hidden = true;
tabHeader.append(tabs, params);
const cards = element('div', 'cards');
content.append(tabs, cards);
content.append(tabHeader, cards);
const chartNav = element('aside', 'sidebar chart-sidebar');
layout.append(companyNav, content, chartNav);
const cartBar = element('div', 'cart-bar');
cartBar.hidden = true;
root.append(layout, reloadButton, cartBar);
return { companyNav, tabs, cards, chartNav, cartBar };
return { companyNav, tabs, params, cards, chartNav, cartBar };
}
export function renderCompanies(container, companies, activeCompanyId, onSelect) {
@@ -82,6 +89,70 @@ export function renderTabs(container, tabs, activeTabId, onSelect) {
});
}
/**
* tab 级数值参数控件(目前只有「现价」)—— 由 manifest 的 tab.controls 声明。
*
* 位置:页面里独立的一行,钉在 tab 栏正下方(见 createAppShell 的 .tab-header)。
* 不塞进 tab 栏:它是改数据口径的输入,混在导航按钮里容易被当成 tab 误点。
*
* 回调约定:onChange(id, rawValue) 返回 false 表示这次输入不合法(越界/非数字),
* 输入框标红示意,图表保持原样 —— 校验规则属于控制层,壳层不重复实现。
*/
export function renderTabControls(container, controls, values, onChange) {
// 容器是常驻的,每次整屏重渲染都要清空;否则 resize(200ms 防抖)会叠出多行控件
container.replaceChildren();
container.hidden = !controls?.length;
if (!controls?.length) return;
controls.forEach((control) => {
const field = element('label', 'param-field');
if (control.hint) field.title = control.hint;
field.append(element('span', 'param-label', control.label));
const input = document.createElement('input');
input.className = 'param-input';
input.type = 'number';
input.inputMode = 'decimal';
input.step = String(control.step ?? 1);
input.min = String(control.min);
input.max = String(control.max);
input.value = String(values?.[control.id] ?? control.value);
input.setAttribute('aria-label', `${control.label}${control.unit ?? ''}`);
field.append(input, element('span', 'param-unit', control.unit ?? ''));
// 合法区间常驻显示:输入越界时它变红,用户不必猜上下限
field.append(element('span', 'param-range', `${control.min}–${control.max}`));
const reset = button('重置', {
className: 'param-reset',
onClick: () => {
input.value = String(control.value);
input.dataset.invalid = 'false';
// 值已回到默认,按钮自己进入禁用态(不等下一次 input 事件,否则点完还亮着)
reset.disabled = true;
onChange?.(control.id, input.value, { immediate: true });
input.focus();
},
});
reset.title = `回到默认值 ${control.value}`;
const apply = (immediate = false) => {
const ok = onChange?.(control.id, input.value, { immediate }) !== false;
input.dataset.invalid = String(!ok);
reset.disabled = input.value === String(control.value);
};
input.addEventListener('input', () => apply());
// 回车立即生效,不等防抖(防抖只针对连续输入)
input.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
apply(true);
});
reset.disabled = input.value === String(control.value);
container.append(field, reset);
});
}
export function renderCards(container, charts, activeViews, { onExport, isCarted, onToggleCart, onSelectView }) {
container.replaceChildren();
if (!charts.length) {
+40 -2
View File
@@ -118,10 +118,48 @@
"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": "宁德时代任意股价试算" }
"sweet-catl-any-price": { "key": "sweet-catl-any-price", "title": "任意股价试算:三年预期年化收益", "csv": "data/sweetspot/catl_price_returns.csv", "height": 740, "filename": "宁德时代任意股价试算" },
"sweet-tencent-price-ev": { "key": "sweet-tencent-price-ev", "title": "股价 → EV/EBIT 对照", "csv": "data/sweetspot/tencent_price_ev.csv", "height": 740, "filename": "腾讯控股股价EVEBIT对照" },
"sweet-tencent-non-op": { "key": "sweet-tencent-non-op", "title": "扣掉非经营资产后,倍数低了多少", "csv": "data/sweetspot/tencent_price_ev.csv", "height": 740, "filename": "腾讯控股非经营资产与倍数" },
"sweet-tencent-any-price": { "key": "sweet-tencent-any-price", "title": "任意股价试算:三年退出年化收益", "csv": "data/sweetspot/tencent_price_returns.csv", "height": 740, "filename": "腾讯控股任意股价试算" }
},
"tabs": [
{ "id": "catl", "name": "宁德时代", "chartRefs": ["sweet-catl-price-pe", "sweet-catl-net-cash", "sweet-catl-any-price"] }
{
"id": "catl",
"name": "宁德时代",
"controls": [
{
"id": "price",
"type": "number",
"label": "现价",
"unit": "元",
"value": 316.21,
"min": 220,
"max": 700,
"step": 1,
"hint": "手输现价,三张图的现价标线与标注同步移动。可输区间即数据区 220–700 元;默认值 316.21 元 = 2026-09-15 收盘价,与 scripts/build_sweet_spot_catl.py 同源。"
}
],
"chartRefs": ["sweet-catl-price-pe", "sweet-catl-net-cash", "sweet-catl-any-price"]
},
{
"id": "tencent",
"name": "腾讯控股",
"controls": [
{
"id": "price",
"type": "number",
"label": "现价",
"unit": "港元",
"value": 426.8,
"min": 240,
"max": 640,
"step": 1,
"hint": "手输现价,三张图的现价标线与标注同步移动。可输区间即数据区 240–640 港元;默认值 426.8 港元 = 2026-09-17 收盘价,与 scripts/build_sweet_spot_tencent.py 同源。"
}
],
"chartRefs": ["sweet-tencent-price-ev", "sweet-tencent-non-op", "sweet-tencent-any-price"]
}
]
},
{
+2 -1
View File
@@ -9,7 +9,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node test/regress.mjs"
"test": "node test/regress.mjs",
"test:param": "node test/sweet_spot_param.mjs"
},
"keywords": [
"echarts",
+3 -1
View File
@@ -79,7 +79,9 @@ def main():
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))
# 价格区间与表 1 保持一致(220–700):站内「现价」输入框是三张图共用的,
# 若这里只到 620,手输 650 时图 3 会取不到值(插值落在数据区外 → NaN)。
rows = price_grid(700, 20, (265, 290, 303, NOW, 378, 450, TARGET_2028))
lines = [
"price,market_cap,pe_base,dividend_yield,cagr_price,cagr_total"
]
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""甜蜜击球区 · 腾讯控股(00700.HK):股价 → 估值指标 投影表生成器
数据层定位:本脚本是 data/sweetspot/tencent_*.csv 的唯一生成入口(CSV 是运行时投影,
禁止手工编辑)。上游底稿是资料库节点「腾讯控股(00700.HK)甜蜜击球区检验」
(https://www.workbuddy.cn/space/d/Ey5NeiPls71FC12zn0ieSX,2026-09-17 版)。
与宁德那套(build_sweet_spot_catl.py)的两处口径差异,都是腾讯特有的:
1. 纵轴是 EV/EBIT,不是 PE。腾讯的「类现金」是 5,260 亿投资组合(股权、会波动),
只扣净现金(582 亿)几乎不改变倍数 —— 必须连投资组合一起扣,才是真实的经营资产价格。
2. 三情景是 EBIT(悲观 2,400 / 基准 2,585 / 乐观 2,900 亿元),不是归母净利。
分子分母成对:分母扣了投资组合,分子就不能含投资收益,所以用经营盈利口径。
计算基准(改这几个常量就能整表重算):
总股本 90.95 亿股
汇率 1 HKD = 0.86154 CNY(中国外汇交易中心 2026-09-17 中间价)
净现金 581.9 亿元
上市投资 4,872.0 亿元(公允价值)
非上市投资 387.9 亿元(账面)
非经营资产 5,841.8 亿元(= 净现金 + 上市 + 非上市,占市值 17.5%)
TTM EBIT 2,585.43 亿元(= 2,415.62 − 1,176.70 + 1,346.51)
折旧情景 5 年直线 → 稳态 EBIT 2,247 亿元(占当前 EBIT 13.1%)
现价 426.8 港元(2026-09-17 收盘)
甜蜜区 8x 白送 / 10x 甜点 / 12x 上沿(巴菲特 10x 税前锚)
目标价 12x 上沿 470.5 / 10x 甜点 404.5 港元(基准 EBIT,不给增长)
用法:
python3 scripts/build_sweet_spot_tencent.py
"""
from pathlib import Path
SHARES = 90.95 # 亿股
FX = 0.86154 # 1 HKD = ? CNY
NET_CASH = 581.9 # 亿元
LISTED = 4872.0 # 亿元(上市投资,公允价值)
UNLISTED = 387.9 # 亿元(非上市投资,账面)
NON_OP = NET_CASH + LISTED + UNLISTED # 5,841.8 亿元
EBIT = {"bear": 2400.0, "base": 2585.43, "bull": 2900.0}
EBIT_DEP = 2247.0 # 5 年折旧情景下的稳态 EBIT(亿元)
NOW = 426.8 # 港元
EXIT_MULT = {"high": 12.0, "mid": 10.0} # 三年后退出倍数(甜蜜区上沿 / 甜点)
PRICE_BOTTOM = 240 # 港元,数据区下限
PRICE_TOP = 640 # 港元,数据区上限
OUT_DIR = Path(__file__).resolve().parent.parent / "data" / "sweetspot"
# 数据点里必须出现的价位:甜蜜区标尺 5 档 + 现价。
# 它们要能被折线的数据标签命中(label.position 不接受函数,只能逐点覆盖 data)。
KEY_PRICES = (
8 * EBIT["base"] + NON_OP,
9 * EBIT["base"] + NON_OP,
10 * EBIT["base"] + NON_OP,
NOW * SHARES * FX,
12 * EBIT["base"] + NON_OP,
)
KEY_PRICES = tuple(value / (SHARES * FX) for value in KEY_PRICES)
def price_grid(top, step=20, keys=()):
"""等距网格 + 关键价位去重后升序,保证关键价位本身就是数据点(可被打标签)。"""
prices = {float(p) for p in range(PRICE_BOTTOM, top + 1, step)}
prices.update(round(float(k), 1) for k in keys)
return sorted(prices)
def price_to_hkd(value):
"""目标价公式:股价 =(目标倍数 × EBIT + 非经营资产)÷ 股本 ÷ 汇率。"""
return value / (SHARES * FX)
def mult_at(price, ebit, deduct=NON_OP):
"""EV/EBIT =(市值 − 扣除的非经营资产)÷ EBIT。deduct=0 即不扣任何资产。"""
return (price * SHARES * FX - deduct) / ebit
def main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
header = (
"# 由 scripts/build_sweet_spot_tencent.py 生成,请勿手工编辑。\n"
"# 口径:总股本 90.95 亿股 · 汇率 0.86154 · 非经营资产 5,841.8 亿元"
"(净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9)· "
"TTM EBIT 2,585.43 亿元 · 三情景 EBIT 2,400/2,585/2,900 亿元 · 现价 426.8 港元(2026-09-17)\n"
"# 倍数与年化收益保留两位小数(与宁德两份表同精度;倍数一位会把 10.68x 与 10.7x 揉成一个读数)\n"
)
# ---------- 1) 股价 → 倍数对照(图 1、图 2 共用) ----------
rows = price_grid(PRICE_TOP, 20, KEY_PRICES)
lines = [
"price,market_cap,mult_bear,mult_base,mult_bull,mult_net_cash,mult_gross,non_op_pct"
]
for px in rows:
cap = px * SHARES * FX
lines.append(
",".join([
f"{px:.2f}",
f"{cap:.1f}",
f"{mult_at(px, EBIT['bear']):.2f}",
f"{mult_at(px, EBIT['base']):.2f}",
f"{mult_at(px, EBIT['bull']):.2f}",
f"{mult_at(px, EBIT['base'], NET_CASH):.2f}",
f"{mult_at(px, EBIT['base'], 0):.2f}",
f"{NON_OP / cap * 100:.1f}",
])
)
path = OUT_DIR / "tencent_price_ev.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) ----------
# 目标价三年不变(不给增长):退出在甜蜜区上沿 / 甜点 / 折旧后的上沿,年化只由买入价决定。
targets = {
"cagr_12x": price_to_hkd(EXIT_MULT["high"] * EBIT["base"] + NON_OP),
"cagr_10x": price_to_hkd(EXIT_MULT["mid"] * EBIT["base"] + NON_OP),
"cagr_12x_dep": price_to_hkd(EXIT_MULT["high"] * EBIT_DEP + NON_OP),
}
rows = price_grid(PRICE_TOP, 20, KEY_PRICES)
lines = ["price,market_cap,mult_base,cagr_12x,cagr_10x,cagr_12x_dep"]
for px in rows:
cap = px * SHARES * FX
lines.append(
",".join([
f"{px:.2f}",
f"{cap:.1f}",
f"{mult_at(px, EBIT['base']):.2f}",
*[f"{((value / px) ** (1 / 3) - 1) * 100:.2f}" for value in (
targets["cagr_12x"], targets["cagr_10x"], targets["cagr_12x_dep"],
)],
])
)
path = OUT_DIR / "tencent_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)} 行")
print("\n目标价(港元):" + " | ".join(f"{k} {v:.1f}" for k, v in targets.items()))
print("现价倍数:" + " | ".join([
f"悲观 {mult_at(NOW, EBIT['bear']):.2f}x",
f"基准 {mult_at(NOW, EBIT['base']):.2f}x",
f"乐观 {mult_at(NOW, EBIT['bull']):.2f}x",
f"只扣净现金 {mult_at(NOW, EBIT['base'], NET_CASH):.2f}x",
f"不扣 {mult_at(NOW, EBIT['base'], 0):.2f}x",
]))
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
/**
* 甜蜜击球区「现价」参数回归测试
*
* 覆盖:手输现价后,每张图的现价标线 x = 输入值,且标注里的估值指标与该价位的
* 独立公式一致(宁德:PE / 扣现金 PE / 含分红年化;腾讯:EV/EBIT / 扣非经营资产 EV、年化)。
*
* 为什么单独一个文件:test/regress.mjs 是「遍历 manifest 的通用样式回归」,
* 而现价是 tab 级交互参数,通用回归跑不到它(它永远按默认现价渲染)。
*
* 【独立性】下面的公式不是从 charts 里 import 的,而是照 scripts/build_sweet_spot_*.py
* 的基准常量重写一遍 —— 测试要能抓到「图表实现算错」,就不能复用被它测的那份代码。
*
* 用法:node test/sweet_spot_param.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseCSV } from '../js/csv.js';
import { getChartBuilder } from '../js/charts/index.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/* ============================================================
* 宁德时代(300750)—— 与 scripts/build_sweet_spot_catl.py 同源
* ============================================================ */
const CATL_SHARES = 46.27; // 亿股
const CATL_NET_CASH = 2263.8; // 亿元
const CATL_PROFIT = 964; // 2026E 归母净利(亿元,中性)
const CATL_PAYOUT = 0.5; // 分红率
const CATL_TARGET = 605; // 三年目标价(元)
const CATL = {
name: '宁德时代 · PE 口径',
tab: 'catl',
/** 可手输区间 = 数据区(220–700 元,见 manifest 的 controls[].min/max)。 */
band: [220, 700],
/** 区间内 + 两个端点 + 越界值(越界时图内标注必为 NaN,正因如此 UI 要拦下) */
prices: [316.21, 400, 310, 220, 700, 201, 750],
cases: [
{ key: 'sweet-catl-price-pe', csv: 'data/sweetspot/catl_price_pe.csv', metric: '归母 PE' },
{ key: 'sweet-catl-net-cash', csv: 'data/sweetspot/catl_price_pe.csv', metric: '扣现金 PE' },
{ key: 'sweet-catl-any-price', csv: 'data/sweetspot/catl_price_returns.csv', metric: '含分红年化' },
],
/** 每张图的「现价标注」应当等于哪个公式 */
expected(key, price) {
if (key === 'sweet-catl-net-cash') return (price * CATL_SHARES - CATL_NET_CASH) / CATL_PROFIT;
if (key === 'sweet-catl-any-price') {
// 与生成脚本同式:(1 + 价差年化) × (1 + 股息率) − 1
const cagr = (CATL_TARGET / price) ** (1 / 3) - 1;
const yieldRate = (CATL_PAYOUT * CATL_PROFIT) / (price * CATL_SHARES);
return ((1 + cagr) * (1 + yieldRate) - 1) * 100;
}
return (price * CATL_SHARES) / CATL_PROFIT;
},
/** 标注显示精度:年化 1 位小数、倍数 2 位 → 容差随之放宽 */
tolerance: (key) => (key === 'sweet-catl-any-price' ? 0.1 : 0.01),
};
/* ============================================================
* 腾讯控股(00700.HK)—— 与 scripts/build_sweet_spot_tencent.py 同源
* ============================================================ */
const TENCENT_SHARES = 90.95; // 亿股
const TENCENT_FX = 0.86154; // 1 HKD = ? CNY
const TENCENT_NET_CASH = 581.9; // 亿元
const TENCENT_NON_OP = 5841.8; // 亿元(净现金 + 上市投资 + 非上市投资)
const TENCENT_EBIT = 2585.43; // TTM EBIT(亿元,基准)
const TENCENT = {
name: '腾讯控股 · EV/EBIT 口径',
tab: 'tencent',
band: [240, 640],
prices: [426.8, 500, 420, 240, 640, 220, 700],
cases: [
{ key: 'sweet-tencent-price-ev', csv: 'data/sweetspot/tencent_price_ev.csv', metric: '基准 EV/EBIT' },
{ key: 'sweet-tencent-non-op', csv: 'data/sweetspot/tencent_price_ev.csv', metric: '全扣 EV/EBIT' },
{ key: 'sweet-tencent-any-price', csv: 'data/sweetspot/tencent_price_returns.csv', metric: '退出 12x 年化' },
],
expected(key, price) {
// 图 1 的主角是基准情景、图 2 的栈底是「连投资组合一起扣」——两者都是 mult_base
if (key !== 'sweet-tencent-any-price') {
return (price * TENCENT_SHARES * TENCENT_FX - TENCENT_NON_OP) / TENCENT_EBIT;
}
// 目标价 =(12x × EBIT + 非经营资产)÷ 股本 ÷ 汇率,再年化;不给 EBIT 增长
const target = (12 * TENCENT_EBIT + TENCENT_NON_OP) / (TENCENT_SHARES * TENCENT_FX);
return ((target / price) ** (1 / 3) - 1) * 100;
},
tolerance: (key) => (key === 'sweet-tencent-any-price' ? 0.1 : 0.01),
};
const COMPANIES = [CATL, TENCENT];
/* ============================================================
* 断言
* ============================================================ */
let fail = 0;
let pass = 0;
/** 抓现价标注:它是 graphic 里唯一一段带「现价」的水平文字(markLine 标签是关掉的) */
function nowNote(option) {
const note = (option.graphic || []).find((g) => g.type === 'text' && g.style.text.includes('现价'));
if (!note) throw new Error('找不到现价标注');
return note.style.text;
}
/**
* 从「现价 316.21 元 = 中性 15.18x」里抠出数值。两处易错:
* 1. 要取 '=' 之后那一段(split('=').pop()),否则会先撞上现价本身;
* 2. 该段里可能还有别的数字 —— 腾讯图 3 的标签是「退出 12x 上沿 3.3%」,
* 指标值在**最后一个**数字里,取第一个会得到 12。
*/
function shownValue(text) {
const tail = text.split('=').pop();
const matches = tail.match(/-?[\d.]+/g);
return matches ? Number(matches[matches.length - 1]) : Number.NaN;
}
for (const company of COMPANIES) {
console.log(`\n########## ${company.name} ##########`);
for (const { key, csv, metric } of company.cases) {
const rows = parseCSV(fs.readFileSync(path.join(ROOT, csv), 'utf8'));
const build = getChartBuilder(key);
console.log(`\n===== ${key}(${metric})=====`);
for (const price of company.prices) {
const inBand = price >= company.band[0] && price <= company.band[1];
try {
const option = build(rows, { width: 1100, height: 740, params: { price } });
const marker = option.series.find((s) => s.markLine).markLine.data[0].xAxis;
const note = nowNote(option);
if (marker !== price) throw new Error(`标线 x=${marker},期望 ${price}`);
if (!inBand) {
// 区间外不打印 NaN,而是写明「超出数据区」
if (!note.includes('超出数据区')) throw new Error(`越界价未提示「超出数据区」:${note}`);
pass += 1;
console.log(` ✓ 现价 ${String(price).padStart(6)} → 标线 x=${marker} | ${note}`);
continue;
}
const shown = shownValue(note);
const expected = company.expected(key, price);
const tolerance = company.tolerance(key);
if (!Number.isFinite(shown) || Math.abs(shown - expected) > tolerance) {
throw new Error(`标注 ${shown},公式 ${expected.toFixed(3)}(容差 ${tolerance})`);
}
pass += 1;
console.log(` ✓ 现价 ${String(price).padStart(6)} → 标线 x=${marker}`
+ ` | ${note} | 公式 ${expected.toFixed(3)}`);
} catch (error) {
fail += 1;
console.log(` ✗ 现价 ${price}:${error.message}`);
}
}
}
}
console.log(`\n${pass} 项通过 / ${fail} 项失败`);
process.exit(fail === 0 ? 0 : 1);