3 Commits

Author SHA1 Message Date
R524809 a46de853a0 feat: 增加 DCF 等估值工具 2026-10-22 15:43:17 +08:00
R524809 f646ee6718 feat: 增加宁德时代的图表 2026-09-24 17:14:03 +08:00
R524809 3a3d19d5dd feat: 增加宁德时代图表 2026-09-21 17:45:21 +08:00
50 changed files with 5787 additions and 28 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`(蓝色基准、青绿色正向、珊瑚红负向);不要在图表文件里为相同财务/渠道语义重写十六进制颜色。柱状系列负责分类,折线只保留一条高辨识度强调色。
+24
View File
@@ -179,3 +179,27 @@ A:编辑 `js/theme.js` 的 `PALETTE` 对象,所有图表自动同步。
## 许可
本项目代码采用 MIT 协议,数据仅供示例。
## 估值工具
左侧选择「估值工具」,打开「目标收益反推增长率」Tab。支持反推所需归母利润年复合增长率、计算预期年化 IRR、反推最高买入价;可调整安全边际、股息、股本变化率、基期和币种汇率。参数自动保存在当前浏览器,点击「恢复示例参数」可重置。
入口:`#/valuation-tools/stock-return-valuation`。默认数字是教学情景假设,不是实时行情或财报。计算口径来自 `stock-return-valuation` 技能 v1.2.0,浏览器引擎位于 `js/tools/stock-return-math.js`,界面位于 `js/tools/stock-return-calculator.js` 和同名 HTML 模板。
单独运行估值检查:`npm run test:valuation`;`npm test` 同时运行图表回归与估值检查。
「DCF 估值」Tab(`#/valuation-tools/dcf`)采用分段工作台,可设置 1—5 个增长阶段,每段输入年数与现金流年增长率,总预测期不超过 30 年。支持 FCFF/WACC 与 FCFE/股权资本成本两种口径、永续增长终值、股权价值调整和逐年折现明细。
总股本在基础参数区单独填写,单位为亿股;每股价值 = 股权价值(亿元)÷ 当前总股本(亿股)。每家公司的股本、股本日期、现金流和增长假设独立保存在当前浏览器,也可新增公司。真实公司初次使用时,现金流与股本留空,需自行核实后填写;教学示例的 10 亿股仅为假设。当前模型使用人民币金额与预测期内不变的股本。
公司以按钮形式展示,点击即可切换;当前股价、股价日期和区间阈值也按公司自动保存到 LocalStorage,兼容原有 DCF 本地记录。股价需要手动输入,不自动请求行情。未填或填错股价不影响已完成的 DCF 计算,也不会保留上次股价标记。
ECharts 区间图以 DCF 每股价值为基准,默认低于其 80% 为低估、高于其 120% 为高估,两端边界均包含在合理区间内。「区间设置」可分别修改低估折价(同时作为安全边际)与高估溢价阈值。这是当前假设下的自定义价格分区,不是独立的投资结论;无正股权价值时不显示区间。
「目标收益交叉验证」共用当前公司的股价、股本、基期和 DCF 参考价。输入独立的归母净利润及预计利润增速后,可比较现价、DCF 含安全边际参考价两种买入情景的所需利润 CAGR 与预计 IRR,并查看悲观/基准/乐观退出 PE 情景。计算复用独立「目标收益反推增长率」工具的数学引擎。
首屏顶部并列展示当前股价、按现价计算的预计年化 IRR、达到目标收益所需的利润年增速;后两项采用基准退出 PE,与下方现价情景结果同步。修改参数或切换公司后即时更新,参数不完整或无效时清除旧值。
FCFE 模式可选择以用户提供的基期现金转化率(FCFE ÷ 归母净利润)估算利润;该值会明确标注为估算,不作为独立财报验证。FCFF 模式要求单独填写归母净利润。真实公司的利润、转化率和预计利润增速默认留空,新增验证参数仍按公司保存在原有 LocalStorage 记录中。
双买入价格表按各自实际买入价反推增速及计算 IRR,不额外叠加安全边际。「共同参考买入上限」取 DCF 参考价和目标收益模型参考价的较低值,两边各应用一次安全边际。分红按独立路径计算;交叉验证中的股本变化仅影响收益模型,DCF 仍采用固定股本假设。验证参数缺失或无效时只隐藏验证结果,不影响有效的 DCF 结果。
+984
View File
@@ -0,0 +1,984 @@
/* Share the application palette; keep semantic valuation bands subdued. */
.dcf-calculator {
--df-bg:var(--surface);
--df-paper:var(--surface);
--df-ink:var(--ink);
--df-muted:#75716c;
--df-line:var(--line);
--df-accent:var(--accent);
--df-soft:var(--accent-soft);
--df-field:#faf9f7;
--df-accent-line:#d7c7b6;
--df-chart-secondary:#cbbba9;
--df-error:#a63f32;
--df-error-bg:#fff1ee;
--df-low:#e8ece5;
--df-fair:#eee9df;
--df-high:#eee5e1;
--df-good:#64715e;
--df-fair-ink:#786950;
--df-high-ink:#866d65;
--df-warning-bg:#f6f2eb;
background:var(--df-bg);
border:1px solid var(--df-line);
color:var(--df-ink);
border-radius:16px;
grid-template-columns:minmax(0,1fr);
gap:20px;
padding:28px;
font-size:13px;
line-height:1.6;
display:grid}
.dcf-calculator * {
box-sizing:border-box}
.dcf-calculator>* {
min-width:0}
.dcf-calculator [hidden] {
display:none!important}
.dcf-calculator h2 {
letter-spacing:-.8px;
margin:4px 0 5px;
font-size:27px;
font-weight:600;
line-height:1.5}
.dcf-calculator h3 {
margin:0;
font-size:14px;
font-weight:600}
.dcf-calculator p {
margin:0}
.dcf-calculator .df-muted {
color:var(--df-muted);
font-size:11px;
line-height:1.7}
.dcf-calculator .df-eyebrow {
letter-spacing:1.5px;
color:var(--df-accent);
font-size:10px;
font-weight:600}
.dcf-calculator .df-eyebrow span {
color:var(--df-muted);
letter-spacing:.5px;
font-weight:400}
.dcf-calculator .df-header {
justify-content:space-between;
align-items:center;
gap:16px;
display:flex}
.dcf-calculator .df-subtitle {
color:var(--df-muted);
font-size:12px}
.dcf-calculator .df-header-mark {
color:var(--df-muted);
white-space:nowrap;
font-size:11px}
.dcf-calculator .df-header-actions {
display:flex;
align-items:center;
flex-shrink:0;
gap:12px}
.dcf-calculator .df-help-trigger {
display:grid;
place-items:center;
width:34px;
height:34px;
padding:0;
color:var(--df-muted);
border-radius:50%}
.dcf-calculator .df-help-trigger:hover {
color:var(--df-accent)}
.dcf-calculator .df-help-dialog {
width:min(860px,calc(100% - 32px));
max-width:none;
max-height:calc(100vh - 32px);
max-height:calc(100dvh - 32px);
margin:auto;
padding:0;
border:1px solid var(--df-line);
border-radius:12px;
background:var(--df-paper);
color:var(--df-ink);
box-shadow:0 18px 60px rgb(51 51 51 / 15%);
overflow:hidden}
.dcf-calculator .df-help-dialog[open] {
display:flex;
flex-direction:column}
.dcf-calculator .df-help-dialog::backdrop {
background:rgb(51 51 51 / 35%)}
.dcf-calculator .df-help-head {
display:flex;
align-items:center;
justify-content:space-between;
gap:16px;
padding:20px 24px 16px;
border-bottom:1px solid var(--df-line)}
.dcf-calculator .df-help-head h2 {
margin:0 0 4px;
font-size:20px;
letter-spacing:0}
.dcf-calculator .df-help-close {
flex-shrink:0;
width:32px;
height:32px;
padding:0;
font-size:22px;
line-height:1;
color:var(--df-muted)}
.dcf-calculator .df-help-nav {
display:flex;
flex-shrink:0;
flex-wrap:wrap;
gap:8px;
padding:12px 24px;
border-bottom:1px solid var(--df-line);
background:var(--df-field)}
.dcf-calculator .df-help-body {
min-height:0;
overflow-y:auto;
overscroll-behavior:contain;
padding:20px 24px 24px;
font-size:13px;
line-height:1.85}
.dcf-calculator .df-help-lead {
padding:14px 16px;
background:var(--df-soft);
border-radius:7px}
.dcf-calculator .df-help-body section {
padding-top:24px;
outline-offset:-2px}
.dcf-calculator .df-help-body h3 {
margin-bottom:10px;
color:var(--df-accent);
font-size:15px}
.dcf-calculator .df-help-body section p+p {
margin-top:10px}
.dcf-calculator .df-help-formula {
margin:12px 0;
padding:12px 14px;
border-left:3px solid var(--df-accent-line);
background:var(--df-field);
font-variant-numeric:tabular-nums;
overflow-wrap:anywhere}
.dcf-calculator .df-help-table {
margin:12px 0;
overflow-x:auto}
.dcf-calculator .df-help-table table {
font-size:12px;
white-space:normal}
.dcf-calculator .df-help-table :is(th,td) {
padding:10px;
text-align:left}
.dcf-calculator .df-help-source {
margin-top:24px;
padding-top:14px;
border-top:1px solid var(--df-line);
font-size:11px;
color:var(--df-muted)}
.dcf-calculator .df-help-source a {
color:var(--df-accent);
text-underline-offset:3px}
@media (width<=680px) {
.dcf-calculator .df-header-actions {gap:6px}
.dcf-calculator .df-help-head {padding:16px}
.dcf-calculator .df-help-nav {padding:12px 16px;gap:6px}
.dcf-calculator .df-help-body {padding:16px}
.dcf-calculator .df-help-table :is(th,td) {padding:8px 6px}
}
.dcf-calculator .df-live-dot {
background:var(--df-accent);
border-radius:50%;
width:6px;
height:6px;
margin-right:7px;
display:inline-block}
.dcf-calculator button {
font:inherit;
border:1px solid var(--df-line);
background:var(--df-paper);
color:var(--df-accent);
cursor:pointer;
border-radius:7px;
padding:7px 11px;
font-size:11px;
transition:background .15s,border-color .15s}
.dcf-calculator button:hover {
background:var(--df-soft);
border-color:var(--df-accent-line)}
.dcf-calculator button:disabled {
opacity:.4;
cursor:default}
.dcf-calculator :is(input,select,button,summary):focus-visible {
outline-offset:3px;
outline:2px solid var(--df-accent)}
.dcf-calculator [data-section] {
scroll-margin-top:88px}
.dcf-calculator [data-section]:focus {
outline:none}
.dcf-calculator label {
color:var(--df-ink);
align-content:start;
gap:6px;
min-width:0;
font-size:11px;
display:grid}
.dcf-calculator input,.dcf-calculator select {
border:1px solid var(--df-line);
background:var(--df-field);
width:100%;
min-width:0;
height:39px;
color:var(--df-ink);
font-variant-numeric:tabular-nums;
border-radius:6px;
padding:8px 10px;
font-family:inherit;
font-size:13px}
.dcf-calculator input:focus,.dcf-calculator select:focus {
border-color:var(--df-accent);
background:var(--df-paper)}
.dcf-calculator input::placeholder {
color:var(--muted);
font-size:11px}
.dcf-calculator .df-profile-bar {
flex-wrap:wrap;
align-items:center;
gap:8px;
display:flex}
.dcf-calculator .df-company-buttons {
flex-wrap:wrap;
gap:6px;
min-width:0;
display:flex}
.dcf-calculator .df-company-buttons button {
color:var(--df-muted);
overflow-wrap:anywhere;
max-width:100%;
padding:7px 13px}
.dcf-calculator .df-company-buttons button[aria-pressed=true] {
background:var(--df-soft);
border-color:var(--df-accent-line);
color:var(--df-accent);
font-weight:600}
.dcf-calculator [data-new-company] {
background:0 0;
border-style:dashed}
.dcf-calculator .df-save-status {
color:var(--df-muted);
margin-left:auto;
font-size:10px}
.dcf-calculator .df-new-company {
flex-wrap:wrap;
align-items:end;
gap:10px;
display:flex}
.dcf-calculator .df-new-company label {
flex:1;
min-width:160px}
.dcf-calculator .df-jumpnav {
border-top:1px solid var(--df-line);
flex-wrap:wrap;
gap:24px;
padding-top:12px;
display:flex}
.dcf-calculator .df-jumpnav button {
color:var(--df-muted);
background:0 0;
border:0;
padding:0;
font-size:11px}
.dcf-calculator .df-jumpnav button:first-child {
color:var(--df-accent);
font-weight:600}
.dcf-calculator .df-jumpnav button:hover {
color:var(--df-accent);
text-decoration:underline}
.dcf-calculator .df-overview {
grid-template-columns:minmax(240px,1.05fr) minmax(0,2.2fr);
align-items:stretch;
gap:14px;
display:grid}
.dcf-calculator .df-output {
min-width:0}
.dcf-calculator .df-hero {
color:var(--df-ink);
background:var(--df-soft);
border-radius:11px;
height:100%;
padding:22px 24px}
.dcf-calculator .df-hero .df-muted {
color:var(--df-muted)}
.dcf-calculator .df-number {
letter-spacing:-1.8px;
font-variant-numeric:tabular-nums;
overflow-wrap:anywhere;
margin:8px 0 3px;
font-size:45px;
font-weight:500;
line-height:1.25}
.dcf-calculator .df-value-note {
color:var(--df-muted);
border-top:1px solid var(--df-accent-line);
margin-top:16px;
padding-top:12px;
font-size:10px}
.dcf-calculator .df-value-note b {
color:var(--df-accent);
letter-spacing:-.4px;
margin-left:7px;
font-size:19px;
font-weight:500}
.dcf-calculator [data-shares-note] {
margin-top:4px}
.dcf-calculator .df-top-comparison {
border:1px solid var(--df-line);
background:var(--df-paper);
border-radius:11px;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:0;
padding:22px 0;
display:grid}
.dcf-calculator .df-top-comparison>div {
min-width:0;
padding:0 18px}
.dcf-calculator .df-top-comparison>div+div {
border-left:1px solid var(--df-line)}
.dcf-calculator .df-top-comparison h3 {
color:var(--df-muted);
min-height:36px;
font-size:11px;
font-weight:500}
.dcf-calculator .df-top-return-value,.dcf-calculator .df-market-price {
letter-spacing:-.8px;
font-variant-numeric:tabular-nums;
overflow-wrap:anywhere;
font-size:29px;
font-weight:500;
line-height:1.4}
.dcf-calculator .df-top-return-value {
color:var(--df-accent);
margin:5px 0 8px}
.dcf-calculator .df-price-readout {
flex-wrap:wrap;
align-items:baseline;
gap:5px;
margin:5px 0 8px;
display:flex}
.dcf-calculator .df-zone-badge {
white-space:nowrap;
border-radius:4px;
padding:1px 5px;
font-size:10px}
.dcf-calculator .df-zone-badge[data-zone=低估] {
background:var(--df-low);
color:var(--df-good)}
.dcf-calculator .df-zone-badge[data-zone=合理] {
background:var(--df-fair);
color:var(--df-fair-ink)}
.dcf-calculator .df-zone-badge[data-zone=高估] {
background:var(--df-high);
color:var(--df-high-ink)}
.dcf-calculator .df-decision {
background:var(--df-soft);
border:1px solid var(--df-accent-line);
border-radius:9px;
align-items:center;
gap:20px;
padding:13px 17px;
display:flex}
.dcf-calculator .df-decision>div {
flex-wrap:wrap;
align-items:baseline;
gap:7px;
min-width:220px;
display:flex}
.dcf-calculator .df-decision .df-eyebrow {
letter-spacing:0;
font-size:10px}
.dcf-calculator .df-decision strong {
font-variant-numeric:tabular-nums;
font-size:23px;
font-weight:500}
.dcf-calculator .df-decision p {
color:var(--df-muted);
flex:1;
font-size:11px}
.dcf-calculator .df-decision button {
white-space:nowrap;
background:0 0;
border:0}
.dcf-calculator .df-decision[data-ok=false] {
background:var(--df-warning-bg);
border-color:var(--df-line)}
.dcf-calculator .df-market {
border:1px solid var(--df-line);
background:var(--df-paper);
border-radius:11px;
padding:20px 22px}
.dcf-calculator .df-market-head {
justify-content:space-between;
align-items:center;
gap:18px;
display:flex}
.dcf-calculator .df-market h3 {
margin:3px 0}
.dcf-calculator .df-price-fields {
grid-template-columns:150px 150px;
gap:10px;
display:grid}
.dcf-calculator .df-price-chart {
width:100%;
min-width:0;
height:180px;
overflow:hidden}
.dcf-calculator .df-zone-keys {
grid-template-columns:repeat(3,minmax(0,1fr));
gap:12px;
display:grid}
.dcf-calculator .df-zone-key {
background:var(--df-field);
border-radius:5px;
justify-content:space-between;
align-items:center;
gap:8px;
padding:8px 12px;
font-size:10px;
display:flex}
.dcf-calculator .df-zone-key:nth-child(2) {
background:var(--df-field)}
.dcf-calculator .df-zone-key:nth-child(3) {
background:var(--df-field)}
.dcf-calculator .df-zone-key strong {
font-variant-numeric:tabular-nums;
font-size:11px;
font-weight:500}
.dcf-calculator .df-zone-dot {
background:var(--df-good);
border-radius:50%;
width:5px;
height:5px;
margin-right:6px;
display:inline-block}
.dcf-calculator .df-zone-key:nth-child(2) .df-zone-dot {
background:var(--df-fair-ink)}
.dcf-calculator .df-zone-key:nth-child(3) .df-zone-dot {
background:var(--df-high-ink)}
.dcf-calculator .df-interval-rule {
border-top:1px solid var(--df-line);
justify-content:space-between;
align-items:start;
gap:16px;
margin-top:14px;
padding-top:12px;
display:flex}
.dcf-calculator .df-interval-rule>span {
font-size:10px}
.dcf-calculator .df-interval-rule details {
border:0;
min-width:90px;
padding:0}
.dcf-calculator .df-interval-fields {
max-width:320px;
margin-top:12px}
.dcf-calculator .df-market-message {
margin-top:12px}
.dcf-calculator .df-layout {
grid-template-columns:minmax(0,1fr) minmax(0,1.25fr);
align-items:start;
gap:20px;
display:grid}
.dcf-calculator .df-form,.dcf-calculator .df-analysis {
border:1px solid var(--df-line);
background:var(--df-paper);
border-radius:11px;
min-width:0;
padding:20px}
.dcf-calculator .df-form {
gap:22px;
display:grid}
.dcf-calculator .df-form>section+section,.dcf-calculator .df-results {
border-top:1px solid var(--df-line);
padding-top:20px}
.dcf-calculator .df-column-heading {
align-items:center;
gap:10px;
display:flex}
.dcf-calculator .df-column-heading>span {
width:29px;
height:29px;
color:var(--df-accent);
border:1px solid var(--df-accent-line);
border-radius:50%;
place-items:center;
font-size:10px;
display:grid}
.dcf-calculator .df-column-heading p {
color:var(--df-muted);
margin-top:2px;
font-size:10px}
.dcf-calculator .df-column-heading>b {
letter-spacing:1px;
color:var(--df-muted);
margin-left:auto;
font-size:10px;
font-weight:500}
.dcf-calculator .df-section-head {
justify-content:space-between;
align-items:center;
gap:10px;
margin-bottom:12px;
display:flex}
.dcf-calculator .df-section-head h3 {
font-size:12px}
.dcf-calculator .df-fields {
grid-template-columns:repeat(2,minmax(0,1fr));
gap:12px;
display:grid}
.dcf-calculator .df-share-field small {
font-size:10px}
.dcf-calculator .df-stages {
gap:8px;
display:grid}
.dcf-calculator .df-stage-row {
background:var(--df-field);
border:1px solid var(--df-line);
border-radius:7px;
grid-template-columns:minmax(55px,1fr) 65px minmax(70px,1fr) 22px;
align-items:center;
gap:8px;
padding:10px;
display:grid}
.dcf-calculator .df-stage-row input {
background:var(--df-paper);
height:34px;
padding:6px}
.dcf-calculator .df-stage-row button {
color:var(--df-muted);
background:0 0;
border:0;
padding:2px;
font-size:17px}
.dcf-calculator .df-stage-name {
font-size:11px}
.dcf-calculator .df-stage-name small {
color:var(--df-muted);
font-size:9px;
display:block}
.dcf-calculator .df-stage-key {
color:var(--df-muted);
font-size:10px}
.dcf-calculator .df-total {
margin-top:8px;
font-size:10px}
.dcf-calculator .df-result-line {
justify-content:space-between;
align-items:baseline;
gap:10px;
padding:8px 0;
font-size:11px;
display:flex}
.dcf-calculator .df-result-line span {
min-width:0}
.dcf-calculator .df-result-line b {
font-variant-numeric:tabular-nums;
white-space:nowrap;
font-weight:500}
.dcf-calculator .df-result-line.df-sum {
border-top:1px solid var(--df-line);
margin-top:4px;
padding-top:10px}
.dcf-calculator .df-composition {
background:var(--df-line);
border-radius:3px;
gap:2px;
height:6px;
margin:15px 0 8px;
display:flex;
overflow:hidden}
.dcf-calculator .df-segment-a {
background:var(--df-accent)}
.dcf-calculator .df-segment-b {
background:var(--df-chart-secondary)}
.dcf-calculator .df-legend {
color:var(--df-muted);
justify-content:space-between;
margin-bottom:4px;
font-size:10px;
display:flex}
.dcf-calculator .df-crosscheck {
min-width:0}
.dcf-calculator .df-cross-shared {
background:var(--df-field);
border-radius:5px;
margin:16px 0;
padding:8px 10px;
font-size:10px}
.dcf-calculator details {
border:0;
padding:0}
.dcf-calculator summary {
color:var(--df-accent);
cursor:pointer;
font-size:11px}
.dcf-calculator .df-cross-inputs>summary {
margin-bottom:13px}
.dcf-calculator .df-cross-inputs>summary span {
margin-left:6px;
font-size:10px}
.dcf-calculator .df-cross-grid {
grid-template-columns:minmax(0,1fr) minmax(0,1fr);
align-items:start;
gap:16px;
display:grid}
.dcf-calculator .df-cross-grid>* {
min-width:0}
.dcf-calculator .df-cross-bridge {
background:var(--df-soft);
border-radius:6px;
gap:10px;
margin-top:12px;
padding:12px;
display:grid}
.dcf-calculator .df-cross-derived {
gap:6px;
font-size:11px;
display:grid}
.dcf-calculator .df-cross-derived b {
font-size:18px;
font-weight:500}
.dcf-calculator .df-cross-profit,.dcf-calculator .df-cross-growth {
margin-top:12px}
.dcf-calculator .df-cross-source-note {
margin-top:7px;
font-size:10px}
.dcf-calculator .df-cross-pe {
grid-template-columns:repeat(3,minmax(0,1fr));
gap:6px;
margin-top:12px;
display:grid}
.dcf-calculator .df-cross-pe label {
font-size:10px}
.dcf-calculator .df-cross-pe input {
padding:8px}
.dcf-calculator .df-cross-pe label:nth-child(2) input {
background:var(--df-field);
border-color:var(--df-accent-line)}
.dcf-calculator .df-cross-pe-note {
margin-top:10px;
font-size:10px}
.dcf-calculator .df-cross-advanced {
border-top:1px solid var(--df-line);
margin-top:16px;
padding-top:13px}
.dcf-calculator .df-cross-advanced .df-fields {
grid-template-columns:repeat(3,minmax(0,1fr));
margin-top:12px}
.dcf-calculator .df-cross-results {
gap:22px;
margin-top:22px;
display:grid}
.dcf-calculator .df-cross-results>* {
min-width:0}
.dcf-calculator .df-cross-joint {
background:var(--df-soft);
border:1px solid var(--df-accent-line);
border-radius:8px;
padding:16px}
.dcf-calculator .df-cross-joint-grid {
grid-template-columns:repeat(3,minmax(0,1fr));
gap:10px;
margin-bottom:10px;
display:grid}
.dcf-calculator .df-cross-joint-grid>div {
gap:5px;
font-size:10px;
display:grid}
.dcf-calculator .df-cross-joint-grid b {
letter-spacing:-.5px;
font-variant-numeric:tabular-nums;
overflow-wrap:anywhere;
font-size:21px;
font-weight:500}
.dcf-calculator .df-cross-joint-grid>div:last-child {
color:var(--df-accent);
border-left:1px solid var(--df-accent-line);
padding-left:10px}
.dcf-calculator .df-cross-joint .df-muted {
font-size:10px}
.dcf-calculator .df-scenario-grid {
grid-template-columns:repeat(3,minmax(0,1fr));
gap:8px;
display:grid}
.dcf-calculator .df-scenario-card {
border:1px solid var(--df-line);
background:var(--df-field);
border-radius:7px;
gap:8px;
min-width:0;
padding:12px 10px;
display:grid}
.dcf-calculator .df-scenario-card[data-base=true] {
background:var(--df-soft);
border-color:var(--df-accent-line)}
.dcf-calculator .df-scenario-card>span:first-child {
color:var(--df-muted);
font-size:10px}
.dcf-calculator .df-scenario-card strong {
font-variant-numeric:tabular-nums;
letter-spacing:-.8px;
overflow-wrap:anywhere;
font-size:24px;
font-weight:500}
.dcf-calculator .df-scenario-card small {
color:var(--df-muted);
font-size:10px}
.dcf-calculator .df-scenario-status {
color:var(--df-high-ink);
font-size:10px}
.dcf-calculator .df-scenario-card[data-ok=true] .df-scenario-status {
color:var(--df-good)}
.dcf-calculator .df-cross-sensitivity .df-section-head {
margin-bottom:0}
.dcf-calculator .df-cross-sensitivity th:nth-child(3),.dcf-calculator .df-cross-sensitivity td:nth-child(3) {
background:var(--df-field)}
.dcf-calculator .df-cross-prices {
border-top:1px solid var(--df-line);
padding-top:16px}
.dcf-calculator .df-cross-prices>.df-section-head {
margin-top:16px}
.dcf-calculator .df-cross-checks {
grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px;
margin-top:16px;
display:grid}
.dcf-calculator .df-cross-checks>div {
border:1px solid var(--df-line);
border-radius:6px;
padding:12px;
font-size:11px}
.dcf-calculator .df-cross-checks strong {
margin-top:5px;
font-size:11px;
display:block}
.dcf-calculator .df-cross-checks p {
margin-top:5px}
.dcf-calculator .df-cross-verdict {
color:var(--df-fair-ink);
background:var(--df-warning-bg);
border-radius:6px;
margin-top:12px;
padding:12px;
font-size:11px}
.dcf-calculator .df-cross-table-note {
margin-top:10px;
font-size:10px}
.dcf-calculator .df-cross-tag {
color:var(--df-fair-ink);
background:var(--df-fair);
border-radius:4px;
padding:2px 6px;
font-size:10px;
display:inline-block}
.dcf-calculator .df-cross-tag[data-ok=true] {
background:var(--df-low);
color:var(--df-good)}
.dcf-calculator .df-table-wrap {
max-width:100%;
margin-top:12px;
overflow-x:auto}
.dcf-calculator table {
border-collapse:collapse;
white-space:nowrap;
font-variant-numeric:tabular-nums;
width:100%;
font-size:10px}
.dcf-calculator th,.dcf-calculator td {
text-align:right;
border-bottom:1px solid var(--df-line);
padding:10px 8px}
.dcf-calculator th {
color:var(--df-muted);
background:var(--df-field);
font-weight:400}
.dcf-calculator th:first-child,.dcf-calculator td:first-child {
text-align:left}
.dcf-calculator .df-table-section {
border:1px solid var(--df-line);
background:var(--df-paper);
border-radius:9px;
padding:17px 20px}
.dcf-calculator .df-foot {
color:var(--df-muted);
flex-wrap:wrap;
justify-content:space-between;
gap:8px;
font-size:10px;
display:flex}
.dcf-calculator .df-note {
border-top:1px solid var(--df-line);
padding-top:14px;
font-size:10px}
.dcf-calculator .df-error {
color:var(--df-error);
background:var(--df-error-bg);
border:1px solid var(--df-high);
border-radius:7px;
padding:12px 14px;
font-size:12px}
.dcf-calculator .df-warning {
color:var(--df-fair-ink);
background:var(--df-warning-bg);
border-radius:7px;
padding:12px 14px;
font-size:12px}
@media (width>=1500px) {
.tools-layout .content:has(.dcf-calculator) {
max-width:1280px}
}
@media (width<=1200px) {
.dcf-calculator {
padding:22px}
.dcf-calculator .df-overview {
grid-template-columns:1fr}
.dcf-calculator .df-hero {
grid-template-columns:1fr 1fr;
column-gap:20px;
display:grid}
.dcf-calculator .df-hero>.df-muted:first-child {
grid-column:1}
.dcf-calculator .df-number {
grid-area:2/1}
.dcf-calculator .df-hero>.df-muted:nth-child(3) {
grid-column:1}
.dcf-calculator .df-value-note {
border-top:0;
border-left:1px solid var(--df-accent-line);
grid-area:1/2/4;
align-self:center;
margin:0;
padding:10px 0 10px 24px}
.dcf-calculator .df-top-comparison h3 {
min-height:0}
.dcf-calculator .df-layout {
grid-template-columns:1fr}
.dcf-calculator .df-cross-grid {
gap:24px}
.dcf-calculator .df-top-comparison {
padding:18px 0}
}
@media (width<=680px) {
.dcf-calculator {
border-radius:10px;
gap:16px;
padding:16px}
.dcf-calculator .df-header-mark {
display:none}
.dcf-calculator h2 {
font-size:24px}
.dcf-calculator .df-save-status {
width:100%;
margin-left:0}
.dcf-calculator .df-jumpnav {
gap:15px}
.dcf-calculator .df-jumpnav button {
font-size:10px}
.dcf-calculator .df-decision {
flex-wrap:wrap;
gap:8px}
.dcf-calculator .df-decision>div {
min-width:0}
.dcf-calculator .df-decision p {
flex:100%;
order:3}
.dcf-calculator .df-decision button {
margin-left:auto}
.dcf-calculator .df-market-head {
flex-wrap:wrap}
.dcf-calculator .df-price-fields {
grid-template-columns:repeat(2,minmax(0,1fr));
width:100%}
.dcf-calculator .df-market {
padding:16px}
.dcf-calculator .df-zone-key {
gap:3px;
padding:8px;
display:grid}
.dcf-calculator .df-zone-key strong {
font-size:10px}
.dcf-calculator .df-interval-rule {
flex-wrap:wrap}
.dcf-calculator .df-form,.dcf-calculator .df-analysis {
padding:16px}
.dcf-calculator .df-top-comparison>div {
padding:0 12px}
.dcf-calculator .df-top-return-value,.dcf-calculator .df-market-price {
font-size:26px}
}
@media (width<=460px) {
.dcf-calculator .df-hero {
padding:20px;
display:block}
.dcf-calculator .df-value-note {
border-top:1px solid var(--df-accent-line);
border-left:0;
margin-top:16px;
padding:12px 0 0}
.dcf-calculator .df-top-comparison {
grid-template-columns:repeat(2,minmax(0,1fr));
gap:16px;
padding:16px 0}
.dcf-calculator .df-top-comparison>div:first-child {
border-bottom:1px solid var(--df-line);
grid-column:1/-1;
padding-bottom:12px}
.dcf-calculator .df-top-comparison>div:nth-child(2) {
border-left:0}
.dcf-calculator .df-cross-grid {
grid-template-columns:1fr;
gap:16px}
.dcf-calculator .df-cross-pe {
gap:10px}
.dcf-calculator .df-cross-checks,.dcf-calculator .df-cross-advanced .df-fields {
grid-template-columns:1fr}
.dcf-calculator .df-stage-row {
grid-template-columns:minmax(48px,1fr) 52px minmax(65px,1fr) 20px;
gap:6px;
padding:8px}
.dcf-calculator input,.dcf-calculator select {
font-size:16px}
.dcf-calculator .df-scenario-grid {
gap:6px}
.dcf-calculator .df-scenario-card {
padding:10px 8px}
.dcf-calculator .df-scenario-card strong {
font-size:21px}
.dcf-calculator .df-cross-joint-grid {
gap:8px}
.dcf-calculator .df-cross-joint-grid b {
font-size:19px}
.dcf-calculator .df-cross-inputs>summary span {
margin-left:14px;
display:block}
}
@media (prefers-reduced-motion:reduce) {
.dcf-calculator * {
transition:none!important}
}
/* Annual cash-flow chart remains visible below the collapsible detail table. */
.dcf-calculator .df-cashflow-section {padding:20px;background:var(--df-paper);border:1px solid var(--df-line);border-radius:11px;min-width:0;}
.dcf-calculator .df-cashflow-section .df-section-head {align-items:start;flex-wrap:wrap;}
.dcf-calculator .df-cashflow-section h3 {margin-bottom:4px;}
.dcf-calculator .df-cashflow-chart {height:320px;width:100%;min-width:0;}
@media(max-width:560px){.dcf-calculator .df-cashflow-section{padding:16px;}.dcf-calculator .df-cashflow-chart{height:280px;}}
/* Deletion appears only after dwelling on a company for three seconds. */
.dcf-calculator .df-company-buttons {gap:10px;}
.dcf-calculator .df-company-chip {position:relative;display:inline-flex;min-width:0;max-width:100%;}
.dcf-calculator .df-company-buttons .df-company-delete {position:absolute;top:-7px;right:-7px;display:grid;place-items:center;width:20px;height:20px;padding:0;border:1px solid var(--df-accent-line);border-radius:50%;background:var(--df-paper);color:var(--df-muted);font-size:16px;line-height:1;z-index:1;opacity:0;visibility:hidden;transition:opacity .15s,visibility 0s;}
.dcf-calculator .df-company-chip:is(:hover,:focus-within) .df-company-delete {opacity:1;visibility:visible;transition:opacity .15s 3s,visibility 0s 3s;}
.dcf-calculator .df-company-buttons .df-company-delete:hover {color:var(--df-error);border-color:var(--df-error);background:var(--df-error-bg);}
.dcf-calculator .df-company-empty {padding:28px 20px;border:1px dashed var(--df-line);border-radius:10px;color:var(--df-muted);text-align:center;}
.dcf-calculator[data-empty="true"] :is(.df-jumpnav,.df-overview,.df-decision,.df-market,.df-layout,.df-table-section,.df-cashflow-section,.df-foot,.df-note,.df-error,.df-warning) {display:none!important;}
@media(hover:none){.dcf-calculator .df-company-buttons .df-company-delete{opacity:1;visibility:visible;transition:none;}}
@media(prefers-reduced-motion:reduce) and (hover:hover){.dcf-calculator .df-company-chip:is(:hover,:focus-within) .df-company-delete{transition:visibility 0s 3s!important;}}
+176 -17
View File
@@ -53,11 +53,29 @@ button {
background: var(--accent);
border-color: var(--accent);
border-radius: 50%;
font-size: 22px;
line-height: 1;
}
.reload-button:hover { opacity: .88; transform: rotate(-45deg); }
/* 图标继承按钮的 color(currentColor),尺寸只在这里定义,换图标不用动 JS。
20px 是配 36px 圆钮的光学最优值:新图标的弧线比原 ↻ 字形细,19px 会显轻。 */
.reload-button .reload-icon {
display: block;
width: 20px;
height: 20px;
fill: currentColor;
}
/* 悬停转一圈:刷新语义的标准反馈,比原先 ↻ 字形的 -45° 倾斜更贴合新图标 */
.reload-button:hover { opacity: .88; }
.reload-button:hover .reload-icon { animation: reload-spin .6s ease-in-out; }
@keyframes reload-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.reload-button:hover .reload-icon { animation: none; }
}
.export-button:hover { color: var(--accent); border-color: var(--accent); }
/* ── 合并队列(购物车)────────────────────────────── */
@@ -305,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 {
@@ -399,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; }
@@ -424,3 +533,53 @@ button {
.chart { min-width: 560px; }
.card { overflow-x: auto; }
}
/* 估值工具沿用图表页的纸白与棕色主题。 */
.tools-title { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
.app-layout.tools-layout { grid-template-columns: 184px minmax(0, 1fr); }
.tools-layout .content { max-width: 1120px; width: 100%; }
.chart-sidebar[hidden] { display: none; }
.return-calculator {
display: grid;
gap: 22px;
min-width: 0;
padding: 26px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
}
.return-calculator [hidden] { display: none !important; }
.return-calculator h2 { margin: 0 0 10px; font-size: 22px; font-weight: 600; }
.return-calculator [data-company] { margin-bottom: 6px; font-size: 13px; color: var(--accent); }
.return-calculator .text-small { font-size: 12px; line-height: 1.8; }
.return-calculator .text-muted { color: #75716c; }
.return-calculator .viz-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 16px; }
.return-calculator .form-label { display: grid; align-content: start; gap: 8px; min-width: 0; font-size: 12px; line-height: 1.6; }
.return-calculator .form-control,
.return-calculator .form-select {
width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid var(--line);
border-radius: 6px; background: #faf9f7; color: var(--ink); font: inherit;
font-size: 15px; font-variant-numeric: tabular-nums;
}
.return-calculator .form-control:focus,
.return-calculator .form-select:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
.return-calculator details { padding: 14px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.return-calculator summary { cursor: pointer; font-size: 13px; color: var(--accent); }
.return-calculator .calc-advanced { padding-top: 20px; }
.return-calculator .calc-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.return-calculator .calc-message { margin: 0; }
.return-calculator .text-destructive { padding: 12px; border-radius: 6px; color: #a63f32; background: #fff1ee; font-size: 13px; }
.return-calculator .calc-section { display: grid; gap: 12px; min-width: 0; }
.return-calculator .viz-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.return-calculator .viz-stat { padding: 18px 14px; border: 1px solid var(--line); box-shadow: none; background: #faf9f7; font-size: 12px; overflow-wrap: anywhere; }
.return-calculator .viz-stat:first-child { background: var(--accent-soft); border-color: var(--accent-soft); }
.return-calculator .viz-stat-value { margin-top: 12px; font-size: clamp(18px, 1.7vw, 26px); font-weight: 600; color: var(--accent); font-variant-numeric: tabular-nums; }
.return-calculator .calc-notes { padding-top: 8px; border-top: 1px solid var(--line); }
.return-calculator .calc-notes p { margin: 8px 0 0; }
@media (max-width: 680px) {
.return-calculator { padding: 20px 16px; gap: 20px; }
.return-calculator h2 { font-size: 20px; }
.return-calculator .viz-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; }
.return-calculator .viz-grid { grid-template-columns: 1fr; }
.return-calculator .viz-stat-value { font-size: 24px; }
}
+7
View File
@@ -0,0 +1,7 @@
company,gp_per_wh,margin_data
宁德时代,0.122,20.63
比亚迪*,0.097,21.00
LG新能源†,-0.010,-0.70
中创新航*,0.077,16.71
国轩高科*,0.063,14.02
亿纬锂能,0.078,16.19
1 company gp_per_wh margin_data
2 宁德时代 0.122 20.63
3 比亚迪* 0.097 21.00
4 LG新能源† -0.010 -0.70
5 中创新航* 0.077 16.71
6 国轩高科* 0.063 14.02
7 亿纬锂能 0.078 16.19
+7
View File
@@ -0,0 +1,7 @@
period,catl,byd,calb,guoxuan,eve
2021,0.784,0.720,0.660,0.683,
2022,0.978,0.800,0.872,0.840,
2023,0.889,0.700,0.618,0.720,
2024,0.664,0.550,0.496,0.626,
2025,0.585,0.470,0.459,0.473,0.516
2026H1,0.591,0.460,0.459,0.452,0.483
1 period catl byd calb guoxuan eve
2 2021 0.784 0.720 0.660 0.683
3 2022 0.978 0.800 0.872 0.840
4 2023 0.889 0.700 0.618 0.720
5 2024 0.664 0.550 0.496 0.626
6 2025 0.585 0.470 0.459 0.473 0.516
7 2026H1 0.591 0.460 0.459 0.452 0.483
+7
View File
@@ -0,0 +1,7 @@
company,gp_2023,gp_2026h1
宁德时代,0.198,0.122
比亚迪*,0.154,0.097
LG新能源†,0.119,-0.010
中创新航*,0.080,0.077
国轩高科*,0.112,0.063
亿纬锂能,,0.078
1 company gp_2023 gp_2026h1
2 宁德时代 0.198 0.122
3 比亚迪* 0.154 0.097
4 LG新能源† 0.119 -0.010
5 中创新航* 0.080 0.077
6 国轩高科* 0.112 0.063
7 亿纬锂能 0.078
+7
View File
@@ -0,0 +1,7 @@
period,catl,peer_avg,gap_multiple
2021,0.172,0.080,2.15
2022,0.168,0.118,1.42
2023,0.198,0.096,2.06
2024,0.159,0.087,1.83
2025,0.139,0.075,1.85
2026H1,0.122,0.073,1.68
1 period catl peer_avg gap_multiple
2 2021 0.172 0.080 2.15
3 2022 0.168 0.118 1.42
4 2023 0.198 0.096 2.06
5 2024 0.159 0.087 1.83
6 2025 0.139 0.075 1.85
7 2026H1 0.122 0.073 1.68
+7
View File
@@ -0,0 +1,7 @@
period,catl,byd,lges,calb,guoxuan,eve
2021,0.172,0.144,0.067,0.037,0.123,
2022,0.168,0.144,0.091,0.090,0.147,
2023,0.198,0.154,0.119,0.080,0.112,
2024,0.159,0.121,0.032,0.079,0.095,
2025,0.139,0.103,0.068,0.077,0.068,0.080
2026H1,0.122,0.097,-0.010,0.077,0.063,0.078
1 period catl byd lges calb guoxuan eve
2 2021 0.172 0.144 0.067 0.037 0.123
3 2022 0.168 0.144 0.091 0.090 0.147
4 2023 0.198 0.154 0.119 0.080 0.112
5 2024 0.159 0.121 0.032 0.079 0.095
6 2025 0.139 0.103 0.068 0.077 0.068 0.080
7 2026H1 0.122 0.097 -0.010 0.077 0.063 0.078
+7
View File
@@ -0,0 +1,7 @@
period,catl,byd,lges,calb,guoxuan,global_total
2021,99.5,26.4,59.4,8.0,6.7,301.5
2022,191.6,70.4,70.4,20.0,14.1,517.9
2023,259.7,111.4,95.8,33.4,17.1,705.5
2024,339.3,153.7,96.3,39.4,28.5,894.4
2025,464.7,194.8,108.8,62.8,53.5,1187.0
2026H1,242.7,87.7,52.6,31.2,28.0,608.5
1 period catl byd lges calb guoxuan global_total
2 2021 99.5 26.4 59.4 8.0 6.7 301.5
3 2022 191.6 70.4 70.4 20.0 14.1 517.9
4 2023 259.7 111.4 95.8 33.4 17.1 705.5
5 2024 339.3 153.7 96.3 39.4 28.5 894.4
6 2025 464.7 194.8 108.8 62.8 53.5 1187.0
7 2026H1 242.7 87.7 52.6 31.2 28.0 608.5
+7
View File
@@ -0,0 +1,7 @@
period,catl,byd,lges,calb,guoxuan
2021,33.0,8.7,19.7,2.6,2.2
2022,37.0,13.6,13.6,3.9,2.7
2023,36.8,15.8,13.6,4.7,2.4
2024,37.9,17.2,10.8,4.4,3.2
2025,39.2,16.4,9.2,5.3,4.5
2026H1,39.9,14.4,8.6,5.1,4.6
1 period catl byd lges calb guoxuan
2 2021 33.0 8.7 19.7 2.6 2.2
3 2022 37.0 13.6 13.6 3.9 2.7
4 2023 36.8 15.8 13.6 4.7 2.4
5 2024 37.9 17.2 10.8 4.4 3.2
6 2025 39.2 16.4 9.2 5.3 4.5
7 2026H1 39.9 14.4 8.6 5.1 4.6
+7
View File
@@ -0,0 +1,7 @@
period,catl,byd,lges,calb,guoxuan,eve
2021,22.00,20.00,4.31,5.60,18.00,
2022,17.17,18.00,4.74,10.32,17.50,
2023,22.27,22.00,6.41,13.00,15.50,
2024,23.94,22.00,2.25,15.89,15.14,
2025,23.84,22.00,5.69,16.69,14.39,15.50
2026H1,20.63,21.00,-0.70,16.71,14.02,16.19
1 period catl byd lges calb guoxuan eve
2 2021 22.00 20.00 4.31 5.60 18.00
3 2022 17.17 18.00 4.74 10.32 17.50
4 2023 22.27 22.00 6.41 13.00 15.50
5 2024 23.94 22.00 2.25 15.89 15.14
6 2025 23.84 22.00 5.69 16.69 14.39 15.50
7 2026H1 20.63 21.00 -0.70 16.71 14.02 16.19
+7
View File
@@ -0,0 +1,7 @@
period,catl,byd,lges,calb,guoxuan
2021,95.1,,73.4,95.2,41.0
2022,83.3,71.0,73.6,93.8,46.0
2023,70.5,72.0,69.3,75.6,48.0
2024,76.3,70.0,57.8,78.7,46.0
2025,96.9,82.0,47.6,,71.0
2026H1,94.9,,52.8,,
1 period catl byd lges calb guoxuan
2 2021 95.1 73.4 95.2 41.0
3 2022 83.3 71.0 73.6 93.8 46.0
4 2023 70.5 72.0 69.3 75.6 48.0
5 2024 76.3 70.0 57.8 78.7 46.0
6 2025 96.9 82.0 47.6 71.0
7 2026H1 94.9 52.8
+7
View File
@@ -0,0 +1,7 @@
period,sales_gwh,revenue_yi,asp,cost_per_wh,gp_per_wh,gross_margin
2021,116.7,914.9,0.784,0.612,0.172,22.00
2022,242.0,2365.9,0.978,0.810,0.168,17.17
2023,321.0,2852.5,0.889,0.691,0.198,22.27
2024,381.0,2530.4,0.664,0.505,0.159,23.94
2025,541.0,3165.1,0.585,0.446,0.139,23.84
2026H1,325,1921.2,0.591,0.469,0.122,20.63
1 period sales_gwh revenue_yi asp cost_per_wh gp_per_wh gross_margin
2 2021 116.7 914.9 0.784 0.612 0.172 22.00
3 2022 242.0 2365.9 0.978 0.810 0.168 17.17
4 2023 321.0 2852.5 0.889 0.691 0.198 22.27
5 2024 381.0 2530.4 0.664 0.505 0.159 23.94
6 2025 541.0 3165.1 0.585 0.446 0.139 23.84
7 2026H1 325 1921.2 0.591 0.469 0.122 20.63
+7
View File
@@ -0,0 +1,7 @@
period,china,korea,japan,others,global_total
2021,48.2,30.2,12.0,9.6,301.5
2022,60.4,23.7,7.3,8.6,517.9
2023,63.5,23.2,6.1,7.2,705.5
2024,67.1,18.5,3.9,10.5,894.4
2025,70.4,15.3,3.7,10.6,1187.0
2026H1,72.4,11.7,3.7,12.2,608.5
1 period china korea japan others global_total
2 2021 48.2 30.2 12.0 9.6 301.5
3 2022 60.4 23.7 7.3 8.6 517.9
4 2023 63.5 23.2 6.1 7.2 705.5
5 2024 67.1 18.5 3.9 10.5 894.4
6 2025 70.4 15.3 3.7 10.6 1187.0
7 2026H1 72.4 11.7 3.7 12.2 608.5
+11
View File
@@ -0,0 +1,11 @@
rank,company,region,gwh,share,yoy,global_total
1,宁德时代,中国,242.7,39.9,25.3,608.5
2,比亚迪,中国,87.7,14.4,1.6,608.5
3,LG新能源,韩国,52.6,8.6,8.4,608.5
4,中创新航,中国,31.2,5.1,39.5,608.5
5,国轩高科,中国,28.0,4.6,43.3,608.5
6,松下,日本,22.7,3.7,10.2,608.5
7,亿纬锂能,中国,20.9,3.4,51.7,608.5
8,SK On,韩国,19.0,3.1,-6.7,608.5
9,蜂巢能源,中国,15.7,2.6,40.9,608.5
10,欣旺达,中国,14.5,2.4,13.7,608.5
1 rank company region gwh share yoy global_total
2 1 宁德时代 中国 242.7 39.9 25.3 608.5
3 2 比亚迪 中国 87.7 14.4 1.6 608.5
4 3 LG新能源 韩国 52.6 8.6 8.4 608.5
5 4 中创新航 中国 31.2 5.1 39.5 608.5
6 5 国轩高科 中国 28.0 4.6 43.3 608.5
7 6 松下 日本 22.7 3.7 10.2 608.5
8 7 亿纬锂能 中国 20.9 3.4 51.7 608.5
9 8 SK On 韩国 19.0 3.1 -6.7 608.5
10 9 蜂巢能源 中国 15.7 2.6 40.9 608.5
11 10 欣旺达 中国 14.5 2.4 13.7 608.5
+7
View File
@@ -0,0 +1,7 @@
period,catl,byd,lges,panasonic,sk_on,samsung_sdi,calb,guoxuan,eve,sunwoda,svolt,farasis,global_total
2021,99.5,26.4,59.4,36.3,17.3,14.5,8.0,6.7,,2.6,,2.4,301.5
2022,191.6,70.4,70.4,38.0,27.8,24.3,20.0,14.1,,9.2,,7.4,517.9
2023,259.7,111.4,95.8,42.8,34.7,33.1,33.4,17.1,16.2,10.5,,,705.5
2024,339.3,153.7,96.3,35.1,39.0,29.6,39.4,28.5,20.3,18.8,,,894.4
2025,464.7,194.8,108.8,44.2,44.5,28.9,62.8,53.5,31.3,,28.5,,1187.0
2026H1,242.7,87.7,52.6,22.7,19.0,,31.2,28.0,20.9,14.5,15.7,,608.5
1 period catl byd lges panasonic sk_on samsung_sdi calb guoxuan eve sunwoda svolt farasis global_total
2 2021 99.5 26.4 59.4 36.3 17.3 14.5 8.0 6.7 2.6 2.4 301.5
3 2022 191.6 70.4 70.4 38.0 27.8 24.3 20.0 14.1 9.2 7.4 517.9
4 2023 259.7 111.4 95.8 42.8 34.7 33.1 33.4 17.1 16.2 10.5 705.5
5 2024 339.3 153.7 96.3 35.1 39.0 29.6 39.4 28.5 20.3 18.8 894.4
6 2025 464.7 194.8 108.8 44.2 44.5 28.9 62.8 53.5 31.3 28.5 1187.0
7 2026H1 242.7 87.7 52.6 22.7 19.0 31.2 28.0 20.9 14.5 15.7 608.5
+7
View File
@@ -0,0 +1,7 @@
period,catl,byd,lges,panasonic,sk_on,samsung_sdi,calb,guoxuan,eve,sunwoda,svolt,farasis,cr3,cr5,cr10,china_top10_count,china_share
2021,33.0,8.7,19.7,12.0,5.7,4.8,2.6,2.2,,0.9,,0.8,64.7,79.1,90.4,6,48.2
2022,37.0,13.6,13.6,7.3,5.4,4.7,3.9,2.7,,1.8,,1.4,64.2,76.9,91.4,6,60.4
2023,36.8,15.8,13.6,6.1,4.9,4.7,4.7,2.4,2.3,1.5,,,66.2,77.2,92.8,6,63.5
2024,37.9,17.2,10.8,3.9,4.4,3.3,4.4,3.2,2.3,2.1,,,65.9,74.7,89.5,6,67.1
2025,39.2,16.4,9.2,3.7,3.7,2.4,5.3,4.5,2.6,,2.4,,64.8,74.6,89.4,6,70.4
2026H1,39.9,14.4,8.6,3.7,3.1,,5.1,4.6,3.4,2.4,2.6,,62.9,72.6,87.8,7,72.4
1 period catl byd lges panasonic sk_on samsung_sdi calb guoxuan eve sunwoda svolt farasis cr3 cr5 cr10 china_top10_count china_share
2 2021 33.0 8.7 19.7 12.0 5.7 4.8 2.6 2.2 0.9 0.8 64.7 79.1 90.4 6 48.2
3 2022 37.0 13.6 13.6 7.3 5.4 4.7 3.9 2.7 1.8 1.4 64.2 76.9 91.4 6 60.4
4 2023 36.8 15.8 13.6 6.1 4.9 4.7 4.7 2.4 2.3 1.5 66.2 77.2 92.8 6 63.5
5 2024 37.9 17.2 10.8 3.9 4.4 3.3 4.4 3.2 2.3 2.1 65.9 74.7 89.5 6 67.1
6 2025 39.2 16.4 9.2 3.7 3.7 2.4 5.3 4.5 2.6 2.4 64.8 74.6 89.4 6 70.4
7 2026H1 39.9 14.4 8.6 3.7 3.1 5.1 4.6 3.4 2.4 2.6 62.9 72.6 87.8 7 72.4
+36
View File
@@ -0,0 +1,36 @@
# 由 scripts/build_sweet_spot_catl.py 生成,请勿手工编辑。
# 口径:总股本 46.27 亿股 · 净现金 2,263.8 亿元(每股 48.93 元)· 2026E 归母 900/964/1,000 亿元 · 分红率 50% · 现价 316.21 元(2026-09-15)
# PE 与股息率保留两位小数(见脚本 docstring 的精度例外说明)
price,market_cap,pe_bear,pe_base,pe_bull,pe_ex_cash,dividend_yield,net_cash_pct
220.00,10179.4,11.31,10.56,10.18,8.21,4.74,22.2
240.00,11104.8,12.34,11.52,11.10,9.17,4.34,20.4
260.00,12030.2,13.37,12.48,12.03,10.13,4.01,18.8
265.00,12261.6,13.62,12.72,12.26,10.37,3.93,18.5
280.00,12955.6,14.40,13.44,12.96,11.09,3.72,17.5
290.00,13418.3,14.91,13.92,13.42,11.57,3.59,16.9
300.00,13881.0,15.42,14.40,13.88,12.05,3.47,16.3
303.00,14019.8,15.58,14.54,14.02,12.20,3.44,16.1
316.21,14631.0,16.26,15.18,14.63,12.83,3.29,15.5
320.00,14806.4,16.45,15.36,14.81,13.01,3.26,15.3
340.00,15731.8,17.48,16.32,15.73,13.97,3.06,14.4
360.00,16657.2,18.51,17.28,16.66,14.93,2.89,13.6
378.00,17490.1,19.43,18.14,17.49,15.79,2.76,12.9
380.00,17582.6,19.54,18.24,17.58,15.89,2.74,12.9
400.00,18508.0,20.56,19.20,18.51,16.85,2.60,12.2
420.00,19433.4,21.59,20.16,19.43,17.81,2.48,11.6
440.00,20358.8,22.62,21.12,20.36,18.77,2.37,11.1
460.00,21284.2,23.65,22.08,21.28,19.73,2.26,10.6
480.00,22209.6,24.68,23.04,22.21,20.69,2.17,10.2
500.00,23135.0,25.71,24.00,23.14,21.65,2.08,9.8
520.00,24060.4,26.73,24.96,24.06,22.61,2.00,9.4
540.00,24985.8,27.76,25.92,24.99,23.57,1.93,9.1
560.00,25911.2,28.79,26.88,25.91,24.53,1.86,8.7
580.00,26836.6,29.82,27.84,26.84,25.49,1.80,8.4
600.00,27762.0,30.85,28.80,27.76,26.45,1.74,8.2
605.00,27993.4,31.10,29.04,27.99,26.69,1.72,8.1
617.00,28548.6,31.72,29.61,28.55,27.27,1.69,7.9
620.00,28687.4,31.87,29.76,28.69,27.41,1.68,7.9
640.00,29612.8,32.90,30.72,29.61,28.37,1.63,7.6
660.00,30538.2,33.93,31.68,30.54,29.33,1.58,7.4
680.00,31463.6,34.96,32.64,31.46,30.29,1.53,7.2
700.00,32389.0,35.99,33.60,32.39,31.25,1.49,7.0
1 # 由 scripts/build_sweet_spot_catl.py 生成,请勿手工编辑。
2 # 口径:总股本 46.27 亿股 · 净现金 2,263.8 亿元(每股 48.93 元)· 2026E 归母 900/964/1,000 亿元 · 分红率 50% · 现价 316.21 元(2026-09-15)
3 # PE 与股息率保留两位小数(见脚本 docstring 的精度例外说明)
4 price,market_cap,pe_bear,pe_base,pe_bull,pe_ex_cash,dividend_yield,net_cash_pct
5 220.00,10179.4,11.31,10.56,10.18,8.21,4.74,22.2
6 240.00,11104.8,12.34,11.52,11.10,9.17,4.34,20.4
7 260.00,12030.2,13.37,12.48,12.03,10.13,4.01,18.8
8 265.00,12261.6,13.62,12.72,12.26,10.37,3.93,18.5
9 280.00,12955.6,14.40,13.44,12.96,11.09,3.72,17.5
10 290.00,13418.3,14.91,13.92,13.42,11.57,3.59,16.9
11 300.00,13881.0,15.42,14.40,13.88,12.05,3.47,16.3
12 303.00,14019.8,15.58,14.54,14.02,12.20,3.44,16.1
13 316.21,14631.0,16.26,15.18,14.63,12.83,3.29,15.5
14 320.00,14806.4,16.45,15.36,14.81,13.01,3.26,15.3
15 340.00,15731.8,17.48,16.32,15.73,13.97,3.06,14.4
16 360.00,16657.2,18.51,17.28,16.66,14.93,2.89,13.6
17 378.00,17490.1,19.43,18.14,17.49,15.79,2.76,12.9
18 380.00,17582.6,19.54,18.24,17.58,15.89,2.74,12.9
19 400.00,18508.0,20.56,19.20,18.51,16.85,2.60,12.2
20 420.00,19433.4,21.59,20.16,19.43,17.81,2.48,11.6
21 440.00,20358.8,22.62,21.12,20.36,18.77,2.37,11.1
22 460.00,21284.2,23.65,22.08,21.28,19.73,2.26,10.6
23 480.00,22209.6,24.68,23.04,22.21,20.69,2.17,10.2
24 500.00,23135.0,25.71,24.00,23.14,21.65,2.08,9.8
25 520.00,24060.4,26.73,24.96,24.06,22.61,2.00,9.4
26 540.00,24985.8,27.76,25.92,24.99,23.57,1.93,9.1
27 560.00,25911.2,28.79,26.88,25.91,24.53,1.86,8.7
28 580.00,26836.6,29.82,27.84,26.84,25.49,1.80,8.4
29 600.00,27762.0,30.85,28.80,27.76,26.45,1.74,8.2
30 605.00,27993.4,31.10,29.04,27.99,26.69,1.72,8.1
31 617.00,28548.6,31.72,29.61,28.55,27.27,1.69,7.9
32 620.00,28687.4,31.87,29.76,28.69,27.41,1.68,7.9
33 640.00,29612.8,32.90,30.72,29.61,28.37,1.63,7.6
34 660.00,30538.2,33.93,31.68,30.54,29.33,1.58,7.4
35 680.00,31463.6,34.96,32.64,31.46,30.29,1.53,7.2
36 700.00,32389.0,35.99,33.60,32.39,31.25,1.49,7.0
+36
View File
@@ -0,0 +1,36 @@
# 由 scripts/build_sweet_spot_catl.py 生成,请勿手工编辑。
# 口径:总股本 46.27 亿股 · 净现金 2,263.8 亿元(每股 48.93 元)· 2026E 归母 900/964/1,000 亿元 · 分红率 50% · 现价 316.21 元(2026-09-15)
# PE 与股息率保留两位小数(见脚本 docstring 的精度例外说明)
price,market_cap,pe_base,dividend_yield,cagr_price,cagr_total
220.00,10179.4,10.56,4.74,40.1,46.7
240.00,11104.8,11.52,4.34,36.1,42.0
260.00,12030.2,12.48,4.01,32.5,37.8
265.00,12261.6,12.72,3.93,31.7,36.9
280.00,12955.6,13.44,3.72,29.3,34.1
290.00,13418.3,13.92,3.59,27.8,32.4
300.00,13881.0,14.40,3.47,26.3,30.7
303.00,14019.8,14.54,3.44,25.9,30.3
316.21,14631.0,15.18,3.29,24.1,28.2
320.00,14806.4,15.36,3.26,23.7,27.7
340.00,15731.8,16.32,3.06,21.2,24.9
360.00,16657.2,17.28,2.89,18.9,22.3
378.00,17490.1,18.14,2.76,17.0,20.2
380.00,17582.6,18.24,2.74,16.8,20.0
400.00,18508.0,19.20,2.60,14.8,17.8
420.00,19433.4,20.16,2.48,12.9,15.7
440.00,20358.8,21.12,2.37,11.2,13.8
450.00,20821.5,21.60,2.31,10.4,12.9
460.00,21284.2,22.08,2.26,9.6,12.0
480.00,22209.6,23.04,2.17,8.0,10.4
500.00,23135.0,24.00,2.08,6.6,8.8
520.00,24060.4,24.96,2.00,5.2,7.3
540.00,24985.8,25.92,1.93,3.9,5.9
560.00,25911.2,26.88,1.86,2.6,4.5
580.00,26836.6,27.84,1.80,1.4,3.2
600.00,27762.0,28.80,1.74,0.3,2.0
605.00,27993.4,29.04,1.72,0.0,1.7
620.00,28687.4,29.76,1.68,-0.8,0.9
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 生成,请勿手工编辑。
2 # 口径:总股本 46.27 亿股 · 净现金 2,263.8 亿元(每股 48.93 元)· 2026E 归母 900/964/1,000 亿元 · 分红率 50% · 现价 316.21 元(2026-09-15)
3 # PE 与股息率保留两位小数(见脚本 docstring 的精度例外说明)
4 price,market_cap,pe_base,dividend_yield,cagr_price,cagr_total
5 220.00,10179.4,10.56,4.74,40.1,46.7
6 240.00,11104.8,11.52,4.34,36.1,42.0
7 260.00,12030.2,12.48,4.01,32.5,37.8
8 265.00,12261.6,12.72,3.93,31.7,36.9
9 280.00,12955.6,13.44,3.72,29.3,34.1
10 290.00,13418.3,13.92,3.59,27.8,32.4
11 300.00,13881.0,14.40,3.47,26.3,30.7
12 303.00,14019.8,14.54,3.44,25.9,30.3
13 316.21,14631.0,15.18,3.29,24.1,28.2
14 320.00,14806.4,15.36,3.26,23.7,27.7
15 340.00,15731.8,16.32,3.06,21.2,24.9
16 360.00,16657.2,17.28,2.89,18.9,22.3
17 378.00,17490.1,18.14,2.76,17.0,20.2
18 380.00,17582.6,18.24,2.74,16.8,20.0
19 400.00,18508.0,19.20,2.60,14.8,17.8
20 420.00,19433.4,20.16,2.48,12.9,15.7
21 440.00,20358.8,21.12,2.37,11.2,13.8
22 450.00,20821.5,21.60,2.31,10.4,12.9
23 460.00,21284.2,22.08,2.26,9.6,12.0
24 480.00,22209.6,23.04,2.17,8.0,10.4
25 500.00,23135.0,24.00,2.08,6.6,8.8
26 520.00,24060.4,24.96,2.00,5.2,7.3
27 540.00,24985.8,25.92,1.93,3.9,5.9
28 560.00,25911.2,26.88,1.86,2.6,4.5
29 580.00,26836.6,27.84,1.80,1.4,3.2
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-*` 样式 |
+1040
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -46,6 +46,16 @@ import {
moutaiBaseWineVolumeChart, moutaiFlyingPriceChart, moutaiPriceAdjustmentChart,
moutaiTonPriceChart, moutaiDividendChart, moutaiDistributorsChart,
} from './moutai.js';
import {
peerInstallChart, peerUtilizationChart, peerShareChart,
peerGpPerWhChart, peerAspChart, peerMarginChart, peer2026h1Chart,
peerGpGapChart, peerGpChangeChart, catlUnitEconomicsChart, catlVolumeGpChart,
globalCrChart, globalTop10Chart, globalRegionShareChart,
} from './catl.js';
import {
sweetCatlPricePeChart, sweetCatlNetCashChart, sweetCatlAnyPriceChart,
sweetTencentPriceEvChart, sweetTencentNonOpChart, sweetTencentAnyPriceChart,
} from './sweet_spot.js';
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
export const chartRegistry = {
@@ -114,6 +124,26 @@ export const chartRegistry = {
'moutai-ton-price': moutaiTonPriceChart,
'moutai-dividend': moutaiDividendChart,
'moutai-distributors': moutaiDistributorsChart,
'catl-unit-economics': catlUnitEconomicsChart,
'catl-volume-gp': catlVolumeGpChart,
'peer-install': peerInstallChart,
'peer-utilization': peerUtilizationChart,
'peer-share': peerShareChart,
'peer-gp-per-wh': peerGpPerWhChart,
'peer-asp': peerAspChart,
'peer-margin': peerMarginChart,
'peer-2026h1': peer2026h1Chart,
'peer-gp-gap': peerGpGapChart,
'peer-gp-change': peerGpChangeChart,
'global-cr': globalCrChart,
'global-top10': globalTop10Chart,
'global-region-share': globalRegionShareChart,
'sweet-catl-price-pe': sweetCatlPricePeChart,
'sweet-catl-net-cash': sweetCatlNetCashChart,
'sweet-catl-any-price': sweetCatlAnyPriceChart,
'sweet-tencent-price-ev': sweetTencentPriceEvChart,
'sweet-tencent-non-op': sweetTencentNonOpChart,
'sweet-tencent-any-price': sweetTencentAnyPriceChart,
};
export function getChartBuilder(key) {
+761
View File
@@ -0,0 +1,761 @@
import {
PALETTE, FONT, FINANCIAL_COLORS, axisCommon, titleStyle, standardLegend,
lineSeries, footnote, watermarks, LAYOUT, tooltipOf,
} from '../theme.js';
/**
* 甜蜜击球区 · 股价 ↔ 估值 的换算图表(宁德时代 300750 / 腾讯控股 00700.HK)
*
* 同一套框架、两套口径。三张图的结构完全一致,差异全部下沉到 PROFILES 的两个配置块里:
* 图 1 股价 → 倍数对照(三档情景 + 倍数轴上的区域横带)
* 图 2 非经营资产让倍数便宜了多少(含/扣各层级 + 中间那层沙色带)
* 图 3 任意股价试算(三年后退出,年化收益 + 股价轴上的纵向区域带)
*
* 【为什么纵轴口径两家不同】这不是随意的:
* 宁德 纵轴 PE = 市值 ÷ 2026E 归母净利;它扣的「类现金」是 2,263.8 亿趴在账上的现金,
* 是准现金,扣掉后不改变分子的性质,所以 PE 口径成立。
* 腾讯 纵轴 EV/EBIT;它的「类现金」是 5,259.9 亿投资组合(股权、会波动),
* 只扣净现金 581.9 亿几乎不改变倍数(少 0.23x),必须连投资组合一起扣才是经营资产
* 的真实价格;同时分母换成经营盈利 EBIT,分子分母成对(分子不含投资收益)。
* 两家的甜蜜区锚也不同:宁德的五档来自资料库原表(13/15.5/25/30x);
* 腾讯用巴菲特原始锚 8x 白送 · 10x 甜点 · 12x 上沿。
*
* 【上游底稿】宁德:资料库「宁德时代_股价PE对照表」EsAWMaLKt4ECKPDvESKKvT(2026-09-15);
* 腾讯:资料库「腾讯控股(00700.HK)甜蜜击球区检验」Ey5NeiPls71FC12zn0ieSX(2026-09-17)。
* 数据由 scripts/build_sweet_spot_catl.py / build_sweet_spot_tencent.py 投影成 CSV。
*
* 【硬编码说明】区域边界的**股价**不是写死的,而是从 CSV 的基准倍数反解(multToPrice),
* 所以改股本/汇率/利润预期后纵向区域带会自动跟着走。配置块里只有三类手工常量:
* 现价默认值、非经营资产(仅用于文案)、以及区域档位本身。
*
* 【现价可手输】三张图的「现价」标线不是常量,而是 tab 级参数 `params.price`
* (在 manifest.json 各 tab 的 controls 里声明,见 js/main.js)。profile.priceDefault
* 是无参数调用(SSR 回归测试、脚本)的兜底。
*/
/* ============================================================
* 配置:宁德时代(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 轴上叠出两块无差别的底色。
*/
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}`,
],
},
};
/* ============================================================
* 配置:腾讯控股(00700.HK)
* ============================================================ */
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) {
const value = parseInt(hex.slice(1), 16);
return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${alpha})`;
}
/** 取某价位的某个指标:命中数据行就用原值,否则在相邻两行之间线性插值 */
function metricAt(rows, price, key) {
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;
}
/**
* 解析 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',
name,
nameLocation: 'middle',
nameGap: 56,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
...extra,
});
/**
* 股价轴:数值轴。
* 不画竖向网格(规范只保留浅色横线)—— 图 3 的纵向区域带本身就是参考线,
* 图 1、图 2 靠「现价」竖线定位。
*/
const priceAxis = (profile, extra = {}) => ({
...axisCommon,
type: 'value',
name: profile.axis.name,
nameLocation: 'middle',
nameGap: 34,
min: profile.axis.min,
max: profile.axis.max,
axisLine: { show: true, lineStyle: { color: PALETTE.axisLine } },
splitLine: { show: false },
...extra,
});
const GRID = { left: 78, right: 78, top: LAYOUT.gridTop, bottom: 80 };
function baseOption(profile, {
title, subtitle, xAxis, yAxis, series, legend, width, height,
tooltipFormatter, extraNote, grid, extraGraphics = [],
}) {
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;实测
// grid.bottom=118 时轴名与脚注只剩 7px 净空(同站三行脚注的图通常有 23px),
// 所以统一提到 134 —— 改脚注行数时这里要跟着重算。
grid: { ...GRID, ...grid },
tooltip: tooltipOf(series, tooltipFormatter),
xAxis,
yAxis,
series,
graphic: [...watermarks(width, height), ...extraGraphics, footnote(notes.join('\n') + ' | @Joey知行记')],
};
}
/** 反解倍数阈值对应的股价:在基准倍数单调递增的序列上线性插值 */
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[key] - mult) * (cur[key] - mult) <= 0) {
const ratio = (mult - prev[key]) / (cur[key] - prev[key]);
return prev.price + ratio * (cur.price - prev.price);
}
}
return null;
}
/**
* 倍数轴上的横向区域带(图 1、图 2 共用)。
* 必须挂在**第一个系列**上:markArea 在系列自己的分组里、先于该系列图元绘制,
* 而系列分组按声明顺序绘制 —— 挂在后面的系列上会盖住前一条线。
*/
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) },
label: {
show: true,
position: 'insideTopLeft',
distance: 10,
formatter: `${zone.name} ${zone.note}`,
color: zone.color,
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
},
}, { yAxis: Math.min(zone.upTo, 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(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,
position: 'insideBottom',
distance: 12,
formatter: zone.name,
color: zone.color,
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
},
}, { xAxis: edges[index + 1] ?? profile.axis.max }]);
}
/**
* 股价 → 指标折线。
* 只在 labels 上的点标数值:20+ 个数据点全标会糊成一片,
* 而这几个点正好是决策价位。
* 不设 z:同图各条线在窗口内不相交(分子随股价线性增长、共享同一个横轴截距),
* 声明顺序即绘制顺序,多设一层 z 只会让 SVG 分组变形。
*/
function priceLine({
name, rows, key, color, width = 2.6, labels = [],
unit = '', decimals = 2, position = 'right', distance = 8,
}) {
const line = lineSeries({ name, data: [], color, width });
return {
...line,
label: { show: false },
data: rows.map((row) => ({
value: [row.price, row[key]],
label: labels.includes(row.price)
? {
...line.label,
show: true,
position,
distance,
formatter: `${row[key].toFixed(decimals)}${unit}`,
}
: { show: false },
})),
};
}
/**
* 现价竖线:所有图共用。
* label 一律关掉 —— markLine 的标签会**跟着竖线旋转 90°**,长串中文变成一列竖排字,
* 极难读。文字改用 nowGraphics() 以 graphic 单独排,保持水平。
*/
function nowMarkLine(now) {
return {
silent: true,
symbol: 'none',
lineStyle: { type: 'dashed', width: 1.6, color: PALETTE.brown },
label: { show: false },
data: [{ xAxis: now }],
};
}
/**
* 现价竖线上方的水平标注,位置由 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(flip ? x - 8 : x + 8),
top: LAYOUT.gridTop - 30,
silent: true,
style: {
text,
fill: PALETTE.brown,
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
...(flip ? { textAlign: 'right' } : {}),
},
}];
}
/**
* 现价标注文案。各图格式一致,且指标取不到时不打印 "NaN" —— 那说明现价落在数据区
* 之外(插值无从取值),把原因写出来比一个 NaN 有用。输入框本身也会拦下这种值。
*/
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;
});
// markArea 挂在第一个系列上(见 multBandMarkAreas 注释)
lines[0].markArea = { silent: true, data: multBandMarkAreas(profile) };
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(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: multipleFormatter(profile),
extraNote: cfg.notes({ now, rows }).join('\n'),
});
}
/**
* 图 2:非经营资产让倍数便宜了多少
*
* 各层级口径是**严格平行**的一组线,层间距恒为 该项资产 ÷ 分母 —— 间距与股价无关,
* 但折价占市值的比例随股价下降而放大。所以这张图要看的不是几条线的高低,
* 而是中间那层沙色:它是一块不产生经营利润、但实实在在归股东的资产。
* 宁德只有一层(净现金);腾讯有两层,薄的是净现金、厚的是投资组合 ——
* 只扣现金几乎不动,正是"假阴性"的来源。
*
* 实现说明:ECharts 没有"两个系列之间填充",只能用 stack —— 栈底是最低那条,
* 往上每一层是相邻两层之差;差额段线宽 0、面积填沙色,上沿即上一层口径。
* stack 的叠放顺序不能改(改了基准就错),于是用 z 控制绘制层级,
* 防止沙色覆盖掉栈底那条实线。
*/
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 base = {
...lineSeries({ name: bottom.name, data: [], color: bottom.color, width: bottom.width }),
label: { show: false },
stack: 'tier',
z: 3,
areaStyle: { opacity: 0 },
data: rows.map((row) => [row.price, row[bottom.key]]),
};
base.markLine = nowMarkLine(now);
base.markArea = { silent: true, data: multBandMarkAreas(profile) };
// 自下而上堆差:第 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 },
z: 4 + index,
data: rows.map((row) => [row.price, row[tier.key]]),
}));
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(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:任意股价试算
*
* 计算器要输入一个价格才出结果,图表反过来:整档价格都画出来,任意价位沿竖线直接读。
* 纵轴穿过 0 的位置就是"三年白干"—— 它正好落在退出价上,所以零线本身就是一条结论。
* 纵向底带把估值区域直接画在股价轴上:横带看倍数、竖带看股价,是同一套档位。
*/
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: 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(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(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: 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;
+134 -6
View File
@@ -1,12 +1,15 @@
import { loadCSV } from './csv.js';
import { getChartBuilder } from './charts/index.js';
import { downloadCanvas, mergeImages } from './merge.js';
import { renderStockReturnCalculator, valuationMenu } from './tools/stock-return-calculator.js';
import { renderDCFCalculator } from './tools/dcf-calculator.js';
import {
createAppShell,
renderAnchors,
renderCards,
renderCartBar,
renderCompanies,
renderTabControls,
renderTabs,
showChartError,
showMergePreview,
@@ -15,6 +18,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 +34,8 @@ let shell;
let state;
let renderVersion = 0;
let cacheToken = Date.now();
let paramTimer;
let disposeTool;
function validateManifest(value) {
if (!Array.isArray(value?.companies) || value.companies.length === 0) {
@@ -51,6 +62,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,7 +73,26 @@ 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) {
if (companyId === valuationMenu.id) return valuationMenu;
return manifest.companies.find((company) => company.id === companyId) || manifest.companies[0];
}
@@ -69,12 +100,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 +157,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 +197,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();
@@ -297,15 +406,31 @@ function renderCurrent() {
const company = getCompany(state.companyId);
const tab = getTab(company, state.tabId);
const charts = chartsForTab(company, tab);
shell.layout.classList.toggle('tools-layout', company.kind === 'tools');
const version = ++renderVersion;
// 整屏重渲染会带上最新参数把图都画一遍,这里再触发一次参数刷新纯属重复劳动
clearTimeout(paramTimer);
disposeTool?.();
disposeTool = undefined;
disposeAll();
renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => {
renderCompanies(shell.companyNav, [...manifest.companies, valuationMenu], company.id, (companyId) => {
updateHash({ companyId });
});
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
updateHash({ companyId: company.id, tabId });
});
renderTabControls(shell.params, tab.controls, paramValues(company, tab), applyTabParam);
if (company.kind === 'tools') {
if (tab.id === 'dcf') {
disposeTool = renderDCFCalculator(shell.cards, manifest.companies.filter(item => ['ppmt', 'moutai', 'catl'].includes(item.id)));
} else {
renderStockReturnCalculator(shell.cards);
}
shell.chartNav.hidden = true;
return;
}
shell.chartNav.hidden = false;
renderCards(shell.cards, charts, activeViews, {
onExport: exportPNG,
isCarted: (instanceId) => cart.has(instanceId),
@@ -335,6 +460,7 @@ async function start() {
shell = createAppShell(document.getElementById('app'), {
onReload: () => {
cacheToken = Date.now();
rowCache.clear();
renderCurrent();
},
});
@@ -358,6 +484,8 @@ window.addEventListener('hashchange', () => {
let resizeTimer;
window.addEventListener('resize', () => {
if (!shell) return;
// Calculator layout is responsive CSS; rebuilding it would interrupt focused inputs.
if (getCompany(state.companyId).kind === 'tools') return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(renderCurrent, 200);
});
+56
View File
@@ -361,6 +361,62 @@ export function stackedSharePoint(value, { pxPerUnit, labelColor, dy = 0, minIns
};
}
/**
* tooltip 彩色圆点标记。
*
* 为什么不直接用 ECharts 的默认 marker:折线系列的 visualDrawType 恒为 'fill'
* (Series.js 原型默认值,只有轨迹图 / 箱线图改成 'stroke'),而空心圆折线的
* itemStyle.color 必须是 #fff —— tooltip 取到的 marker 就是白色,在白底提示框上
* 等于隐形。这里按系列语义色自绘标记,不依赖 ECharts 内部取色。
*/
export function tooltipMarker(color) {
return `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;`
+ `background:${color};margin-right:6px;vertical-align:-1px"></span>`;
}
/**
* 标准 tooltip:每一项前面带该系列语义色的圆点。
*
* 不能用 ECharts 默认 marker:折线系列 visualDrawType 为 'fill',
* 而空心圆折线的 itemStyle.color 是 #fff,默认 marker 会被取成白色而在白底上隐形。
* 取色规则:
* - 柱:优先用该数据点自身的颜色(支持逐项着色,如按公司或国别上色)
* - 折线:用 lineStyle.color(空心圆的 itemStyle.color 是白色,不能作语义色)
*
* @param {Array} series 已构造好的系列数组
* @param {(value: number) => string} formatValue 默认数值格式化
* @param {Object<string, (value: number) => string>} [formatBySeries] 按系列名覆盖
* —— 柱线组合图两条序列单位不同(如 GWh 与 %)时必需
*/
export function tooltipOf(series, formatValue, formatBySeries) {
const seriesColors = {};
series.forEach((item) => {
const color = item.lineStyle?.color ?? item.itemStyle?.color;
if (item.name && color) seriesColors[item.name] = color;
});
return {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT, fontSize: 13 },
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
if (list.length === 0) return '';
const head = `<span style="color:${PALETTE.ink};font-weight:600">`
+ `${list[0].axisValueLabel ?? list[0].name ?? ''}</span>`;
const body = list.map((item) => {
const fallback = seriesColors[item.seriesName] ?? PALETTE.grey;
const color = item.seriesType === 'bar' ? (item.color || fallback) : fallback;
const fmt = formatBySeries?.[item.seriesName] ?? formatValue;
const raw = item.value;
const value = raw === null || raw === undefined || raw === '' ? '—' : fmt(raw);
return `${tooltipMarker(color)}${item.seriesName}:${value}`;
});
return [head, ...body].join('<br/>');
},
};
}
/** 底部注释 */
export function footnote(text) {
return {
+99
View File
@@ -0,0 +1,99 @@
<section class="dcf-calculator return-calculator" aria-label="DCF 估值计算器">
<header class="df-header"><div><div class="df-eyebrow">VALUATION WORKBENCH <span>/ 估值研究</span></div><h2>DCF 估值工作台</h2><p class="df-subtitle">从现金流价值出发,用盈利增长检验回报。</p></div><div class="df-header-actions"><div class="df-header-mark"><span class="df-live-dot"></span> 参数调整 · 即时试算</div><button class="df-help-trigger" type="button" data-help-open aria-label="打开估值概念说明" aria-haspopup="dialog" title="估值概念说明"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M9.5 9a2.5 2.5 0 0 1 5 .3c0 1.7-2.5 2-2.5 3.7"></path><circle cx="12" cy="16.5" r=".8" fill="currentColor" stroke="none"></circle></svg></button></div></header>
<div class="df-profile-bar">
<div class="df-company-buttons" data-company-buttons role="group" aria-label="切换估值公司"></div>
<button class="export-button" type="button" data-new-company>+ 新增公司</button>
<span class="text-small text-muted df-save-status" data-save-status role="status">每家公司的参数独立保存在本机浏览器</span>
</div>
<form class="df-new-company" data-company-form hidden>
<label>公司名称<input class="form-control" data-company-name maxlength="60" placeholder="输入公司名称" required></label>
<button class="export-button" type="submit">添加</button>
<button class="export-button" type="button" data-cancel-company>取消</button>
<span class="text-small text-destructive" data-company-error role="alert"></span>
</form>
<div class="df-company-empty" data-company-empty role="status" hidden>还没有估值公司,添加一家公司开始估值。</div>
<nav class="df-jumpnav" aria-label="估值工作台导航"><button type="button" data-jump="overview">01 估值总览</button><button type="button" data-jump="parameters">02 DCF 参数</button><button type="button" data-jump="crosscheck">03 双模型校验</button><button type="button" data-jump="detail">04 计算明细</button></nav>
<div class="df-overview" data-section="overview" tabindex="-1"><div class="df-output" aria-live="polite"><div class="df-hero"><div class="df-muted">DCF 每股内在价值</div><div class="df-number" data-price>—</div><div class="df-muted">人民币/股 · 当前现金流假设</div><div class="df-value-note"><span data-margin-label>20%</span> 安全边际参考价 <b data-buy>—</b> <span class="df-muted">元/股</span><div class="df-muted" data-shares-note></div></div></div>
</div><div class="df-top-comparison" aria-live="polite" aria-label="现价与收益门槛对比">
<div><h3>当前股价</h3>
<div class="df-price-readout"><span class="df-market-price" data-market-current>—</span><span class="df-muted">元/股</span><span class="df-zone-badge" data-market-badge hidden></span></div>
<div class="df-muted" data-deviation>填写当前股价后查看所处区间</div>
</div>
<div class="df-top-return"><h3>预计年化 IRR</h3>
<div class="df-top-return-value" data-top-irr>—</div>
<div class="df-muted" data-top-irr-detail>补全交叉验证参数后显示</div>
</div>
<div class="df-top-return"><h3>目标收益所需利润年增速(含边际)</h3>
<div class="df-top-return-value" data-top-growth>—</div>
<div class="df-muted" data-top-growth-detail>按现价与基准退出 PE 反推</div>
</div>
</div></div>
<section class="df-decision" aria-label="双模型结论" aria-live="polite"><div><span class="df-eyebrow">双模型共同参考上限</span><strong data-overview-ceiling>—</strong><span class="df-muted">元/股</span></div><p data-overview-verdict>填写当前股价及利润假设,比较现金流价值与目标收益门槛。</p><button type="button" data-jump="crosscheck">查看校验 ↓</button></section>
<section class="df-market" aria-label="当前股价与估值区间">
<div class="df-market-head">
<div><div class="df-eyebrow">PRICE POSITION</div><h3>现价在估值的什么位置?</h3><div class="df-muted">以当前 DCF 假设为基准的价格区间</div></div>
<div class="df-price-fields">
<label>当前股价(人民币)<input data-field="marketPrice" type="number" min="0" step="any" placeholder="手动输入当前股价"></label>
<label>股价日期(选填)<input data-field="quoteDate" type="date"></label>
</div>
</div>
<div class="df-price-chart" data-market-chart role="img" aria-label="当前股价与 DCF 估值区间" hidden></div>
<div class="df-zone-keys" data-zone-keys hidden>
<div class="df-zone-key"><span><i class="df-zone-dot"></i>低估区间</span><strong data-zone-low>—</strong></div>
<div class="df-zone-key"><span><i class="df-zone-dot"></i>合理区间</span><strong data-zone-fair>—</strong></div>
<div class="df-zone-key"><span><i class="df-zone-dot"></i>高估区间</span><strong data-zone-high>—</strong></div>
</div>
<div class="df-muted df-market-message" data-market-message>请补全现金流与股本后查看估值区间</div>
<div class="df-error" data-market-error role="alert" hidden></div>
<div class="df-interval-rule">
<span class="df-muted">区间依据当前 DCF 假设与折溢价阈值划分,不自动获取行情</span>
<details><summary>区间设置</summary>
<div class="df-fields df-interval-fields">
<label>低估折价/安全边际(%)<input data-field="margin" type="number" min="0" max="99.99" value="20" step="any"></label>
<label>高估溢价阈值(%)<input data-field="premium" type="number" min="0" max="1000" value="20" step="any"></label>
</div>
</details>
</div>
</section>
<div class="df-layout">
<div class="df-form" data-section="parameters" tabindex="-1"><div class="df-column-heading"><span>01</span><div><h3>现金流估值</h3><p>企业能产生多少可分配现金流</p></div><b>DCF</b></div>
<section><div class="df-section-head"><h3>基础参数</h3><span class="df-muted"><span data-context>教学示例 · 人民币</span></span></div>
<div class="df-fields">
<label>现金流口径<select class="cursor-interaction" data-field="basis"><option value="fcff">企业自由现金流 FCFF</option><option value="fcfe">股权自由现金流 FCFE</option></select></label>
<label>基期自由现金流(亿元)<input data-field="fcf" type="number" value="100" step="any"></label>
<label class="df-share-field">当前总股本(亿股,必填)<input data-field="shares" type="number" min="0" step="any" required placeholder="填写该公司总股本"><small class="df-muted">用于每股价值换算,1 亿股 = 100,000,000 股</small></label>
<label>股本数据日期(选填)<input data-field="shareDate" type="date"></label>
<label>现金流基期年份<input data-field="year" type="number" value="2026" step="1"></label>
<label><span data-rate-label>折现率 WACC(%)</span><input data-field="rate" type="number" value="10" step="0.5"></label>
</div></section>
<section><div class="df-section-head"><h3>分段增长率</h3><button class="cursor-interaction" type="button" data-add>+ 添加阶段</button></div>
<div class="df-stages" data-stages></div><div class="df-total df-muted" data-duration></div>
</section>
<section><div class="df-section-head"><h3>永续经营与股权换算</h3></div>
<div class="df-fields">
<label>永续增长率(%)<input data-field="terminal" type="number" value="2" step="0.5"></label>
<label data-debt-field>净债务(亿元,可为负)<input data-field="debt" type="number" value="100" step="any"></label>
<label data-assets-field>其他非经营资产净额(亿元)<input data-field="assets" type="number" value="0" step="any"></label>
<label data-minority-field>少数股东权益(亿元)<input data-field="minority" type="number" value="0" step="any"></label>
</div></section>
<div class="df-results"><h3>价值拆解 <span class="df-muted">/亿元</span></h3><div class="df-result-line"><span>预测期现金流现值</span><b data-pv>—</b></div><div class="df-result-line"><span>永续终值现值</span><b data-tv>—</b></div><div class="df-result-line df-sum"><span data-value-label>企业经营价值</span><b data-ev>—</b></div><div class="df-result-line" data-adjustment><span>− 净债务 + 非经营资产 − 少数股东权益</span><b data-bridge>—</b></div><div class="df-result-line df-sum"><span>股权价值</span><b data-equity>—</b></div><div class="df-composition" aria-hidden="true"><span class="df-segment-a" data-composition-forecast></span><span class="df-segment-b" data-composition-terminal></span></div><div class="df-legend"><span>预测期现金流</span><span>永续终值</span></div><div class="df-muted" data-terminal-share></div></div>
</div>
<div class="df-analysis" data-section="crosscheck" tabindex="-1" data-crosscheck-slot></div>
</div>
<div class="df-error" role="alert" data-error hidden></div>
<div class="df-warning" data-warning hidden></div>
<details class="df-table-section" data-detail data-section="detail" tabindex="-1"><summary class="cursor-interaction">逐年现金流与折现明细 <span class="df-muted" data-table-summary></span></summary><div class="df-table-wrap"><table aria-label="逐年现金流折现"><thead><tr><th>年份/阶段</th><th>增长率</th><th>自由现金流(亿元)</th><th>折现因子</th><th>现值(亿元)</th></tr></thead><tbody data-rows></tbody></table></div></details>
<section class="df-cashflow-section" data-cashflow-section aria-label="逐年现金流与现值图表" hidden>
<div class="df-section-head"><div><h3>逐年现金流与折现现值</h3><p class="df-muted" data-cashflow-caption></p></div><span class="df-muted">单位:亿元</span></div>
<div class="df-cashflow-chart" data-cashflow-chart role="img" aria-label="逐年自由现金流与折现现值对比"></div>
<div class="df-error" data-cashflow-error role="alert" hidden></div>
<p class="df-muted">浅棕为当年自由现金流,深棕为折算到基期的现值;仅展示显式预测期,不含永续终值。悬停或轻触柱形可查看年份、增长阶段及折现因子。</p>
</section>
<footer class="df-foot"><span>假设示例 · 年末现金流 · 永续增长法</span><span data-model-note></span></footer>
<p class="df-muted df-note">净利润不能直接代替自由现金流。FCFF 按 WACC 折现后调整为股权价值,FCFE 按股权资本成本折现。非经营资产不得与净债务中的超额现金重复计入。所有金额使用人民币,股本在预测期内保持不变,终值采用持续经营假设。</p>
</section>
+457
View File
@@ -0,0 +1,457 @@
import template from './dcf-calculator.html?raw';
import '../../css/dcf.css';
import { calculateDCF, calculateDCFPriceRange, classifyDCFPrice } from './dcf-math.js';
import { buildDCFPriceOption } from './dcf-price-chart.js';
import { buildDCFCashflowOption } from './dcf-cashflow-chart.js';
import crosscheckTemplate from './dcf-crosscheck.html?raw';
import { createDCFCrosscheck } from './dcf-crosscheck.js';
import { createDCFHelp } from './dcf-help.js';
const STORAGE_KEY = 'smart-fin-chart:dcf:v1';
let memory;
const defaults = (example = false) => ({basis: 'fcff', fcf: '', year: '2026', shares: '', shareDate: '',
rate: '10', terminal: '2', debt: '0', assets: '0', minority: '0', margin: '20',
premium: '20', marketPrice: '', quoteDate: '',
crossProfitMode: 'manual', crossProfit: example ? '125' : '', crossConversion: example ? '80' : '',
crossProfitGrowth: example ? '8' : '', crossTarget: '13', crossYears: '5',
crossPeLow: '9', crossPe: '12', crossPeHigh: '15',
crossDividend: '0', crossDividendGrowth: '0', crossDilution: '0'});
const initialStages = () => [{years: '3', growth: '12'}, {years: '4', growth: '8'}, {years: '3', growth: '4'}];
const profile = (name, example = false) => ({name,
inputs: {...defaults(example), ...(example ? {fcf: '100', shares: '10', debt: '100', marketPrice: '138'} : {})}, stages: initialStages(), detailOpen: false});
const fmt = value => new Intl.NumberFormat('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(value);
function readStore() {
if (memory) return memory;
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (saved?.version === 1 && Array.isArray(saved.profiles)) {
const profiles = saved.profiles.filter(item => typeof item.id === 'string' && typeof item.name === 'string' &&
item.inputs && typeof item.inputs === 'object' && Array.isArray(item.stages) && item.stages.length >= 1 &&
item.stages.length <= 5 && item.stages.every(stage => stage && typeof stage === 'object'));
if (profiles.length || (saved.profiles.length === 0 && Array.isArray(saved.deletedProfileIds))) return {...saved, profiles};
}
} catch { /* Storage can be unavailable or corrupt; the calculator still works in memory. */ }
return {version: 1, selected: 'example', profiles: []};
}
export function renderDCFCalculator(container, companies = []) {
container.innerHTML = template;
const root = container.querySelector('.dcf-calculator');
const disposeHelp = createDCFHelp(root);
const q = name => root.querySelector(`[data-${name}]`);
q('crosscheck-slot').innerHTML = crosscheckTemplate;
const fields = Object.fromEntries([...root.querySelectorAll('[data-field]')].map(field => [field.dataset.field, field]));
const crosscheck = createDCFCrosscheck(q('crosscheck-slot').querySelector('.df-crosscheck'), fields, (data) => {
if (!data) {
q('overview-ceiling').textContent = '—';
q('overview-verdict').textContent = '填写有效的股价及利润假设,比较现金流价值与目标收益门槛。';
q('overview-verdict').closest('.df-decision').removeAttribute('data-ok');
q('top-irr').textContent = '—';
q('top-growth').textContent = '—';
q('top-irr-detail').textContent = '补全交叉验证参数后显示';
q('top-growth-detail').textContent = '按现价与基准退出 PE 反推';
return;
}
const {result, assumptions} = data;
const current = result.rows[0].base;
q('overview-ceiling').textContent = fmt(result.jointCeiling);
const decision = q('overview-verdict').closest('.df-decision');
decision.dataset.ok = String(assumptions.price <= result.jointCeiling + 1e-10);
q('overview-verdict').textContent = assumptions.price <= result.jointCeiling + 1e-10
? '现价低于共同参考上限,两套价格上限均留有设定的安全边际。'
: '现价高于共同参考上限,需复核现金流、利润增长与退出 PE 假设。';
q('top-irr').textContent = `${fmt(current.irr * 100)}%`;
q('top-growth').textContent = current.requiredGrowth === null ? '无需最低增速' : `${fmt(current.requiredGrowth * 100)}%`;
q('top-irr-detail').textContent = `${current.meetsTarget ? '达到' : '未达'}目标 ${fmt(assumptions.target * 100)}% · 持有 ${assumptions.years} 年`;
q('top-growth-detail').textContent = `含安全边际 ${fmt(Number(fields.margin.value))}% · 无边际 ${current.requiredGrowthWithoutMargin === null ? '无需最低增速' : fmt(current.requiredGrowthWithoutMargin * 100) + '%'} · 退出 PE ${fmt(assumptions.pe)} 倍`;
});
const store = readStore();
store.deletedProfileIds = Array.isArray(store.deletedProfileIds) ? store.deletedProfileIds.filter(id => typeof id === 'string') : [];
const addPreset = (id, name, example = false) => {
if (!store.deletedProfileIds.includes(id) && !store.profiles.some(item => item.id === id)) store.profiles.push({id, ...profile(name, example)});
};
addPreset('example', '教学示例(假设参数)', true);
companies.forEach(company => addPreset(company.id, company.name));
let active = store.profiles.find(item => item.id === store.selected) || store.profiles[0];
store.selected = active?.id ?? null;
let stages;
let chart;
let chartData;
let cashflowChart;
let cashflowRows;
let resizeFrame;
let disposed = false;
let lastDCFInput;
memory = store;
function save() {
if (active) {
active.inputs = Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.value]));
active.stages = stages.map(stage => ({...stage}));
active.detailOpen = q('detail').open;
active.crossAdvancedOpen = q('cross-advanced').open;
}
memory = store;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
q('save-status').textContent = '已自动保存到本机 · 每家公司独立保存';
} catch {
q('save-status').textContent = '浏览器存储不可用,参数仅在本次页面中保留';
}
}
function deleteCompany(item) {
if (!window.confirm(`删除「${item.name}」?\n该公司保存在本机的 DCF 与收益校验参数将一并删除,无法撤销。`)) return;
save();
const index = store.profiles.findIndex(profile => profile.id === item.id);
if (index < 0) return;
store.profiles.splice(index, 1);
if (!store.deletedProfileIds.includes(item.id)) store.deletedProfileIds.push(item.id);
if (active?.id === item.id) active = store.profiles[Math.min(index, store.profiles.length - 1)];
store.selected = active?.id ?? null;
applyProfile();
save();
const selected = q('company-buttons').querySelector('[aria-pressed="true"]');
(selected || q('company-name')).focus();
}
function renderCompanies() {
q('company-buttons').replaceChildren(...store.profiles.map(item => {
const chip = document.createElement('span');
chip.className = 'df-company-chip';
chip.dataset.profileId = item.id;
const button = document.createElement('button');
button.type = 'button';
button.textContent = item.name;
button.setAttribute('aria-pressed', String(item.id === active?.id));
button.addEventListener('click', () => {
if (active?.id === item.id) return;
save();
active = item;
store.selected = item.id;
q('company-form').hidden = true;
applyProfile();
save();
});
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'df-company-delete';
remove.textContent = '×';
remove.setAttribute('aria-label', `删除公司:${item.name}`);
remove.title = `删除${item.name}`;
remove.addEventListener('click', () => deleteCompany(item));
chip.append(button, remove);
return chip;
}));
}
function chartColors() {
const styles = getComputedStyle(root);
const color = name => styles.getPropertyValue(name).trim();
return {ink: color('--df-ink'), muted: color('--df-muted'), line: color('--df-line'), paper: color('--df-paper'),
accent: color('--df-accent'), secondary: color('--df-chart-secondary'), low: color('--df-low'), fair: color('--df-fair'), high: color('--df-high')};
}
function drawChart() {
if (disposed || !root.isConnected || !chartData) return;
if (!globalThis.echarts) {
q('market-error').textContent = '图表组件未加载,请检查网络后刷新页面;区间数值仍可查看。';
q('market-error').hidden = false;
return;
}
if (!chart) chart = globalThis.echarts.init(q('market-chart'), null, {renderer: 'svg'});
chart.resize();
chart.setOption(buildDCFPriceOption({...chartData, colors: chartColors(), width: q('market-chart').clientWidth,
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches}), true);
}
function drawCashflowChart() {
if (disposed || !root.isConnected || !cashflowRows) return;
q('cashflow-error').hidden = true;
try {
if (!globalThis.echarts) throw new Error('图表组件未加载,仍可在上方表格查看逐年数据。');
const element = q('cashflow-chart');
if (!cashflowChart) cashflowChart = globalThis.echarts.init(element, null, {renderer: 'svg'});
cashflowChart.resize();
cashflowChart.setOption(buildDCFCashflowOption({rows: cashflowRows, colors: chartColors(), width: element.clientWidth,
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches}), true);
} catch (error) {
q('cashflow-error').textContent = error.message;
q('cashflow-error').hidden = false;
}
}
function clearMarket(message) {
chartData = undefined;
chart?.clear();
q('market-chart').hidden = true;
q('zone-keys').hidden = true;
q('market-badge').hidden = true;
q('market-error').hidden = true;
const price = Number(fields.marketPrice.value);
q('market-current').textContent = fields.marketPrice.value.trim() && Number.isFinite(price) && price > 0 ? fmt(price) : '—';
q('deviation').textContent = '待补全有效的估值参数';
q('market-message').textContent = message;
q('market-message').hidden = false;
}
function updateMarket(p, result) {
clearMarket('填写当前股价后查看所处区间');
if (result.perShare <= 0) {
q('market-message').textContent = '无正的每股股权价值,无法划分股价区间';
return;
}
try {
if (!fields.premium.value.trim()) throw new Error('请填写高估溢价阈值');
const range = calculateDCFPriceRange({perShare: result.perShare, margin: p.margin, premium: Number(fields.premium.value) / 100});
q('zone-low').textContent = `< ${fmt(range.low)} 元`;
q('zone-fair').textContent = `${fmt(range.low)}—${fmt(range.high)} 元`;
q('zone-high').textContent = `> ${fmt(range.high)} 元`;
q('zone-keys').hidden = false;
q('market-message').hidden = true;
q('deviation').textContent = '填写当前股价后查看所处区间';
let price;
if (fields.marketPrice.value.trim()) {
const entered = Number(fields.marketPrice.value);
try {
const position = classifyDCFPrice(entered, range);
price = entered;
q('market-current').textContent = fmt(price);
q('market-badge').textContent = `${position.zone}区间`;
q('market-badge').dataset.zone = position.zone;
q('market-badge').hidden = false;
q('deviation').textContent = `相对 DCF 每股价值${position.deviation <= 0 ? '折价' : '溢价'} ${fmt(Math.abs(position.deviation) * 100)}%`;
q('market-message').hidden = true;
} catch (error) {
q('market-error').textContent = error.message;
q('market-error').hidden = false;
}
}
q('market-chart').hidden = false;
q('market-chart').setAttribute('aria-label', `${price === undefined ? '尚未填写有效股价' : `当前股价 ${fmt(price)} 元`},DCF 每股价值 ${fmt(range.perShare)} 元,合理区间 ${fmt(range.low)} 至 ${fmt(range.high)} 元`);
chartData = {range, price};
drawChart();
} catch (error) {
clearMarket(error.message);
q('market-error').textContent = error.message;
q('market-error').hidden = false;
q('market-message').hidden = true;
}
}
function renderStages() {
q('stages').replaceChildren();
stages.forEach((stage, index) => {
const row = document.createElement('div');
row.className = 'df-stage-row';
const heading = document.createElement('div');
heading.className = 'df-stage-name';
heading.textContent = `阶段 ${index + 1}`;
const range = document.createElement('small');
range.dataset.stageRange = String(index);
heading.append(range);
row.append(heading);
for (const [key, caption] of [['years', '年数'], ['growth', '年增长率 %']]) {
const label = document.createElement('label');
const text = document.createElement('span');
text.className = 'df-stage-key';
text.textContent = caption;
const input = document.createElement('input');
input.type = 'number';
input.step = key === 'years' ? '1' : '0.5';
input.value = stage[key];
input.setAttribute('aria-label', `阶段${index + 1}${caption}`);
input.dataset.stage = String(index);
input.dataset.stageField = key;
input.addEventListener('input', () => {
stage[key] = input.value;
update();
save();
});
label.append(text, input);
row.append(label);
}
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = '×';
remove.setAttribute('aria-label', `删除阶段${index + 1}`);
remove.disabled = stages.length === 1;
remove.addEventListener('click', () => {
stages.splice(index, 1);
renderStages();
update();
save();
});
row.append(remove);
q('stages').append(row);
});
q('add').disabled = stages.length >= 5;
}
function read() {
const input = {basis: fields.basis.value};
for (const key of ['fcf', 'year', 'shares', 'rate', 'terminal', 'margin', 'debt', 'assets', 'minority']) {
if (input.basis === 'fcfe' && ['debt', 'assets', 'minority'].includes(key)) { input[key] = 0; continue; }
if (!fields[key].value.trim()) throw new Error(key === 'shares'
? '请填写该公司的当前总股本(亿股),用于每股价值换算'
: '请补全基期现金流、折现率和其他必填参数');
input[key] = Number(fields[key].value);
}
for (const key of ['rate', 'terminal', 'margin']) input[key] /= 100;
input.stages = stages.map(stage => ({
years: stage.years.trim() ? Number(stage.years) : NaN,
growth: stage.growth.trim() ? Number(stage.growth) / 100 : NaN,
}));
return input;
}
function update() {
const fcfe = fields.basis.value === 'fcfe';
q('rate-label').textContent = fcfe ? '股权资本成本(%)' : '折现率 WACC(%)';
for (const name of ['debt-field', 'assets-field', 'minority-field', 'adjustment']) q(name).hidden = fcfe;
q('value-label').textContent = fcfe ? '现金流股权价值' : '企业经营价值';
q('model-note').textContent = fcfe ? 'FCFE 按股权资本成本折现,不再扣减净债务' : 'FCFF 按 WACC 折现,再调整为股权价值';
let first = 1;
stages.forEach((stage, index) => {
const years = Number(stage.years);
root.querySelector(`[data-stage-range="${index}"]`).textContent =
Number.isInteger(years) && years > 0 && Number.isFinite(first) ? `第 ${first}—${first + years - 1} 年` : '请填写有效年数';
first = Number.isInteger(years) && years > 0 ? first + years : NaN;
});
q('error').hidden = true;
q('warning').hidden = true;
try {
const p = read();
const r = calculateDCF(p);
q('duration').textContent = `合计 ${r.years} 年 · ${p.year + 1}—${p.year + r.years} 年,各阶段逐年复利衔接`;
q('table-summary').textContent = ` · ${r.years} 年`;
const values = {pv: r.forecastPV, tv: r.terminalPV, ev: r.operatingValue, equity: r.equityValue, bridge: r.adjustment};
for (const [name, value] of Object.entries(values)) q(name).textContent = fmt(value);
q('price').textContent = r.equityValue > 0 ? fmt(r.perShare) : '无正股权价值';
q('buy').textContent = r.equityValue > 0 ? fmt(r.entryPrice) : '—';
q('margin-label').textContent = fmt(p.margin * 100) + '%';
q('shares-note').textContent = `按 ${fmt(p.shares)} 亿股换算${fields.shareDate.value ? ` · 股本日期 ${fields.shareDate.value}` : ''}`;
q('terminal-share').textContent = `永续终值占比 ${fmt(r.terminalWeight * 100)}%`;
q('rows').replaceChildren(...r.rows.map(row => {
const tr = document.createElement('tr');
for (const value of [`${row.year} · 阶段 ${row.stage}`, `${fmt(row.growth * 100)}%`, fmt(row.cash), row.factor.toFixed(4), fmt(row.presentValue)]) {
const td = document.createElement('td');
td.textContent = value;
tr.append(td);
}
return tr;
}));
const warnings = [];
if (r.terminalWeight > .75) warnings.push('永续终值超过总现值的 75%,结果对折现率与永续增长率高度敏感。');
if (r.equityValue <= 0) warnings.push('调整后的股权价值不大于 0,无法据此给出正的每股估值或参考买入价。');
if (warnings.length) { q('warning').textContent = warnings.join(' '); q('warning').hidden = false; }
q('detail').hidden = false;
cashflowRows = r.rows;
q('cashflow-section').hidden = false;
q('cashflow-caption').textContent = `${p.year + 1}—${p.year + r.years} 年 · ${r.years} 年预测期 · 现值合计 ${fmt(r.forecastPV)} 亿元`;
q('cashflow-chart').setAttribute('aria-label', `${r.years} 年自由现金流与折现现值对比,${r.rows.map(row => `${row.year} 年现金流 ${fmt(row.cash)}、现值 ${fmt(row.presentValue)} 亿元`).join(';')}`);
drawCashflowChart();
root.querySelector('.df-output').hidden = false;
root.querySelector('.df-results').hidden = false;
q('composition-forecast').style.width = `${Math.max(0, Math.min(100, (1 - r.terminalWeight) * 100))}%`;
q('composition-terminal').style.width = `${Math.max(0, Math.min(100, r.terminalWeight * 100))}%`;
updateMarket(p, r);
lastDCFInput = p;
crosscheck.update(p);
root.dataset.status = 'ready';
} catch (error) {
q('error').textContent = error.message;
q('error').hidden = false;
q('detail').hidden = true;
cashflowRows = undefined;
cashflowChart?.clear();
q('cashflow-section').hidden = true;
root.querySelector('.df-output').hidden = true;
root.querySelector('.df-results').hidden = true;
q('duration').textContent = '请补全有效参数后查看结果';
clearMarket('请补全有效的现金流、股本与折现参数后查看估值区间');
lastDCFInput = undefined;
crosscheck.update(undefined);
root.dataset.status = 'invalid';
}
}
function applyProfile() {
root.dataset.empty = String(!active);
q('company-empty').hidden = Boolean(active);
if (!active) {
stages = [];
lastDCFInput = chartData = cashflowRows = undefined;
chart?.clear();
cashflowChart?.clear();
root.dataset.status = 'empty';
q('company-form').hidden = false;
q('company-error').textContent = '';
renderCompanies();
return;
}
const values = {...defaults(active.id === 'example'), ...active.inputs};
for (const [key, field] of Object.entries(fields)) field.value = values[key];
stages = active.stages.map(stage => ({years: String(stage.years ?? ''), growth: String(stage.growth ?? '')}));
q('detail').open = active.detailOpen !== false;
q('cross-advanced').open = Boolean(active.crossAdvancedOpen);
q('context').textContent = active.id === 'example' ? '教学示例 · 非实时数据 · 人民币' : `${active.name} · 用户手动输入 · 人民币`;
renderCompanies();
renderStages();
update();
}
q('add').addEventListener('click', () => {
if (stages.length >= 5) return;
stages.push({years: '3', growth: '3'});
renderStages(); update(); save();
});
for (const [name, field] of Object.entries(fields)) field.addEventListener('input', () => {
if (name.startsWith('cross')) crosscheck.update(lastDCFInput);
else update();
save();
});
q('detail').addEventListener('toggle', () => { if (root.isConnected) save(); });
q('cross-advanced').addEventListener('toggle', () => { if (root.isConnected) save(); });
q('new-company').addEventListener('click', () => {
q('company-form').hidden = false;
q('company-error').textContent = '';
q('company-name').focus();
});
q('cancel-company').addEventListener('click', () => { q('company-form').hidden = true; });
q('company-form').addEventListener('submit', event => {
event.preventDefault();
const name = q('company-name').value.trim();
if (!name) { q('company-error').textContent = '请输入公司名称'; return; }
if (store.profiles.some(item => item.name === name)) { q('company-error').textContent = '该公司已存在,请点击公司按钮切换'; return; }
save();
active = {id: `custom-${crypto.randomUUID()}`, ...profile(name)};
store.profiles.push(active);
store.selected = active.id;
q('company-form').hidden = true;
q('company-name').value = '';
applyProfile(); save();
});
root.querySelectorAll('[data-jump]').forEach(button => button.addEventListener('click', () => {
const target = root.querySelector(`[data-section="${button.dataset.jump}"]`);
if (target === q('detail')) target.open = true;
target.scrollIntoView({behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', block: 'start'});
target.focus({preventScroll: true});
}));
applyProfile();
const observer = new ResizeObserver(() => {
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => { drawChart(); drawCashflowChart(); });
});
observer.observe(q('market-chart'));
observer.observe(q('cashflow-chart'));
return () => {
disposed = true;
disposeHelp();
observer.disconnect();
cancelAnimationFrame(resizeFrame);
chart?.dispose();
cashflowChart?.dispose();
};
}
+36
View File
@@ -0,0 +1,36 @@
const fmt = value => new Intl.NumberFormat('zh-CN', {maximumFractionDigits: 2}).format(value);
// Both series use the same annual rows as the retained table, in 亿元.
export function buildDCFCashflowOption({rows, colors, width, reducedMotion = false}) {
const compact = width < 520;
return {
animation: !reducedMotion,
animationDuration: 0,
animationDurationUpdate: 200,
color: [colors.secondary, colors.accent],
legend: {top: 0, left: 'center', itemWidth: 12, itemHeight: 8,
textStyle: {color: colors.muted, fontSize: 11}, data: ['自由现金流', '折现后现值']},
grid: {left: compact ? 8 : 12, right: 12, top: 58, bottom: rows.length > 15 ? 70 : 16, containLabel: true},
tooltip: {trigger: 'axis', confine: true, backgroundColor: colors.paper, borderColor: colors.line,
textStyle: {color: colors.ink, fontSize: 12}, axisPointer: {type: 'shadow'},
formatter: params => {
const row = rows[params[0].dataIndex];
return `${row.year} 年 · 阶段 ${row.stage}<br>自由现金流:${fmt(row.cash)} 亿元<br>折现后现值:${fmt(row.presentValue)} 亿元<br>折现因子:${row.factor.toFixed(4)}<br>现金流增长率:${fmt(row.growth * 100)}%`;
}},
xAxis: {type: 'category', data: rows.map(row => String(row.year)),
axisLine: {lineStyle: {color: colors.line}}, axisTick: {show: false},
axisLabel: {color: colors.muted, fontSize: 11, hideOverlap: true}},
yAxis: {type: 'value', name: '亿元', nameTextStyle: {color: colors.muted, fontSize: 11},
axisLabel: {color: colors.muted, fontSize: 11},
splitLine: {lineStyle: {color: colors.line, type: 'dashed'}}},
dataZoom: rows.length > 15 ? [{type: 'slider', height: 18, bottom: 8,
startValue: 0, endValue: 14, borderColor: colors.line,
textStyle: {color: colors.muted, fontSize: 10}}] : [],
series: [
{name: '自由现金流', type: 'bar', data: rows.map(row => row.cash),
barMaxWidth: 24, itemStyle: {borderRadius: [3, 3, 0, 0]}},
{name: '折现后现值', type: 'bar', data: rows.map(row => row.presentValue),
barMaxWidth: 24, itemStyle: {borderRadius: [3, 3, 0, 0]}},
],
};
}
+50
View File
@@ -0,0 +1,50 @@
import { calculateDCF } from './dcf-math.js';
import { calculate as calculateReturn } from './stock-return-math.js';
// Cash amounts: 亿元; shares: 亿股; prices/dividends: 元/股; rates: decimals.
export function calculateDCFCrosscheck(dcfInput, assumptions) {
const dcf = calculateDCF(dcfInput);
if (dcf.perShare <= 0) throw new Error('无正的 DCF 每股股权价值,无法进行双价格验证');
if (!Number.isFinite(assumptions.price) || assumptions.price <= 0) throw new Error('请在股价区填写大于 0 的当前股价');
if (!['manual', 'estimate'].includes(assumptions.profitMode)) throw new Error('请选择归母净利润来源');
let profit;
if (assumptions.profitMode === 'estimate') {
if (dcfInput.basis !== 'fcfe') throw new Error('FCFF 口径不能直接估算归母净利润,请手动填写');
if (!Number.isFinite(assumptions.conversion) || assumptions.conversion <= 0) throw new Error('基期现金转化率须大于 0');
profit = dcfInput.fcf / assumptions.conversion;
} else profit = assumptions.profit;
if (!Number.isFinite(profit) || profit <= 0) throw new Error('基期归母净利润须大于 0');
for (const pe of [assumptions.peLow, assumptions.pe, assumptions.peHigh]) {
if (!Number.isFinite(pe) || pe <= 0) throw new Error('悲观、基准和乐观退出 PE 均须大于 0');
}
if (assumptions.peLow > assumptions.pe || assumptions.pe > assumptions.peHigh) throw new Error('退出 PE 请按悲观 ≤ 基准 ≤ 乐观填写');
const model = {base_year: dcfInput.year, base_profit_100m: profit, shares_100m: dcfInput.shares,
years: assumptions.years, growth_rate: assumptions.profitGrowth, target_return: assumptions.target,
annual_dividend_per_share: assumptions.dividend, dividend_growth_rate: assumptions.dividendGrowth,
annual_dilution_rate: assumptions.dilution,
// Use the standalone calculator's safety-margin convention for both price and growth.
// Actual IRR still uses the actual buy price, without a synthetic price adjustment.
safety_margin: dcfInput.margin};
const scenario = (price, exitPe) => {
const result = calculateReturn({...model, price, exit_pe: exitPe});
return {pe: exitPe, irr: result.holding_period_irr,
requiredGrowth: result.required_growth_with_safety_margin.required_growth_rate,
requiredGrowthWithoutMargin: result.required_growth.required_growth_rate,
maxPrice: result.max_purchase_price_at_target_return,
maxPriceWithMargin: result.max_purchase_price_with_safety_margin,
meetsSafetyMargin: result.meets_safety_margin,
meetsTarget: result.holding_period_irr + 1e-10 >= assumptions.target};
};
const rows = [
{name: '当前股价买入', price: assumptions.price},
{name: 'DCF 参考价买入', price: dcf.entryPrice},
].map(row => ({...row, scenarios: [assumptions.peLow, assumptions.pe, assumptions.peHigh].map(pe => scenario(row.price, pe))}));
rows.forEach(row => { row.base = row.scenarios[1]; });
const returnCeiling = rows[0].base.maxPriceWithMargin;
const jointCeiling = Math.min(dcf.entryPrice, returnCeiling);
const dcfCondition = assumptions.price <= dcf.entryPrice + 1e-10;
const returnCondition = rows[0].base.meetsSafetyMargin;
return {profit, estimated: assumptions.profitMode === 'estimate', dcf, rows, returnCeiling, jointCeiling,
dcfCondition, returnCondition, dcfDiscount: 1 - assumptions.price / dcf.perShare,
growthGap: rows[0].base.requiredGrowth === null ? null : assumptions.profitGrowth - rows[0].base.requiredGrowth};
}
+69
View File
@@ -0,0 +1,69 @@
<section class="df-crosscheck" aria-label="目标收益交叉验证">
<div class="df-column-heading"><span>02</span><div><h3>目标收益交叉验证</h3><p>以当前价格买入,需要怎样的盈利增长</p></div><b>IRR</b></div>
<div class="df-cross-shared df-muted" data-cross-shared>共用当前公司的股价、股本、基期及 DCF 参考价</div>
<details class="df-cross-inputs" open><summary>利润与收益假设 <span class="df-muted">独立于 DCF 现金流增速</span></summary><div class="df-cross-grid">
<div>
<label>基期归母净利润来源
<select data-field="crossProfitMode"><option value="manual">手动填写归母净利润</option><option value="estimate">共用 FCFE + 现金转化率估算</option></select>
</label>
<div class="df-cross-bridge" data-cross-bridge hidden>
<div class="df-muted">已关联基础参数:<span data-cross-fcf>—</span> 亿元 FCFE</div>
<label>基期现金转化率(FCFE ÷ 归母净利润,%)<input data-field="crossConversion" type="number" min="0" step="any" placeholder="填写基期转化率假设"></label>
<div class="df-cross-derived"><span>基期归母净利润(估算)</span><b data-cross-derived>—</b></div>
<div class="df-muted">基期转化率为用户假设;100% 表示假设基期 FCFE 等于归母利润。</div>
</div>
<label class="df-cross-profit" data-cross-manual>基期归母净利润(亿元)<input data-field="crossProfit" type="number" min="0" step="any" placeholder="填写归母净利润"></label>
<div class="df-muted df-cross-source-note" data-cross-source-note></div>
<label class="df-cross-growth">预计归母净利润年增长率(%)<input data-field="crossProfitGrowth" type="number" min="-99.99" max="1000" step="any" placeholder="独立填写利润增速假设"></label>
</div>
<div>
<div class="df-fields">
<label>目标年化收益率(%)<input data-field="crossTarget" type="number" min="-99.99" max="1000" step="any"></label>
<label>持有年限<input data-field="crossYears" type="number" min="1" max="30" step="1"></label>
</div>
<div class="df-cross-pe">
<label>悲观退出 PE(倍)<input data-field="crossPeLow" type="number" min="0" step="any"></label>
<label>基准退出 PE(倍)<input data-field="crossPe" type="number" min="0" step="any"></label>
<label>乐观退出 PE(倍)<input data-field="crossPeHigh" type="number" min="0" step="any"></label>
</div>
<div class="df-muted df-cross-pe-note">退出 PE 为情景假设,不是未来市场定价承诺。目标收益率不会改写 DCF 折现率。</div>
</div>
</div>
<details class="df-cross-advanced" data-cross-advanced>
<summary>分红与股本变化 <span class="df-muted" data-cross-advanced-caption></span></summary>
<div class="df-fields">
<label>首年每股现金股息(元)<input data-field="crossDividend" type="number" min="0" step="any"></label>
<label>每股股息年增长率(%)<input data-field="crossDividendGrowth" type="number" min="-99.99" max="1000" step="any"></label>
<label>股本年变化率(%)<input data-field="crossDilution" type="number" min="-99.99" max="1000" step="any"></label>
</div>
<div class="df-muted df-cross-pe-note">每年末收到股息,末年同时收到股息与退出款;股息路径独立于利润,不计再投资与税费。DCF 按固定股本计算,本区股本变化仅用于收益验证。</div>
</details>
</details>
<div class="df-error" data-cross-error role="alert" hidden></div>
<div class="df-cross-results" data-cross-results aria-live="polite" hidden>
<section class="df-cross-joint" aria-label="共同参考买入上限">
<div class="df-cross-joint-grid">
<div><span>DCF 参考价</span><b data-cross-joint-dcf></b></div>
<div><span>目标收益参考价(含边际)</span><b data-cross-joint-return></b><small class="df-muted" data-cross-return-base></small></div>
<div><span>共同参考买入上限</span><b data-cross-joint-price></b></div>
</div>
<div class="df-muted" data-cross-joint-note></div>
</section>
<section class="df-scenarios" aria-label="现价买入的三种退出情景"><div class="df-section-head"><h3>三种退出情景</h3><span class="df-muted">现价买入 · 预计年化 IRR</span></div><div class="df-scenario-grid" data-cross-cards></div></section>
<section class="df-cross-sensitivity" aria-label="退出 PE 情景验证">
<div class="df-section-head"><h3>退出 PE 情景验证</h3><span class="df-muted">表中为预计年化 IRR</span></div>
<div class="df-table-wrap"><table aria-label="退出 PE 敏感性"><thead><tr><th>买入情景</th><th data-cross-head-low></th><th data-cross-head-base></th><th data-cross-head-high></th></tr></thead><tbody data-cross-scenario-rows></tbody></table></div>
</section>
<details class="df-cross-prices"><summary>展开双价格验证与条件拆解</summary>
<div class="df-section-head"><h3>两种买入价格,分别验证</h3><span class="df-muted" data-cross-caption></span></div>
<div class="df-table-wrap"><table aria-label="双买入价格交叉验证"><thead><tr><th>买入情景</th><th>买入价(元)</th><th>所需利润年增速</th><th>预计年化 IRR</th><th>目标回报条件</th></tr></thead><tbody data-cross-price-rows></tbody></table></div>
<div class="df-muted df-cross-table-note" data-cross-growth-note></div>
<div class="df-cross-checks">
<div><span class="df-muted">现金流估值条件 · 按现价</span><strong data-cross-dcf-condition></strong><p class="df-muted" data-cross-dcf-gap></p></div>
<div><span class="df-muted">盈利与安全边际门槛 · 基准 PE · 按现价</span><strong data-cross-growth-condition></strong><p class="df-muted" data-cross-growth-gap></p></div>
</div>
<div class="df-cross-verdict" data-cross-verdict></div>
</details>
</div>
</section>
+130
View File
@@ -0,0 +1,130 @@
import { calculateDCFCrosscheck } from './dcf-crosscheck-math.js';
const fmt = value => new Intl.NumberFormat('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(value);
const pct = value => `${fmt(value * 100)}%`;
const cell = (text, ok) => {
const td = document.createElement('td');
if (ok === undefined) td.textContent = text;
else {
const span = document.createElement('span');
span.className = 'df-cross-tag';
span.dataset.ok = String(ok);
span.textContent = text;
td.append(span);
}
return td;
};
export function createDCFCrosscheck(root, fields, onResult = () => {}) {
const q = name => root.querySelector(`[data-cross-${name}]`);
const number = (name, label) => {
if (!fields[name].value.trim()) throw new Error(`请填写${label}`);
const value = Number(fields[name].value);
if (!Number.isFinite(value)) throw new Error(`${label}须为有效数字`);
return value;
};
function reset(message) {
q('results').hidden = true;
q('error').textContent = message;
q('error').hidden = false;
q('derived').textContent = '—';
root.dataset.status = 'invalid';
onResult(undefined);
}
function update(dcfInput) {
const fcff = fields.basis.value !== 'fcfe';
fields.crossProfitMode.querySelector('[value="estimate"]').disabled = fcff;
if (fcff && fields.crossProfitMode.value === 'estimate') fields.crossProfitMode.value = 'manual';
const estimated = fields.crossProfitMode.value === 'estimate';
q('bridge').hidden = !estimated;
q('manual').hidden = estimated;
q('source-note').textContent = fcff ? 'FCFF 包含债权人现金流,归母净利润需独立输入。'
: estimated ? '采用基期转化率假设估算利润,非独立财报验证。' : '归母净利润独立输入,不随基期现金流自动变动。';
q('fcf').textContent = dcfInput ? fmt(dcfInput.fcf) : '—';
if (!dcfInput) {
q('shared').textContent = '共用当前公司的股价、股本和基期,请先补全有效的 DCF 参数。';
reset('完成 DCF 参数后,可继续填写利润与目标收益假设进行验证。');
return;
}
q('shared').textContent = `已共用当前公司 · ${dcfInput.year} 年基期 · ${fmt(dcfInput.shares)} 亿股 · 人民币口径`;
try {
const assumptions = {
price: number('marketPrice', '当前股价(上方股价区)'),
profitMode: fields.crossProfitMode.value,
profit: estimated ? undefined : number('crossProfit', '基期归母净利润'),
conversion: estimated ? number('crossConversion', '基期现金转化率') / 100 : undefined,
profitGrowth: number('crossProfitGrowth', '预计利润年增长率') / 100,
target: number('crossTarget', '目标年化收益率') / 100,
years: number('crossYears', '持有年限'),
peLow: number('crossPeLow', '悲观退出 PE'), pe: number('crossPe', '基准退出 PE'), peHigh: number('crossPeHigh', '乐观退出 PE'),
dividend: number('crossDividend', '首年股息'),
dividendGrowth: number('crossDividendGrowth', '股息增长率') / 100,
dilution: number('crossDilution', '股本年变化率') / 100,
};
const result = calculateDCFCrosscheck(dcfInput, assumptions);
const current = result.rows[0].base;
const growthText = growth => growth === null ? '无需最低增速' : pct(growth);
q('derived').textContent = `${fmt(result.profit)} 亿元`;
q('caption').textContent = `基准退出 PE ${fmt(assumptions.pe)} 倍 · 持有 ${assumptions.years} 年`;
q('advanced-caption').textContent = ` · 首年股息 ${fmt(assumptions.dividend)} 元 · 股本年变化 ${pct(assumptions.dilution)}`;
q('price-rows').replaceChildren(...result.rows.map(row => {
const tr = document.createElement('tr');
tr.append(cell(row.name), cell(fmt(row.price)), cell(`${growthText(row.base.requiredGrowth)}(含边际);无边际 ${growthText(row.base.requiredGrowthWithoutMargin)}`),
cell(pct(row.base.irr)), cell(row.base.meetsTarget ? '达到目标' : '未达目标', row.base.meetsTarget));
return tr;
}));
q('dcf-condition').textContent = result.dcfCondition ? '现价满足 DCF 安全边际要求' : '现价未满足 DCF 安全边际要求';
q('dcf-gap').textContent = `相对 DCF 每股价值${result.dcfDiscount >= 0 ? '折价' : '溢价'} ${pct(Math.abs(result.dcfDiscount))}`;
q('growth-condition').textContent = current.requiredGrowth === null ? '股息现值已覆盖含边际要求'
: `预计利润年增 ${pct(assumptions.profitGrowth)},含边际门槛 ${pct(current.requiredGrowth)}`;
q('growth-gap').textContent = result.growthGap === null ? '当前分红假设下,无最低退出盈利要求。'
: `${result.growthGap >= 0 ? '高于' : '低于'}门槛 ${fmt(Math.abs(result.growthGap) * 100)} 个百分点`;
q('verdict').textContent = result.dcfCondition
? result.returnCondition ? '当前两套模型条件均满足;结果仍取决于现金流、利润和退出 PE 假设。'
: 'DCF 有安全边际,但当前利润增长与退出 PE 假设尚未同时满足目标回报和设定的安全边际。'
: result.returnCondition ? '收益情景满足目标回报和设定的安全边际,但现价尚未满足 DCF 安全边际要求,需复核两边假设。'
: '现价尚未同时满足 DCF 与目标收益模型的安全边际条件。';
q('joint-dcf').textContent = fmt(result.dcf.entryPrice);
q('joint-return').textContent = fmt(result.returnCeiling);
q('return-base').textContent = `折扣前 ${fmt(current.maxPrice)} 元/股`;
q('joint-price').textContent = fmt(result.jointCeiling);
q('joint-note').textContent = `两套参考价各应用一次 ${pct(dcfInput.margin)} 安全边际后取较低值;单位:元/股。`;
q('growth-note').textContent = `所需增速计入 ${pct(dcfInput.margin)} 安全边际,与“收益反推”的主结果口径相同,同时列示无边际对照。预计 IRR 按实际买入价、股息与退出款计算,不因安全边际设置而改变。`;
for (const [name, value] of [['low', assumptions.peLow], ['base', assumptions.pe], ['high', assumptions.peHigh]]) {
q(`head-${name}`).textContent = `${{low: '悲观', base: '基准', high: '乐观'}[name]} · ${fmt(value)} 倍`;
}
q('cards').replaceChildren(...currentScenarios());
function currentScenarios() {
return result.rows[0].scenarios.map((scenario, index) => {
const card = document.createElement('article');
card.className = 'df-scenario-card';
card.dataset.base = String(index === 1);
card.dataset.ok = String(scenario.meetsTarget);
const caption = document.createElement('span');
caption.textContent = `${['悲观', '基准', '乐观'][index]} · PE ${fmt(scenario.pe)}`;
const value = document.createElement('strong');
value.textContent = pct(scenario.irr);
const growth = document.createElement('small');
growth.textContent = `含边际所需年增 ${growthText(scenario.requiredGrowth)};无边际 ${growthText(scenario.requiredGrowthWithoutMargin)}`;
const status = document.createElement('span');
status.className = 'df-scenario-status';
status.textContent = scenario.meetsTarget ? '✓ 达到目标收益' : '未达目标收益';
card.append(caption, value, growth, status);
return card;
});
}
q('scenario-rows').replaceChildren(...result.rows.map(row => {
const tr = document.createElement('tr');
tr.append(cell(row.name), ...row.scenarios.map(scenario => cell(pct(scenario.irr))));
return tr;
}));
q('results').hidden = false;
q('error').hidden = true;
root.dataset.status = 'ready';
onResult({result, assumptions});
} catch (error) {
reset(error.message);
}
}
return {update};
}
+49
View File
@@ -0,0 +1,49 @@
<dialog class="df-help-dialog" aria-labelledby="dcf-help-title" aria-describedby="dcf-help-intro">
<header class="df-help-head">
<div><h2 id="dcf-help-title">估值概念说明</h2><p id="dcf-help-intro" class="df-muted">理解现金流口径,再选择适合数据的估值方式。</p></div>
<button class="df-help-close" type="button" data-help-close aria-label="关闭概念说明" autofocus>×</button>
</header>
<nav class="df-help-nav" aria-label="概念目录">
<button type="button" data-help-jump="fcff">FCFF 企业现金流</button>
<button type="button" data-help-jump="fcfe">FCFE 股东现金流</button>
<button type="button" data-help-jump="comparison">区别与选择</button>
<button type="button" data-help-jump="profit">与净利润的关系</button>
</nav>
<div class="df-help-body">
<p class="df-help-lead">FCFF 看企业能为<strong>股东和债权人整体</strong>创造多少现金;FCFE 看处理完债务融资后,能留给<strong>股东</strong>多少现金。</p>
<section data-help-section="fcff" tabindex="-1">
<h3>FCFF · 企业自由现金流</h3>
<p>公司支付经营支出、税费,并满足必要的再投资后,可以供股东和债权人分配的现金。它尚未扣除向债权人支付的利息及本金,也不包含新借款流入。</p>
<p>可以把公司想成一家你和银行共同出资的工厂:FCFF 衡量工厂业务本身创造现金的能力。</p>
<div class="df-help-formula">FCFF = 税后经营利润 + 折旧摊销等非现金费用 − 资本开支 − 经营营运资金增加</div>
<p><strong>本页如何使用:</strong>按 WACC(股东与债权人的综合资金成本)折现,先得到企业经营价值,再扣净债务、加其他非经营资产净额、扣少数股东权益,换算为每股价值。超额现金若已在净债务中扣除,不应重复加回。</p>
</section>
<section data-help-section="fcfe" tabindex="-1">
<h3>FCFE · 股权自由现金流</h3>
<p>满足经营、税费及再投资需求,并考虑利息、偿还借款和新增借款后,可供普通股股东分配的现金。它代表分配能力,实际分红可能低于这个金额。</p>
<div class="df-help-formula">FCFE = FCFF − 利息 ×(1 − 所得税率)+ 新增借款 − 偿还借款本金</div>
<p><strong>假设例子:</strong>FCFF 为 100 万元,利息为 10 万元,所得税率 25%,偿还本金 20 万元,新借款 10 万元。则 FCFE = 100 − 7.5 − 20 + 10 = <strong>82.5 万元</strong>。</p>
<p><strong>本页如何使用:</strong>按股权资本成本(股东要求的回报率)折现,直接得到现金流对应的股权价值。债务融资影响已在现金流预测中考虑,本页不再单独扣减净债务;其他未纳入现金流的资产需另行核对。</p>
</section>
<section data-help-section="comparison" tabindex="-1">
<h3>两种口径怎么选?</h3>
<div class="df-help-table"><table aria-label="FCFF 与 FCFE 对比"><thead><tr><th>对比</th><th>FCFF</th><th>FCFE</th></tr></thead><tbody>
<tr><th scope="row">现金属于谁</th><td>股东与债权人整体</td><td>普通股股东</td></tr>
<tr><th scope="row">折现率</th><td>WACC</td><td>股权资本成本</td></tr>
<tr><th scope="row">折现结果</th><td>企业经营价值</td><td>现金流股权价值</td></tr>
<tr><th scope="row">本页净债务处理</th><td>从经营价值中扣减</td><td>不再单独扣减</td></tr>
</tbody></table></div>
<p>根据手上数据的实际定义选择。切换下拉选项不会自动把 FCFF 转换为 FCFE,也不会自动换算折现率;现金流金额、预测增长率与折现率都要使用匹配的假设。</p>
<p>财报中的“经营活动现金流净额”还没有扣除资本开支,不能直接当作自由现金流。“经营现金流减资本开支”也需核对利息分类与借款处理,才能确定其口径。</p>
</section>
<section data-help-section="profit" tabindex="-1">
<h3>为什么还要输入归母净利润?</h3>
<p>DCF 使用自由现金流;目标收益交叉验证通过利润、股本和退出 PE 推算回报。利润和现金流会因折旧、资本开支、应收账款与存货占款等产生差异。</p>
<div class="df-help-formula">现金转化率 = FCFE ÷ 归母净利润<br>估算归母净利润 = FCFE ÷ 现金转化率</div>
<p>例如 FCFE 为 80 亿元、现金转化率假设为 80%,估算利润为 100 亿元。该比例是用户假设;填 100% 表示假设两者相等。</p>
<p><strong>本页的选择限制:</strong>“共用 FCFE + 现金转化率估算”仅在基础参数选择 FCFE 时启用。FCFF 模式需手动输入归母净利润。使用独立财报利润更有利于交叉验证;共用现金流估算的利润会依赖同一套假设。</p>
<p><strong>与“收益反推”对照:</strong>目标收益参考价和所需利润增速均计入本页设置的安全边际。两页对照时,需同时保持买入价、利润、股本、持有年限、退出 PE、目标收益率、安全边际、分红与股本变化假设一致。预计 IRR 始终按实际买入价计算。</p>
</section>
<p class="df-help-source">参考:<a href="https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/free-cash-flow-valuation" target="_blank" rel="noopener noreferrer">CFA Institute · Free Cash Flow Valuation(英文)</a></p>
</div>
</dialog>
+59
View File
@@ -0,0 +1,59 @@
import template from './dcf-help.html?raw';
// Keep the documentation separate from calculator inputs and per-company storage.
// Add future concepts as a section and a matching directory button in the template.
export function createDCFHelp(root) {
root.insertAdjacentHTML('beforeend', template);
const dialog = root.querySelector('.df-help-dialog');
const trigger = root.querySelector('[data-help-open]');
const body = dialog.querySelector('.df-help-body');
let previousOverflow;
const unlock = () => {
if (previousOverflow === undefined) return;
document.documentElement.style.overflow = previousOverflow;
previousOverflow = undefined;
};
trigger.addEventListener('click', () => {
if (dialog.open) return;
dialog.showModal();
previousOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
body.scrollTop = 0;
});
dialog.querySelector('[data-help-close]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => {
unlock();
if (trigger.isConnected) trigger.focus({preventScroll: true});
});
dialog.addEventListener('keydown', event => {
if (event.key !== 'Tab') return;
const items = [...dialog.querySelectorAll('button:not(:disabled), a[href]')];
const destination = event.shiftKey && document.activeElement === items[0] ? items.at(-1)
: !event.shiftKey && document.activeElement === items.at(-1) ? items[0] : undefined;
if (destination) {
event.preventDefault();
destination.focus();
}
});
let backdropPointer = false;
const outside = event => {
const rect = dialog.getBoundingClientRect();
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
};
dialog.addEventListener('pointerdown', event => { backdropPointer = event.target === dialog && outside(event); });
dialog.addEventListener('click', event => {
if (backdropPointer && event.target === dialog && outside(event)) dialog.close();
backdropPointer = false;
});
dialog.querySelectorAll('[data-help-jump]').forEach(button => button.addEventListener('click', () => {
const section = dialog.querySelector(`[data-help-section="${button.dataset.helpJump}"]`);
body.scrollTop = section.getBoundingClientRect().top - body.getBoundingClientRect().top + body.scrollTop;
section.focus({preventScroll: true});
}));
return () => {
if (dialog.open) dialog.close();
unlock();
dialog.remove();
};
}
+71
View File
@@ -0,0 +1,71 @@
// Segmented, year-end cash-flow DCF. Rates are decimals; cash amounts are 亿元, shares 亿股.
export function calculateDCF(p) {
const finite = (value, label) => {
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${label}须为有效数字`);
};
if (!['fcff', 'fcfe'].includes(p.basis)) throw new Error('请选择现金流口径');
for (const [key, label] of Object.entries({fcf: '基期自由现金流', shares: '当前总股本', rate: '折现率', terminal: '永续增长率', margin: '安全边际'})) finite(p[key], label);
if (p.fcf <= 0) throw new Error('本模型要求基期可持续自由现金流大于 0');
if (p.shares <= 0) throw new Error('请填写该公司的当前总股本(亿股),且须大于 0');
if (!Number.isInteger(p.year) || p.year < 1900 || p.year > 2200) throw new Error('基期年份须为 1900—2200 的整数');
if (p.rate <= -1 || p.terminal <= -1 || p.terminal >= p.rate) throw new Error('永续增长率须小于折现率,且两者均须大于 −100%');
if (p.margin < 0 || p.margin >= 1) throw new Error('安全边际须大于等于 0% 且小于 100%');
if (!Array.isArray(p.stages) || p.stages.length < 1 || p.stages.length > 5) throw new Error('请设置 1—5 个增长阶段');
let years = 0;
for (const stage of p.stages) {
if (!Number.isInteger(stage.years) || stage.years < 1) throw new Error('每阶段年数须为正整数');
finite(stage.growth, '阶段增长率');
if (stage.growth <= -1) throw new Error('阶段增长率须大于 −100%');
years += stage.years;
}
if (years > 30) throw new Error('显式预测期合计最多 30 年');
let adjustment = 0;
if (p.basis === 'fcff') {
for (const [key, label] of Object.entries({debt: '净债务', assets: '非经营资产净额', minority: '少数股东权益'})) finite(p[key], label);
adjustment = -p.debt + p.assets - p.minority;
}
let cash = p.fcf, t = 0, forecastPV = 0;
const rows = [];
p.stages.forEach((stage, i) => {
for (let y = 0; y < stage.years; y++) {
t++;
cash *= 1 + stage.growth;
const factor = (1 + p.rate) ** -t;
const presentValue = cash * factor;
forecastPV += presentValue;
rows.push({year: p.year + t, stage: i + 1, growth: stage.growth, cash, factor, presentValue});
}
});
const terminalCash = cash * (1 + p.terminal);
const terminalValue = terminalCash / (p.rate - p.terminal);
const terminalPV = terminalValue / (1 + p.rate) ** years;
const operatingValue = forecastPV + terminalPV;
const equityValue = operatingValue + adjustment;
const perShare = equityValue / p.shares;
const entryPrice = perShare * (1 - p.margin);
const terminalWeight = terminalPV / operatingValue;
const result = {years, rows, terminalCash, terminalValue, forecastPV, terminalPV, operatingValue,
adjustment, equityValue, perShare, entryPrice, terminalWeight};
if (![terminalCash, terminalValue, forecastPV, terminalPV, operatingValue, equityValue, perShare,
entryPrice, terminalWeight, ...rows.flatMap(row => [row.cash, row.factor, row.presentValue])].every(Number.isFinite)) {
throw new Error('参数超出有效计算范围,请缩小参数');
}
return result;
}
// The price bands are user-defined discounts/premiums around this DCF scenario.
export function calculateDCFPriceRange({perShare, margin, premium}) {
if (!Number.isFinite(perShare) || perShare <= 0) throw new Error('无正的每股股权价值,无法划分股价区间');
if (!Number.isFinite(margin) || margin < 0 || margin >= 1) throw new Error('低估折价须大于等于 0% 且小于 100%');
if (!Number.isFinite(premium) || premium < 0 || premium > 10) throw new Error('高估溢价阈值须为 0%—1000%');
const low = perShare * (1 - margin);
const high = perShare * (1 + premium);
if (![low, high].every(Number.isFinite)) throw new Error('区间超出有效数值范围');
return {low, high, perShare};
}
export function classifyDCFPrice(price, range) {
if (!Number.isFinite(price) || price <= 0) throw new Error('当前股价须为大于 0 的有效数字');
const zone = price < range.low ? '低估' : price > range.high ? '高估' : '合理';
return {zone, deviation: price / range.perShare - 1};
}
+36
View File
@@ -0,0 +1,36 @@
const fmt = value => new Intl.NumberFormat('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(value);
/** Pure option builder; the calculator owns chart mounting, resizing and disposal. */
export function buildDCFPriceOption({range, price, colors, width, reducedMotion = false}) {
const {low, high, perShare} = range;
const max = Math.max(high * 1.3, perShare * 1.6, price === undefined ? 0 : price * 1.2);
if (!Number.isFinite(max)) throw new Error('股价超出有效图表范围');
const plotWidth = Math.max(1, width - 40);
const label = (text, amount) => ({show: amount / max * plotWidth > 40, position: 'inside', formatter: text, color: colors.ink, fontSize: 12});
const series = [
{name: `低估:低于 ${fmt(low)} 元`, type: 'bar', stack: 'zone', data: [low], barWidth: 42,
itemStyle: {color: colors.low}, label: label('低估', low)},
{name: `合理:${fmt(low)}—${fmt(high)} 元`, type: 'bar', stack: 'zone', data: [high - low],
itemStyle: {color: colors.fair}, label: label('合理', high - low),
markLine: {silent: true, symbol: ['none', 'none'], lineStyle: {type: 'dashed', color: colors.accent, width: 1.5},
label: {show: true, position: 'end', formatter: `DCF ${fmt(perShare)}`, align: perShare / max < .18 ? 'left' : 'center', color: colors.ink, fontSize: 12},
data: [{xAxis: perShare}]}},
{name: `高估:高于 ${fmt(high)} 元`, type: 'bar', stack: 'zone', data: [max - high],
itemStyle: {color: colors.high}, label: label('高估', max - high)},
];
if (price !== undefined) series.push({name: '当前股价', type: 'scatter', data: [[price, 0]], symbol: 'triangle', symbolSize: 13,
symbolOffset: [0, 31], itemStyle: {color: colors.accent}, z: 6,
label: {show: true, position: price / max < .12 ? 'right' : 'bottom', distance: 5, formatter: fmt(price), color: colors.ink, fontSize: 12}});
return {
animation: !reducedMotion, animationDuration: 0, animationDurationUpdate: 260,
grid: {left: 16, right: 24, top: 45, bottom: 65},
tooltip: {trigger: 'item', confine: true, backgroundColor: colors.paper, borderColor: colors.line,
textStyle: {color: colors.ink, fontSize: 12}, formatter: params => params.seriesType === 'scatter' ? `当前股价:${fmt(price)} 元/股` : params.seriesName},
xAxis: {type: 'value', min: 0, max, splitNumber: width < 400 ? 3 : 5, name: '股价(元/股)', nameLocation: 'middle', nameGap: 42,
nameTextStyle: {color: colors.muted, fontSize: 11}, axisLine: {show: true, lineStyle: {color: colors.line}},
axisTick: {show: false}, splitLine: {show: false},
axisLabel: {color: colors.muted, fontSize: 11, formatter: value => Number(value).toFixed(0), hideOverlap: true}},
yAxis: {type: 'category', data: ['价格区间'], axisLine: {show: false}, axisTick: {show: false}, axisLabel: {show: false}},
series,
};
}
+99
View File
@@ -0,0 +1,99 @@
<div id="stock-return-calculator" class="return-calculator" aria-label="目标收益反推增长率计算器">
<div class="calc-heading">
<h2>目标收益反推增长率</h2>
<div data-company></div>
<div class="text-small text-muted" data-context></div>
</div>
<div class="viz-controls">
<label class="form-label">计算方式
<select class="form-select" data-field="mode">
<option value="reverse">反推所需盈利增速</option>
<option value="forward">计算预期年化回报</option>
<option value="buy">反推最高买入价</option>
</select>
</label>
<label class="form-label">持有年限
<select class="form-select" data-field="years" aria-label="持有年限"></select>
</label>
</div>
<div class="viz-controls">
<label class="form-label"><span data-price-label>买入价格(元)</span>
<input class="form-control" data-field="price" type="number" min="0.01" step="any" required>
</label>
<label class="form-label"><span data-profit-label>基期归母净利润(亿元)</span>
<input class="form-control" data-field="profit" type="number" min="0.01" step="any" required>
</label>
<label class="form-label"><span data-pe-label>退出PE(倍)</span>
<input class="form-control" data-field="pe" type="number" min="0.01" step="any" required>
</label>
<label class="form-label">目标年化收益率(%)
<input class="form-control" data-field="target" type="number" min="-99.99" max="1000" step="any" required>
</label>
<label class="form-label">当前总股本(亿股)
<input class="form-control" data-field="shares" type="number" min="0.01" step="any" required>
</label>
<label class="form-label">安全边际(%,按目标回报价格打折)
<input class="form-control" data-field="safetyMargin" type="number" min="0" max="99.99" step="any" required>
</label>
<label class="form-label" data-growth-field hidden>预计利润年增长率(%)
<input class="form-control" data-field="growth" type="number" min="-99.99" max="1000" step="any" required>
</label>
</div>
<details data-advanced>
<summary>分红、股本、币种与基期</summary>
<div class="calc-advanced">
<div class="viz-controls">
<label class="form-label"><span data-dividend-label>首年每股现金股息(元)</span>
<input class="form-control" data-field="dividend" type="number" min="0" step="any" required>
</label>
<label class="form-label">每股股息年增长率(%)
<input class="form-control" data-field="dividendGrowth" type="number" min="-99.99" max="1000" step="any" required>
</label>
<label class="form-label">股本年变化率(%,负值为净减少)
<input class="form-control" data-field="dilution" type="number" min="-99.99" max="1000" step="any" required>
</label>
<label class="form-label">利润基期年份
<input class="form-control" data-field="baseYear" type="number" min="1900" max="2200" step="1" required>
</label>
<label class="form-label">股价/股息币种
<select class="form-select" data-field="tradeCurrency">
<option value="CNY">人民币 CNY</option><option value="HKD">港元 HKD</option><option value="USD">美元 USD</option><option value="OTHER">其他</option>
</select>
</label>
<label class="form-label">财报利润币种
<select class="form-select" data-field="profitCurrency">
<option value="CNY">人民币 CNY</option><option value="HKD">港元 HKD</option><option value="USD">美元 USD</option><option value="OTHER">其他</option>
</select>
</label>
<label class="form-label"><span data-fx-label>1单位股价币种=财报币种</span>
<input class="form-control" data-field="fx" type="number" min="0.000001" step="any" required>
</label>
</div>
</div>
</details>
<div class="calc-actions"><button type="button" class="export-button" data-reset>恢复示例参数</button><span class="text-small text-muted" data-save-status>参数自动保存在本机浏览器</span></div>
<p class="calc-message text-small text-muted" data-assumptions></p>
<div data-error role="alert" class="text-destructive" hidden></div>
<div data-results class="calc-section" aria-live="polite" aria-atomic="true">
<div class="viz-grid">
<div class="card viz-stat">
<div data-main-label>所需归母利润年复合增长率</div>
<div class="viz-stat-value tabular-nums" data-main-value>—</div>
</div>
<div class="card viz-stat">
<div data-terminal-label>目标年所需归母利润</div>
<div class="viz-stat-value tabular-nums" data-terminal-value>—</div>
</div>
<div class="card viz-stat">
<div data-exit-label>所需退出价</div>
<div class="viz-stat-value tabular-nums" data-exit-value>—</div>
</div>
</div>
<div class="text-small text-muted" data-result-detail></div>
</div>
<div class="calc-notes text-small text-muted">
<p>所需增速是达到目标回报的门槛,不是企业盈利预测。退出 PE 是用户假设,本模型依赖未来市场估值,不等于企业内在价值。</p>
<p>每年末收取股息,末年同时收到股息和退出款;股息按独立增速计算,不自动与利润联动,不计股息再投资和交易税费。股本增加会摊薄每股盈利。</p>
<p>安全边际按股息与退出款的总现值打折,20% 表示打八折。币种切换不会自动换算已填金额,跨币种请填写有来源日期的固定汇率;本工具不联网获取行情或财报。</p>
</div>
</div>
+144
View File
@@ -0,0 +1,144 @@
// UI and formulas adapted from the stock-return-valuation skill (v1.2.0).
import template from './stock-return-calculator.html?raw';
import * as math from './stock-return-math.js';
export const valuationMenu = {
id: 'valuation-tools', name: '估值工具', kind: 'tools', chartCatalog: {},
tabs: [
{id: 'dcf', name: 'DCF 估值', chartRefs: []},
{id: 'stock-return-valuation', name: '收益反推', chartRefs: []},
],
};
const STORAGE_KEY = 'smart-fin-chart:stock-return-valuation:v1';
let memoryState;
const example = {
model: {company: '通用股票估值 · 教学示例', base_year: 2026, years: 3, price: 220,
base_profit_100m: 860, shares_100m: 46.27, exit_pe: 13, target_return: 0.13,
growth_rate: 0.10, safety_margin: 0, annual_dividend_per_share: 0,
dividend_growth_rate: 0, annual_dilution_rate: 0},
view: {mode: 'reverse', fx: 1, trade_currency: 'CNY', profit_currency: 'CNY',
context: '默认参数仅为情景假设,不是实时行情、已披露财报或机构预测。请替换为你的研究数据。'},
};
export function renderStockReturnCalculator(container) {
container.innerHTML = template;
const root = container.querySelector('.return-calculator');
const fields = Object.fromEntries(Array.from(root.querySelectorAll('[data-field]')).map(el => [el.dataset.field, el]));
const q = name => root.querySelector('[data-' + name + ']');
const currencyName = {CNY:'人民币',HKD:'港元',USD:'美元',OTHER:'其他币种'};
const format = n => new Intl.NumberFormat('zh-CN', {maximumFractionDigits:2, minimumFractionDigits:2}).format(n);
const model = example.model, view = example.view, fx = view.fx;
for (let n = 1; n <= 30; n++) fields.years.add(new Option(n + '年', n));
const defaults = {
mode:view.mode, years:model.years, price:model.price / fx, profit:model.base_profit_100m, pe:model.exit_pe,
target:model.target_return * 100, shares:model.shares_100m, growth:model.growth_rate * 100,
safetyMargin:(model.safety_margin || 0) * 100,
dividend:model.annual_dividend_per_share / fx, dividendGrowth:model.dividend_growth_rate * 100,
dilution:model.annual_dilution_rate * 100, baseYear:model.base_year,
tradeCurrency:view.trade_currency, profitCurrency:view.profit_currency, fx:fx
};
q('company').textContent = model.company || '股票估值';
q('context').textContent = view.context || model.data_as_of || '';
function read() {
const raw = {};
for (const [name, field] of Object.entries(fields)) {
if (['mode','tradeCurrency','profitCurrency'].includes(name)) raw[name] = field.value;
else {
if (!field.value.trim()) throw new Error('请填写完整参数');
raw[name] = Number(field.value);
if (!Number.isFinite(raw[name])) throw new Error('参数必须是有效数字');
}
}
if (!['reverse','forward','buy'].includes(raw.mode)) throw new Error('请选择计算方式');
if (!(raw.fx > 0)) throw new Error('汇率必须大于0');
if (raw.tradeCurrency === raw.profitCurrency && Math.abs(raw.fx - 1) > 1e-10) throw new Error('币种相同时,请将汇率设为1');
return raw;
}
function normalize(raw) {
return {company:model.company, base_year:raw.baseYear, years:raw.years, price:raw.price * raw.fx,
base_profit_100m:raw.profit, shares_100m:raw.shares, exit_pe:raw.pe, target_return:raw.target / 100,
growth_rate:raw.growth / 100, safety_margin:raw.safetyMargin / 100, annual_dividend_per_share:raw.dividend * raw.fx,
dividend_growth_rate:raw.dividendGrowth / 100, annual_dilution_rate:raw.dilution / 100};
}
function updateLabels() {
const tc = currencyName[fields.tradeCurrency.value], pc = currencyName[fields.profitCurrency.value];
const targetYear = Number(fields.baseYear.value) + Number(fields.years.value);
q('price-label').textContent = '买入价格(' + tc + ')';
q('profit-label').textContent = fields.baseYear.value + '年归母净利润(亿' + pc + ')';
q('pe-label').textContent = (Number.isFinite(targetYear) ? targetYear + '年' : '') + '退出PE(倍)';
q('dividend-label').textContent = '首年每股现金股息(' + tc + ')';
q('fx-label').textContent = '1' + tc + '=多少' + pc;
q('growth-field').hidden = fields.mode.value === 'reverse';
}
function render() {
updateLabels();
try {
const raw = read(), p = normalize(raw), r = math.calculate(p), rev = r.required_growth_with_safety_margin;
const tc = currencyName[raw.tradeCurrency], pc = currencyName[raw.profitCurrency], endYear = raw.baseYear + raw.years;
const isReverse = raw.mode === 'reverse';
let main, profit, exit;
if (isReverse) {
main = rev.required_growth_rate === null ? '无需最低增速' : format(rev.required_growth_rate * 100) + '%';
profit = rev.required_growth_rate === null ? null : rev.required_terminal_profit_100m;
exit = rev.required_growth_rate === null ? null : rev.required_exit_price / raw.fx;
} else {
main = raw.mode === 'forward' ? format(r.holding_period_irr * 100) + '%' : format(r.max_purchase_price_with_safety_margin / raw.fx) + ' ' + tc;
profit = r.future_years[r.future_years.length-1].profit_100m;
exit = r.exit_price / raw.fx;
}
q('main-label').textContent = isReverse ? '所需归母利润年复合增长率' : raw.mode === 'forward' ? '预期税前年化IRR' : '含安全边际的最高买入价';
q('terminal-label').textContent = endYear + '年' + (isReverse ? '所需' : '预计') + '归母利润';
q('exit-label').textContent = isReverse ? '所需退出价' : '预计退出价';
q('main-value').textContent = main;
q('terminal-value').textContent = profit === null ? '—' : format(profit) + ' 亿' + pc;
q('exit-value').textContent = exit === null ? '—' : format(exit) + ' ' + tc;
const baseline = r.required_growth.required_growth_rate;
q('result-detail').textContent = isReverse ?
(rev.required_growth_rate === null ? rev.message : '已计入安全边际 ' + format(raw.safetyMargin) + '%') +
' · 不加安全边际时:' + (baseline === null ? '无需最低增速' : format(baseline * 100) + '%') :
'折扣前最高买入价 ' + format(r.max_purchase_price_at_target_return / raw.fx) + tc +
' · 折扣后 ' + format(r.max_purchase_price_with_safety_margin / raw.fx) + tc +
' · 当前价格隐含安全边际 ' + format(r.margin_of_safety_at_purchase * 100) + '%' +
' · ' + (r.meets_safety_margin ? '满足' : '未满足') + '设定的 ' + format(raw.safetyMargin) + '%';
if (isReverse && baseline !== null) {
const rows = math.schedule(p, baseline);
const verified = math.irr(p.price, rows.map(row => row.dividend_per_share), rows.at(-1).eps * p.exit_pe);
q('result-detail').textContent += ' · 无边际情景正向 IRR 验证:' + format(verified * 100) + '%';
}
q('assumptions').textContent = (raw.dividend === 0 ? '不计分红' : '首年股息 ' + format(raw.dividend) + tc + ',每股股息年增 ' + format(raw.dividendGrowth) + '%') + ' · 股本年变化 ' + format(raw.dilution) + '% · 税费前 · 汇率固定';
q('error').hidden = true; q('results').hidden = false;
root.dataset.status = 'ready';
} catch (error) {
q('error').textContent = error.message || '参数无效'; q('error').hidden = false; q('results').hidden = true;
root.dataset.status = 'invalid';
}
}
function save() {
// Save raw input, including incomplete edits, so navigation and resize never lose it.
const parameters = Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.value]));
const saved = {version: 1, parameters, advanced: q('advanced').open};
memoryState = saved;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
q('save-status').textContent = '参数已自动保存在本机浏览器';
} catch {
q('save-status').textContent = '浏览器存储不可用,参数仅在本次页面中保留';
}
}
let saved = memoryState;
if (!saved) {
try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch { /* Storage may be unavailable. */ }
}
const values = {...defaults, ...(saved?.version === 1 ? saved.parameters : {})};
for (const [key, field] of Object.entries(fields)) field.value = values[key];
q('advanced').open = Boolean(saved?.advanced);
render();
root.addEventListener('input', () => { save(); render(); });
root.addEventListener('change', () => { save(); render(); });
q('advanced').addEventListener('toggle', save);
q('reset').addEventListener('click', () => {
for (const [key, field] of Object.entries(fields)) field.value = defaults[key];
save();
render();
});
}
+71
View File
@@ -0,0 +1,71 @@
/* Mirrors scripts/valuation.py. All amounts reaching the engine use one currency. */
function num(x, name, positive) {
if (typeof x !== 'number' || !Number.isFinite(x) || (positive && x <= 0)) throw new Error(name + '必须为' + (positive ? '正数' : '有限数值'));
return x;
}
function validate(p) {
['price', 'base_profit_100m', 'shares_100m', 'exit_pe'].forEach(k => num(p[k], k, true));
if (!Number.isInteger(p.years) || p.years < 1 || p.years > 30) throw new Error('持有年限须为1—30年的整数');
if (!Number.isInteger(p.base_year) || p.base_year < 1900 || p.base_year > 2200) throw new Error('基期年份须为1900—2200的整数');
['growth_rate', 'target_return', 'dividend_growth_rate', 'annual_dilution_rate'].forEach(k => {
const x = num(p[k], k, false);
if (x <= -1 || x > 10) throw new Error('增长率和收益率须大于−100%且不超过1000%');
});
if (num(p.annual_dividend_per_share, '首年股息', false) < 0) throw new Error('首年股息不能为负');
const margin = num(p.safety_margin === undefined ? 0 : p.safety_margin, '安全边际', false);
if (margin < 0 || margin >= 1) throw new Error('安全边际须大于等于0%且小于100%');
}
function schedule(p, growth) {
return Array.from({length: p.years}, (_, i) => {
const t = i + 1, profit = p.base_profit_100m * (1 + growth) ** t;
const shares = p.shares_100m * (1 + p.annual_dilution_rate) ** t;
return {year: p.base_year + t, profit_100m: profit, shares_100m: shares, eps: profit / shares,
dividend_per_share: p.annual_dividend_per_share * (1 + p.dividend_growth_rate) ** i};
});
}
function pv(rate, dividends, exit) {
return dividends.reduce((s, d, i) => s + d / (1 + rate) ** (i + 1), 0) + exit / (1 + rate) ** dividends.length;
}
function irr(price, dividends, exit) {
let low = -.999999999, high = 10;
if (pv(high, dividends, exit) > price) throw new Error('回报率超过模型上限1000%');
for (let i = 0; i < 150; i++) {
const m = (low + high) / 2;
if (pv(m, dividends, exit) > price) low = m; else high = m;
}
return (low + high) / 2;
}
function calculate(p) {
validate(p);
const rows = schedule(p, p.growth_rate), last = rows[rows.length - 1];
const dividends = rows.map(x => x.dividend_per_share), exit = last.eps * p.exit_pe;
const pvDiv = dividends.reduce((s, d, i) => s + d / (1 + p.target_return) ** (i + 1), 0);
function reverseForPrice(price) {
const requiredExit = (price - pvDiv) * (1 + p.target_return) ** p.years;
const requiredProfit = requiredExit * last.shares_100m / p.exit_pe;
return requiredExit <= 0 ? {required_growth_rate: null, message: '股息现值已覆盖所需现值,无最低退出盈利要求。'} : {
required_growth_rate: (requiredProfit / p.base_profit_100m) ** (1 / p.years) - 1,
required_terminal_profit_100m: requiredProfit, required_terminal_eps: requiredProfit / last.shares_100m,
required_exit_price: requiredExit, pv_dividends_per_share: pvDiv
};
}
const margin = p.safety_margin === undefined ? 0 : p.safety_margin;
const reverse = reverseForPrice(p.price), buffered = reverseForPrice(p.price / (1-margin));
const maxPrice = pv(p.target_return, dividends, exit), marginPrice = maxPrice * (1-margin);
const result = {base_eps: p.base_profit_100m / p.shares_100m, base_pe: p.price * p.shares_100m / p.base_profit_100m,
future_years: rows, exit_price: exit, holding_period_irr: irr(p.price, dividends, exit),
required_growth: reverse, required_growth_with_safety_margin: buffered,
max_purchase_price_at_target_return: maxPrice, max_purchase_price_with_safety_margin: marginPrice,
margin_of_safety_at_purchase: 1-p.price/maxPrice, meets_safety_margin: p.price <= marginPrice + 1e-12*maxPrice};
[result.base_pe, result.exit_price, result.holding_period_irr, result.max_purchase_price_at_target_return,
...rows.flatMap(x => [x.profit_100m, x.shares_100m, x.eps, x.dividend_per_share])].forEach(x => {
if (!Number.isFinite(x)) throw new Error('计算超出有效数值范围,请缩小参数');
});
for (const r of [reverse, buffered]) {
if (r.required_growth_rate !== null && ![r.required_growth_rate, r.required_terminal_profit_100m,
r.required_exit_price, r.required_terminal_eps].every(Number.isFinite)) throw new Error('无法反推增长率,请缩小参数');
}
return result;
}
export { calculate, validate, schedule, pv, irr };
+99 -4
View File
File diff suppressed because one or more lines are too long
+74
View File
@@ -88,6 +88,80 @@
{ "id": "price-shareholder", "name": "价格与股东回报", "chartRefs": ["moutai-flying-price", "moutai-price-adjustment", "moutai-dividend"] }
]
},
{
"id": "catl",
"name": "宁德时代",
"chartCatalog": {
"catl-unit-economics": { "key": "catl-unit-economics", "title": "动力电池单 Wh 经济性", "csv": "data/catl/catl_unit_economics.csv", "height": 700, "filename": "宁德时代动力电池单Wh经济性" },
"catl-volume-gp": { "key": "catl-volume-gp", "title": "动力电池销量与单 Wh 毛利", "csv": "data/catl/catl_unit_economics.csv", "height": 700, "filename": "宁德时代销量与单Wh毛利" },
"peer-install": { "key": "peer-install", "title": "Top5 动力电池装车量走势", "csv": "data/catl/catl_peer_install.csv", "height": 700, "filename": "Top5动力电池装车量走势" },
"peer-utilization": { "key": "peer-utilization", "title": "Top5 动力电池产能利用率", "csv": "data/catl/catl_peer_utilization.csv", "height": 700, "filename": "Top5动力电池产能利用率" },
"peer-share": { "key": "peer-share", "title": "Top5 动力电池全球市占率走势", "csv": "data/catl/catl_peer_install_share.csv", "height": 700, "filename": "Top5动力电池全球市占率走势" },
"peer-gp-per-wh": { "key": "peer-gp-per-wh", "title": "Top6 动力电池单 Wh 毛利走势", "csv": "data/catl/catl_peer_gp_per_wh.csv", "height": 720, "filename": "Top6动力电池单Wh毛利走势" },
"peer-asp": { "key": "peer-asp", "title": "Top6 动力电池 ASP 对比", "csv": "data/catl/catl_peer_asp.csv", "height": 700, "filename": "Top6动力电池ASP对比" },
"peer-margin": { "key": "peer-margin", "title": "Top6 毛利率与营业利润率", "csv": "data/catl/catl_peer_margin.csv", "height": 700, "filename": "Top6毛利率与营业利润率" },
"peer-2026h1": { "key": "peer-2026h1", "title": "2026H1 单 Wh 毛利与盈利能力", "csv": "data/catl/catl_peer_2026h1.csv", "height": 700, "filename": "2026H1单Wh毛利与盈利能力" },
"peer-gp-gap": { "key": "peer-gp-gap", "title": "宁德 vs 二线厂均值:单 Wh 毛利差距", "csv": "data/catl/catl_peer_gp_gap.csv", "height": 700, "filename": "宁德vs二线厂单Wh毛利差距" },
"peer-gp-change": { "key": "peer-gp-change", "title": "单 Wh 毛利:2026H1 vs 2023", "csv": "data/catl/catl_peer_gp_change.csv", "height": 700, "filename": "单Wh毛利2026H1对比2023" },
"global-cr": { "key": "global-cr", "title": "全球装车量集中度 CR3 / CR5 / CR10", "csv": "data/catl/global_top10_share.csv", "height": 700, "filename": "全球动力电池装车量集中度" },
"global-top10": { "key": "global-top10", "title": "2026H1 全球装车量前十与同比增速", "csv": "data/catl/global_top10_2026h1.csv", "height": 720, "filename": "2026H1全球装车量前十与同比增速" },
"global-region-share": { "key": "global-region-share", "title": "中日韩厂商全球份额结构", "csv": "data/catl/global_region_share.csv", "height": 700, "filename": "中日韩厂商全球份额结构" }
},
"tabs": [
{ "id": "unit-economics", "name": "单位经济", "chartRefs": ["catl-unit-economics", "catl-volume-gp"] },
{ "id": "peer", "name": "同业对比", "chartRefs": ["peer-install", "peer-utilization", "peer-share", "global-cr", "global-top10", "global-region-share", "peer-gp-per-wh", "peer-asp", "peer-margin", "peer-2026h1", "peer-gp-gap", "peer-gp-change"] }
]
},
{
"id": "sweet-spot",
"name": "甜蜜击球区",
"chartCatalog": {
"sweet-catl-price-pe": { "key": "sweet-catl-price-pe", "title": "股价 → 2026E PE 对照", "csv": "data/sweetspot/catl_price_pe.csv", "height": 740, "filename": "宁德时代股价PE对照" },
"sweet-catl-net-cash": { "key": "sweet-catl-net-cash", "title": "净现金让 PE 便宜了多少", "csv": "data/sweetspot/catl_price_pe.csv", "height": 740, "filename": "宁德时代净现金与PE" },
"sweet-catl-any-price": { "key": "sweet-catl-any-price", "title": "任意股价试算:三年预期年化收益", "csv": "data/sweetspot/catl_price_returns.csv", "height": 740, "filename": "宁德时代任意股价试算" },
"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": "宁德时代",
"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"]
}
]
},
{
"id": "brk",
"name": "巴菲特致股东信",
+3 -1
View File
@@ -9,7 +9,9 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node test/regress.mjs"
"test": "node test/regress.mjs && npm run test:valuation",
"test:valuation": "node test/stock_return_valuation.mjs && node test/dcf_valuation.mjs && node test/dcf_crosscheck.mjs",
"test:param": "node test/sweet_spot_param.mjs"
},
"keywords": [
"echarts",
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""甜蜜击球区 · 宁德时代(300750):股价 → 估值指标 投影表生成器
数据层定位:本脚本是 data/sweetspot/*.csv 的唯一生成入口(CSV 是运行时投影,
禁止手工编辑)。上游底稿是资料库节点「宁德时代_股价PE对照表」
(https://www.workbuddy.cn/space/d/EsAWMaLKt4ECKPDvESKKvT,2026-09-15 版)。
计算基准(改这几个常量就能整表重算):
总股本 46.27 亿股
净现金 2,263.8 亿元 (每股 48.93 元)
2026E 归母 悲观 900 / 中性 964 / 乐观 1,000 亿元(65 家机构一致预期)
分红率 50%(2023–2025 年实际值)
现价 316.21 元(2026-09-15 收盘)
2028E 目标 1,400 亿 × 20x → 605 元(老唐法三年后合理估值,用于试算年化收益)
无风险利率 1.68%(10 年期国债,仅用于股息率倍数的脚注说明)
精度例外:PE 与股息率保留两位小数。CLAUDE.md 默认一位,但 PE 一位会把
15.18x 与 15.2x 揉成同一个读数,而「现价到底落在哪个区间」正是这张表要回答的
问题(买入区上沿 15.5x 与合理区下沿贴得极近),故按行业惯例取两位。
用法:
python3 scripts/build_sweet_spot_catl.py
"""
from pathlib import Path
SHARES = 46.27 # 亿股
NET_CASH = 2263.8 # 亿元
NOW = 316.21 # 元
PAYOUT = 0.50
NI = {"bear": 900.0, "base": 964.0, "bull": 1000.0}
TARGET_2028 = 605.0 # 元(2028E 1,400 亿 × 20x ÷ 46.27 亿股)
OUT_DIR = Path(__file__).resolve().parent.parent / "data" / "sweetspot"
def price_grid(top, step=20, keys=()):
"""等距网格 + 关键价位去重后升序,保证关键价位本身就是数据点(可被打标签)。"""
prices = {float(p) for p in range(220, top + 1, step)}
prices.update(float(k) for k in keys)
return sorted(prices)
def pct(value):
return value * 100
def main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
header = (
"# 由 scripts/build_sweet_spot_catl.py 生成,请勿手工编辑。\n"
"# 口径:总股本 46.27 亿股 · 净现金 2,263.8 亿元(每股 48.93 元)· "
"2026E 归母 900/964/1,000 亿元 · 分红率 50% · 现价 316.21 元(2026-09-15)\n"
"# PE 与股息率保留两位小数(见脚本 docstring 的精度例外说明)\n"
)
# ---------- 1) 股价 → PE 对照(图 1、图 2 共用) ----------
rows = price_grid(700, 20, (265, 290, 303, NOW, 378, 605, 617))
lines = [
"price,market_cap,pe_bear,pe_base,pe_bull,pe_ex_cash,dividend_yield,net_cash_pct"
]
for px in rows:
cap = px * SHARES
lines.append(
",".join([
f"{px:.2f}",
f"{cap:.1f}",
f"{cap / NI['bear']:.2f}",
f"{cap / NI['base']:.2f}",
f"{cap / NI['bull']:.2f}",
f"{(cap - NET_CASH) / NI['base']:.2f}",
f"{pct(NI['base'] * PAYOUT / cap):.2f}",
f"{pct(NET_CASH / cap):.1f}",
])
)
path = OUT_DIR / "catl_price_pe.csv"
path.write_text(header + "\n".join(lines) + "\n", encoding="utf-8")
print(f"{path.relative_to(OUT_DIR.parent.parent)} {len(rows)} 行")
# ---------- 2) 股价 → 试算(图 3) ----------
# 价格区间与表 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"
]
for px in rows:
cap = px * SHARES
dy = NI["base"] * PAYOUT / cap
cagr = (TARGET_2028 / px) ** (1 / 3) - 1
# 总收益按 (1 + 价差年化) × (1 + 股息率) − 1:股息以买入价口径年年复投,
# 与资料库试算器的「未含分红」口径并列展示,两者差额即分红的贡献。
lines.append(
",".join([
f"{px:.2f}",
f"{cap:.1f}",
f"{cap / NI['base']:.2f}",
f"{pct(dy):.2f}",
f"{pct(cagr):.1f}",
f"{pct((1 + cagr) * (1 + dy) - 1):.1f}",
])
)
path = OUT_DIR / "catl_price_returns.csv"
path.write_text(header + "\n".join(lines) + "\n", encoding="utf-8")
print(f"{path.relative_to(OUT_DIR.parent.parent)} {len(rows)} 行")
if __name__ == "__main__":
main()
+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()
+89
View File
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { calculateDCFCrosscheck } from '../js/tools/dcf-crosscheck-math.js';
import { calculate as calculateReturn } from '../js/tools/stock-return-math.js';
const dcf = {basis: 'fcfe', fcf: 100, year: 2026, rate: .1, terminal: .02, shares: 10,
debt: 100, assets: 0, minority: 0, margin: .2,
stages: [{years: 3, growth: .12}, {years: 4, growth: .08}, {years: 3, growth: .04}]};
const assumptions = {price: 138, profitMode: 'manual', profit: 125, conversion: .8,
profitGrowth: .08, target: .13, years: 5, peLow: 9, pe: 12, peHigh: 15,
dividend: 0, dividendGrowth: 0, dilution: 0};
const near = (a, b) => assert.ok(Math.abs(a - b) <= 1e-8 * Math.max(1, Math.abs(b)), `${a} ≠ ${b}`);
const result = calculateDCFCrosscheck(dcf, assumptions);
assert.equal(result.dcf.perShare.toFixed(2), '203.46');
assert.equal(result.rows.length, 2);
for (const row of result.rows) {
for (const scenario of row.scenarios) {
const standalone = calculateReturn({base_year: dcf.year, base_profit_100m: assumptions.profit,
shares_100m: dcf.shares, price: row.price, years: 5, exit_pe: scenario.pe, growth_rate: .08,
target_return: .13, annual_dividend_per_share: 0, dividend_growth_rate: 0, annual_dilution_rate: 0, safety_margin: dcf.margin});
near(scenario.irr, standalone.holding_period_irr);
near(scenario.requiredGrowth, standalone.required_growth_with_safety_margin.required_growth_rate);
near(scenario.requiredGrowthWithoutMargin, standalone.required_growth.required_growth_rate);
near(scenario.maxPrice, standalone.max_purchase_price_at_target_return);
near(scenario.maxPriceWithMargin, standalone.max_purchase_price_with_safety_margin);
}
}
assert.equal(result.dcfCondition, true);
assert.equal(result.returnCondition, false);
near(result.returnCeiling, 125 / 10 * 1.08 ** 5 * 12 / 1.13 ** 5 * .8);
near(result.jointCeiling, Math.min(result.dcf.entryPrice, result.returnCeiling));
// Safety margin changes the growth hurdle and price ceiling, while actual IRR stays unchanged.
const noMargin = calculateDCFCrosscheck({...dcf, margin: 0}, assumptions);
near(result.returnCeiling, noMargin.returnCeiling * .8);
near(result.rows[0].base.irr, noMargin.rows[0].base.irr);
near(result.rows[0].base.requiredGrowthWithoutMargin, noMargin.rows[0].base.requiredGrowth);
assert.ok(result.rows[0].base.requiredGrowth > noMargin.rows[0].base.requiredGrowth);
const estimated = calculateDCFCrosscheck(dcf, {...assumptions, profitMode: 'estimate', profit: NaN});
near(estimated.profit, 125);
near(estimated.rows[0].base.irr, result.rows[0].base.irr);
const changedCash = calculateDCFCrosscheck({...dcf, fcf: 120}, assumptions);
near(changedCash.profit, 125);
near(changedCash.rows[0].base.irr, result.rows[0].base.irr);
assert.notEqual(changedCash.dcf.perShare, result.dcf.perShare);
near(calculateDCFCrosscheck({...dcf, fcf: 120}, {...assumptions, profitMode: 'estimate'}).profit, 150);
const distribution = calculateDCFCrosscheck(dcf, {...assumptions, dividend: 100});
assert.equal(distribution.rows[0].base.requiredGrowth, null);
assert.equal(distribution.growthGap, null);
assert.equal(calculateDCFCrosscheck(dcf, {...assumptions, profitGrowth: .2}).returnCondition, true);
// A scenario can reach the actual IRR target yet fail the requested price safety margin.
const targetOnly = calculateDCFCrosscheck(dcf, {...assumptions, price: 110});
assert.equal(targetOnly.rows[0].base.meetsTarget, true);
assert.equal(targetOnly.returnCondition, false);
// End-to-end round trips: DCF forward price -> standalone reverse growth -> DCF price.
// Exercise zero/nonzero margins, payouts, dilution, negative growth and long holdings.
for (const patch of [{}, {dividend: 2, dividendGrowth: .03, dilution: .02},
{profitGrowth: -.08, dilution: -.03, years: 3}, {years: 30}, {target: -.1}]) {
for (const margin of [0, .2, .5]) {
const a = {...assumptions, ...patch};
const p = {...dcf, margin};
const cross = calculateDCFCrosscheck(p, a);
const input = {base_year: p.year, base_profit_100m: a.profit, shares_100m: p.shares,
price: cross.returnCeiling, years: a.years, exit_pe: a.pe, growth_rate: a.profitGrowth,
target_return: a.target, annual_dividend_per_share: a.dividend,
dividend_growth_rate: a.dividendGrowth, annual_dilution_rate: a.dilution, safety_margin: margin};
const reversed = calculateReturn(input);
near(reversed.required_growth_with_safety_margin.required_growth_rate, a.profitGrowth);
near(reversed.max_purchase_price_with_safety_margin, cross.returnCeiling);
const atCeiling = calculateDCFCrosscheck(p, {...a, price: cross.returnCeiling});
near(atCeiling.rows[0].base.requiredGrowth, a.profitGrowth);
assert.equal(atCeiling.returnCondition, true);
const fromReverse = calculateDCFCrosscheck(p, {...a, profitGrowth: reversed.required_growth_with_safety_margin.required_growth_rate});
near(fromReverse.returnCeiling, cross.returnCeiling);
}
}
// Dividends may cover the actual price but not the price buffered by the safety margin.
const dividendCovered = calculateDCFCrosscheck(dcf, {...assumptions, dividend: 40});
assert.equal(dividendCovered.rows[0].base.requiredGrowthWithoutMargin, null);
assert.notEqual(dividendCovered.rows[0].base.requiredGrowth, null);
const diluted = calculateDCFCrosscheck(dcf, {...assumptions, dilution: .02, dividend: 2, dividendGrowth: .03});
assert.ok(diluted.rows.every(row => row.scenarios.every(scenario => Number.isFinite(scenario.irr))));
assert.throws(() => calculateDCFCrosscheck({...dcf, basis: 'fcff'}, {...assumptions, profitMode: 'estimate'}));
assert.throws(() => calculateDCFCrosscheck({...dcf, basis: 'fcff', debt: 1e9}, assumptions));
for (const patch of [{profit: 0}, {profitMode: 'invalid'}, {price: 0}, {profitGrowth: -1}, {years: 0},
{years: 3.5}, {target: -1}, {peLow: 15}, {peHigh: 10}, {pe: 0}, {dividend: -1}, {dilution: -1}]) {
assert.throws(() => calculateDCFCrosscheck(dcf, {...assumptions, ...patch}));
}
for (const conversion of [0, -1, Infinity, NaN]) {
assert.throws(() => calculateDCFCrosscheck(dcf, {...assumptions, profitMode: 'estimate', conversion}));
}
console.log('✓ DCF 与收益反推的含边际增速一致、价格/增速往返、实际 IRR、利润来源及边界校验');
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { calculateDCF } from '../js/tools/dcf-math.js';
const base = {basis: 'fcff', fcf: 100, year: 2026, rate: .1, terminal: .02, shares: 10,
debt: 100, assets: 0, minority: 0, margin: .2,
stages: [{years: 3, growth: .12}, {years: 4, growth: .08}, {years: 3, growth: .04}]};
const near = (actual, expected) => assert.ok(Math.abs(actual - expected) <= 1e-8 * Math.max(1, Math.abs(expected)), `${actual} ≠ ${expected}`);
const r = calculateDCF(base);
assert.equal(r.rows.length, 10);
near(r.rows[0].cash, 112);
near(r.rows[3].cash, 100 * 1.12 ** 3 * 1.08);
near(r.rows.at(-1).cash, 100 * 1.12 ** 3 * 1.08 ** 4 * 1.04 ** 3);
near(r.forecastPV, r.rows.reduce((sum, row) => sum + row.cash / 1.1 ** (row.year - base.year), 0));
near(r.terminalPV, r.rows.at(-1).cash * 1.02 / .08 / 1.1 ** 10);
near(r.equityValue, r.operatingValue - 100);
near(r.perShare, r.equityValue / 10);
near(r.entryPrice, r.perShare * .8);
// Each company's share count changes per-share valuation, never the company's total DCF.
const diluted = calculateDCF({...base, shares: 20});
near(diluted.equityValue, r.equityValue);
near(diluted.perShare, r.perShare / 2);
near(diluted.entryPrice, r.entryPrice / 2);
for (const years of [1, 10, 30]) {
near(calculateDCF({...base, terminal: 0, stages: [{years, growth: 0}]}).operatingValue, 1000);
near(calculateDCF({...base, stages: [{years, growth: .02}]}).operatingValue, 1275);
}
const fcfe = calculateDCF({...base, basis: 'fcfe', debt: NaN, assets: NaN, minority: NaN});
near(fcfe.equityValue, r.operatingValue);
near(fcfe.adjustment, 0);
const adjusted = calculateDCF({...base, debt: -50, assets: 20, minority: 10});
near(adjusted.equityValue, r.operatingValue + 60);
const noEquity = calculateDCF({...base, debt: 1e6});
assert.ok(noEquity.equityValue < 0);
const shrinking = calculateDCF({...base, stages: [{years: 10, growth: -.05}]});
assert.ok(shrinking.rows.at(-1).cash < 100);
for (const patch of [{shares: 0}, {shares: -1}, {shares: NaN}, {fcf: 0}, {fcf: Infinity}, {year: 2026.5},
{basis: 'invalid'}, {terminal: .1}, {terminal: -1}, {rate: -1}, {margin: 1}, {margin: -.1},
{stages: []}, {stages: [{years: 31, growth: .1}]}, {stages: [{years: 1.5, growth: .1}]},
{stages: [{years: 1, growth: -1}]}, {stages: [{years: 1, growth: Infinity}]}, {debt: NaN},
{stages: [{years: 30, growth: 1e300}]}]) assert.throws(() => calculateDCF({...base, ...patch}));
console.log('✓ DCF 分段复利、永续现金流、折现时点、股权调整、公司股本换算及无效输入');
// Price-zone thresholds are inclusive at both ends of the fair band.
const { calculateDCFPriceRange, classifyDCFPrice } = await import('../js/tools/dcf-math.js');
const { buildDCFPriceOption } = await import('../js/tools/dcf-price-chart.js');
const range = calculateDCFPriceRange({perShare: 100, margin: .2, premium: .2});
assert.deepEqual(range, {low: 80, high: 120, perShare: 100});
for (const [price, zone] of [[79.99, '低估'], [80, '合理'], [100, '合理'], [120, '合理'], [120.01, '高估']]) {
assert.equal(classifyDCFPrice(price, range).zone, zone);
}
near(classifyDCFPrice(80, range).deviation, -.2);
const pointRange = calculateDCFPriceRange({perShare: 100, margin: 0, premium: 0});
assert.equal(classifyDCFPrice(100, pointRange).zone, '合理');
for (const patch of [{perShare: 0}, {perShare: -1}, {perShare: NaN}, {margin: -.1}, {margin: 1}, {premium: -.1}, {premium: Infinity}]) {
assert.throws(() => calculateDCFPriceRange({perShare: 100, margin: .2, premium: .2, ...patch}));
}
for (const price of [0, -1, NaN, Infinity]) assert.throws(() => classifyDCFPrice(price, range));
const colors = {ink:'#333', muted:'#777', line:'#eee', paper:'#fff', accent:'#654', low:'#ded', fair:'#eed', high:'#edd'};
const unquoted = buildDCFPriceOption({range, colors, width: 320});
assert.equal(unquoted.series.some(series => series.type === 'scatter'), false);
const quoted = buildDCFPriceOption({range, price: 200, colors, width: 320});
assert.ok(quoted.xAxis.max > 200);
assert.deepEqual(quoted.series.at(-1).data, [[200, 0]]);
near(quoted.series.slice(0, 3).reduce((sum, series) => sum + series.data[0], 0), quoted.xAxis.max);
console.log('✓ DCF 低估/合理/高估边界、折溢价、可选股价及图表标记');
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { calculate } from '../js/tools/stock-return-math.js';
const base = {price: 220, base_profit_100m: 860, shares_100m: 46.27, exit_pe: 13,
years: 3, base_year: 2026, target_return: .13, growth_rate: .1,
safety_margin: 0, annual_dividend_per_share: 0, dividend_growth_rate: 0, annual_dilution_rate: 0};
const near = (actual, expected, tolerance = 1e-8) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≠ ${expected}`);
const example = calculate(base);
near(example.required_growth.required_growth_rate, .0952, .0001);
near(example.required_growth.required_exit_price, 317.43734);
near(example.required_growth.required_terminal_profit_100m, 317.43734 * 46.27 / 13);
// Reverse/forward cash-flow consistency with dividends, dilution, negative rates and FX-normalized inputs.
for (const patch of [{}, {annual_dividend_per_share: 9.3, dividend_growth_rate: .08},
{annual_dilution_rate: .02}, {annual_dilution_rate: -.03, growth_rate: -.08},
{years: 5, target_return: .15}, {target_return: -.15},
{price: 398 * .85619, annual_dividend_per_share: 5.3 * .85619, base_profit_100m: 2300, shares_100m: 90.93, exit_pe: 15}]) {
for (const safety_margin of [0, .2, .5]) {
const input = {...base, ...patch, safety_margin};
const r = calculate(input);
const required = r.required_growth_with_safety_margin.required_growth_rate;
const forward = calculate({...input, growth_rate: required});
near(forward.max_purchase_price_with_safety_margin, input.price);
near(forward.margin_of_safety_at_purchase, safety_margin);
near(r.max_purchase_price_with_safety_margin, r.max_purchase_price_at_target_return * (1 - safety_margin));
near(calculate({...input, safety_margin: 0}).holding_period_irr, r.holding_period_irr);
const noMargin = calculate({...input, growth_rate: r.required_growth.required_growth_rate});
near(noMargin.holding_period_irr, input.target_return);
}
}
const covered = calculate({...base, annual_dividend_per_share: 100, safety_margin: .5});
assert.equal(covered.required_growth.required_growth_rate, null);
assert.notEqual(covered.required_growth_with_safety_margin.required_growth_rate, null);
assert.equal(calculate({...base, annual_dividend_per_share: 500}).required_growth_with_safety_margin.required_growth_rate, null);
for (const patch of [{price: 0}, {base_profit_100m: -1}, {shares_100m: 0}, {exit_pe: 0},
{years: 0}, {years: 3.2}, {years: 31}, {target_return: -1}, {annual_dilution_rate: -1},
{annual_dividend_per_share: -2}, {safety_margin: -1}, {safety_margin: 1}, {price: Infinity}]) {
assert.throws(() => calculate({...base, ...patch}));
}
console.log('✓ 估值示例、现金流反向/正向验证、安全边际、多币种、分红、摊薄及边界条件');
+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);