From f646ee67183e4b69bce3babfdf7415e08bbf1d0d Mon Sep 17 00:00:00 2001 From: R524809 Date: Thu, 24 Sep 2026 17:14:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=AE=81=E5=BE=B7?= =?UTF-8?q?=E6=97=B6=E4=BB=A3=E7=9A=84=E5=9B=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 54 ++ css/style.css | 119 +++- data/sweetspot/catl_price_returns.csv | 4 + data/sweetspot/tencent_price_ev.csv | 30 + data/sweetspot/tencent_price_returns.csv | 30 + docs/图片导出下载实现.md | 138 ++++ js/charts/index.js | 4 + js/charts/sweet_spot.js | 770 ++++++++++++++++------- js/main.js | 119 +++- js/ui.js | 75 ++- manifest.json | 42 +- package.json | 3 +- scripts/build_sweet_spot_catl.py | 4 +- scripts/build_sweet_spot_tencent.py | 147 +++++ test/sweet_spot_param.mjs | 160 +++++ 15 files changed, 1459 insertions(+), 240 deletions(-) create mode 100644 data/sweetspot/tencent_price_ev.csv create mode 100644 data/sweetspot/tencent_price_returns.csv create mode 100644 docs/图片导出下载实现.md create mode 100644 scripts/build_sweet_spot_tencent.py create mode 100644 test/sweet_spot_param.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 72afd82..7a5665c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`(蓝色基准、青绿色正向、珊瑚红负向);不要在图表文件里为相同财务/渠道语义重写十六进制颜色。柱状系列负责分类,折线只保留一条高辨识度强调色。 diff --git a/css/style.css b/css/style.css index 9a05246..dabbac5 100644 --- a/css/style.css +++ b/css/style.css @@ -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; } diff --git a/data/sweetspot/catl_price_returns.csv b/data/sweetspot/catl_price_returns.csv index a46470d..1f1cdc5 100644 --- a/data/sweetspot/catl_price_returns.csv +++ b/data/sweetspot/catl_price_returns.csv @@ -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 diff --git a/data/sweetspot/tencent_price_ev.csv b/data/sweetspot/tencent_price_ev.csv new file mode 100644 index 0000000..ceb8b9d --- /dev/null +++ b/data/sweetspot/tencent_price_ev.csv @@ -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 diff --git a/data/sweetspot/tencent_price_returns.csv b/data/sweetspot/tencent_price_returns.csv new file mode 100644 index 0000000..1903294 --- /dev/null +++ b/data/sweetspot/tencent_price_returns.csv @@ -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 diff --git a/docs/图片导出下载实现.md b/docs/图片导出下载实现.md new file mode 100644 index 0000000..f9e8b6a --- /dev/null +++ b/docs/图片导出下载实现.md @@ -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()` 出一个弹窗(弹窗不受沙箱约束),在弹窗里挂 `` 触发下载。 +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-*` 样式 | diff --git a/js/charts/index.js b/js/charts/index.js index e5f3f38..2c871d6 100644 --- a/js/charts/index.js +++ b/js/charts/index.js @@ -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) { diff --git a/js/charts/sweet_spot.js b/js/charts/sweet_spot.js index 3a7d858..3f43710 100644 --- a/js/charts/sweet_spot.js +++ b/js/charts/sweet_spot.js @@ -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; +/* ============================================================ + * 配置:宁德时代(300750) + * ============================================================ */ -/** 净现金(亿元)。仅用于图 2 的文案;沙色带的几何量取自 CSV 的 pe_base − pe_ex_cash。 */ -const NET_CASH = 2263.8; +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 轴上叠出两块无差别的底色。 + */ + 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}`, + ], + }, +}; -/** 2028E 目标价(1,400 亿 × 20x ÷ 46.27 亿股),图 3 的年化收益基准。 */ -const TARGET_2028 = 605; +/* ============================================================ + * 配置:腾讯控股(00700.HK) + * ============================================================ */ -/** - * 五档估值区域(按中性情景 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 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 PE_MIN = 8; -const PE_MAX = 38; -const PRICE_MIN = 200; -const PRICE_MAX = 720; +const PROFILES = { catl: CATL, tencent: TENCENT }; -const SOURCE_NOTE = '数据来源:宁德时代 2026 中报(本地财报库)· 65 家机构 2026E 一致预期'; +/* ============================================================ + * 通用工具(两家公司共用) + * ============================================================ */ /** 十六进制转 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 亿', - rows, - key: 'pe_base', - color: FINANCIAL_COLORS.netProfit, - width: 3.5, - labeledPrices: [265, NOW, 480, 605], - unit: 'x', - position: 'right', +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: scenario.key, + color: scenario.color, + width: scenario.width, + labels, + unit: profile.metric.unit, + position: scenario.label ?? 'right', + }); + if (scenario.hero) line.markLine = nowMarkLine(now); + return line; }); - hero.markLine = nowMarkLine(); + // 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: 'tier', + z: 2, + lineStyle: { color: style.color, width: 0, opacity: 0 }, + itemStyle: { color: style.color }, + showSymbol: false, + // opacity 必须显式写:areaStyle 的默认 opacity 是 0.7,只给 rgba 底色会被再乘一次 + areaStyle: { color: style.color, opacity: 0.7 }, + data: rows.map((row) => [row.price, row[upper.key] - row[lower.key]]), + }); + } + + // 栈顶以上的每一层都要自己的实线(栈只有上沿,没有中间的线) + const overlays = cfg.lines.slice(0, -1).map((tier, index) => ({ + ...lineSeries({ name: tier.name, data: [], color: tier.color, width: tier.width }), 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]), - }; + z: 4 + index, + data: rows.map((row) => [row.price, row[tier.key]]), + })); - 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]), - }; + 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({ - 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'], + 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: '含分红年化', - 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) }; +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 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, + const lines = cfg.lines.map((item) => { + const line = priceLine({ + name: item.name, + rows, + key: item.key, + color: item.color, + width: item.width, + labels: refLabels(profile, rows, now, item.labels ?? []), + unit: '%', + decimals: 1, + position: item.position ?? 'right', + distance: item.distance ?? 8, + }); + 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; diff --git a/js/main.js b/js/main.js index c0eb075..44339c6 100644 --- a/js/main.js +++ b/js/main.js @@ -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。缓存 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(); }, }); diff --git a/js/ui.js b/js/ui.js index 991d1cb..46f9b63 100644 --- a/js/ui.js +++ b/js/ui.js @@ -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) { diff --git a/manifest.json b/manifest.json index fe1c360..e11eb4c 100644 --- a/manifest.json +++ b/manifest.json @@ -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"] + } ] }, { diff --git a/package.json b/package.json index 94e0a88..b7b0b1b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build_sweet_spot_catl.py b/scripts/build_sweet_spot_catl.py index 391edc7..b7b5d7b 100644 --- a/scripts/build_sweet_spot_catl.py +++ b/scripts/build_sweet_spot_catl.py @@ -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" ] diff --git a/scripts/build_sweet_spot_tencent.py b/scripts/build_sweet_spot_tencent.py new file mode 100644 index 0000000..bf26ac9 --- /dev/null +++ b/scripts/build_sweet_spot_tencent.py @@ -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() diff --git a/test/sweet_spot_param.mjs b/test/sweet_spot_param.mjs new file mode 100644 index 0000000..7f89f48 --- /dev/null +++ b/test/sweet_spot_param.mjs @@ -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);