Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a46de853a0 | |||
| f646ee6718 | |||
| 3a3d19d5dd |
@@ -29,6 +29,41 @@ pnpm test # 全量,只在改了 t
|
|||||||
|
|
||||||
它会断言 `legend.top`、`grid.top`、折线 `symbolSize`/`borderWidth`,并检查 SVG 字体引号。回归测试直接遍历 `manifest.json` 的 `chartCatalog`,新增图只要完成 registry 与 manifest 登记就会被测到。
|
它会断言 `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` 都会报)。
|
**右侧纵向图例的图会报 `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。
|
**桥图/瀑布图要验算闭合。** 各项占营收比是税前口径,加总到净利率前须乘税盾 `(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`(蓝色基准、青绿色正向、珊瑚红负向);不要在图表文件里为相同财务/渠道语义重写十六进制颜色。柱状系列负责分类,折线只保留一条高辨识度强调色。
|
**正式方向:轻快消费。** 柱线组合图的现金流、利润与同比使用 `FINANCIAL_COLORS`,渠道结构使用 `CHANNEL_COLORS`,瀑布图使用 `BRIDGE_COLORS`(蓝色基准、青绿色正向、珊瑚红负向);不要在图表文件里为相同财务/渠道语义重写十六进制颜色。柱状系列负责分类,折线只保留一条高辨识度强调色。
|
||||||
|
|||||||
@@ -179,3 +179,27 @@ A:编辑 `js/theme.js` 的 `PALETTE` 对象,所有图表自动同步。
|
|||||||
## 许可
|
## 许可
|
||||||
|
|
||||||
本项目代码采用 MIT 协议,数据仅供示例。
|
本项目代码采用 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
@@ -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
@@ -53,11 +53,29 @@ button {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
border-radius: 50%;
|
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); }
|
.export-button:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
/* ── 合并队列(购物车)────────────────────────────── */
|
/* ── 合并队列(购物车)────────────────────────────── */
|
||||||
@@ -305,30 +323,109 @@ button {
|
|||||||
|
|
||||||
.content { min-width: 0; }
|
.content { min-width: 0; }
|
||||||
|
|
||||||
.tabs {
|
/* ── 页头:tab 栏 + 参数行 ─────────────────────────
|
||||||
|
sticky 只挂在容器上,里面两行一起钉住。底色比过去(.6)更实、并加一层模糊:
|
||||||
|
现在压在卡片上方的是两行内容,透过去的字会干扰读数。 */
|
||||||
|
.tab-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
z-index: 5;
|
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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
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-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; }
|
.cards { display: grid; gap: 28px; }
|
||||||
|
|
||||||
|
/* scroll-margin-top = 页头底边(sticky top 20 + 页头实测高 89)+ 18px 呼吸:
|
||||||
|
页头是 tab 栏 + 参数行两行,比过去的单行 tab 栏高约 43px,锚点跳转时标题才不会被盖住。
|
||||||
|
页头高度会随 tab 换行浮动,这里按单行 tab 的最常见形态取值。 */
|
||||||
.card {
|
.card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
|
||||||
scroll-margin-top: 84px;
|
scroll-margin-top: 128px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-bar {
|
.card-bar {
|
||||||
@@ -399,24 +496,36 @@ button {
|
|||||||
}
|
}
|
||||||
.company-sidebar .sidebar-title { display: none; }
|
.company-sidebar .sidebar-title { display: none; }
|
||||||
.company-list { display: flex; overflow-x: auto; }
|
.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; }
|
.company-button { flex: 0 0 auto; width: auto; }
|
||||||
.tabs {
|
.tab-header {
|
||||||
top: 50px;
|
top: 50px;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
margin: 0 -12px 18px;
|
margin: 0 -12px 18px;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px 7px;
|
||||||
border-width: 0 0 1px;
|
border-width: 0 0 1px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: rgba(245, 244, 241, .96);
|
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;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.tabs::-webkit-scrollbar { display: none; }
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
.tab-button { flex: 0 0 auto; }
|
.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; }
|
.card-bar { align-items: flex-start; flex-wrap: wrap; }
|
||||||
.cart-button { margin-left: auto; }
|
.cart-button { margin-left: auto; }
|
||||||
.cart-bar { left: 12px; right: 12px; transform: none; flex-wrap: wrap; }
|
.cart-bar { left: 12px; right: 12px; transform: none; flex-wrap: wrap; }
|
||||||
@@ -424,3 +533,53 @@ button {
|
|||||||
.chart { min-width: 560px; }
|
.chart { min-width: 560px; }
|
||||||
.card { overflow-x: auto; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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,,
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,16 @@ import {
|
|||||||
moutaiBaseWineVolumeChart, moutaiFlyingPriceChart, moutaiPriceAdjustmentChart,
|
moutaiBaseWineVolumeChart, moutaiFlyingPriceChart, moutaiPriceAdjustmentChart,
|
||||||
moutaiTonPriceChart, moutaiDividendChart, moutaiDistributorsChart,
|
moutaiTonPriceChart, moutaiDividendChart, moutaiDistributorsChart,
|
||||||
} from './moutai.js';
|
} 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 函数。 */
|
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
|
||||||
export const chartRegistry = {
|
export const chartRegistry = {
|
||||||
@@ -114,6 +124,26 @@ export const chartRegistry = {
|
|||||||
'moutai-ton-price': moutaiTonPriceChart,
|
'moutai-ton-price': moutaiTonPriceChart,
|
||||||
'moutai-dividend': moutaiDividendChart,
|
'moutai-dividend': moutaiDividendChart,
|
||||||
'moutai-distributors': moutaiDistributorsChart,
|
'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) {
|
export function getChartBuilder(key) {
|
||||||
|
|||||||
@@ -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
@@ -1,12 +1,15 @@
|
|||||||
import { loadCSV } from './csv.js';
|
import { loadCSV } from './csv.js';
|
||||||
import { getChartBuilder } from './charts/index.js';
|
import { getChartBuilder } from './charts/index.js';
|
||||||
import { downloadCanvas, mergeImages } from './merge.js';
|
import { downloadCanvas, mergeImages } from './merge.js';
|
||||||
|
import { renderStockReturnCalculator, valuationMenu } from './tools/stock-return-calculator.js';
|
||||||
|
import { renderDCFCalculator } from './tools/dcf-calculator.js';
|
||||||
import {
|
import {
|
||||||
createAppShell,
|
createAppShell,
|
||||||
renderAnchors,
|
renderAnchors,
|
||||||
renderCards,
|
renderCards,
|
||||||
renderCartBar,
|
renderCartBar,
|
||||||
renderCompanies,
|
renderCompanies,
|
||||||
|
renderTabControls,
|
||||||
renderTabs,
|
renderTabs,
|
||||||
showChartError,
|
showChartError,
|
||||||
showMergePreview,
|
showMergePreview,
|
||||||
@@ -15,6 +18,12 @@ import {
|
|||||||
const instances = new Map();
|
const instances = new Map();
|
||||||
const activeViews = new Map();
|
const activeViews = new Map();
|
||||||
const chartRenderVersions = 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 }
|
// 合并队列:instanceId → { instanceId, title, filename, dataUrl }
|
||||||
// dataUrl 是加购(及后续每次重渲染)时的快照,切换 tab 后实例被 dispose 也能照常导出
|
// dataUrl 是加购(及后续每次重渲染)时的快照,切换 tab 后实例被 dispose 也能照常导出
|
||||||
const cart = new Map();
|
const cart = new Map();
|
||||||
@@ -25,6 +34,8 @@ let shell;
|
|||||||
let state;
|
let state;
|
||||||
let renderVersion = 0;
|
let renderVersion = 0;
|
||||||
let cacheToken = Date.now();
|
let cacheToken = Date.now();
|
||||||
|
let paramTimer;
|
||||||
|
let disposeTool;
|
||||||
|
|
||||||
function validateManifest(value) {
|
function validateManifest(value) {
|
||||||
if (!Array.isArray(value?.companies) || value.companies.length === 0) {
|
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 (!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`);
|
if (!Array.isArray(tab.chartRefs)) throw new Error(`公司 ${company.id} 的 ${tab.id} 缺少 chartRefs`);
|
||||||
tabIds.add(tab.id);
|
tabIds.add(tab.id);
|
||||||
|
validateControls(company.id, tab);
|
||||||
const refs = new Set();
|
const refs = new Set();
|
||||||
tab.chartRefs.forEach((chartId) => {
|
tab.chartRefs.forEach((chartId) => {
|
||||||
if (!company.chartCatalog[chartId]) throw new Error(`栏目 ${tab.id} 引用了不存在的图表:${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) {
|
function getCompany(companyId) {
|
||||||
|
if (companyId === valuationMenu.id) return valuationMenu;
|
||||||
return manifest.companies.find((company) => company.id === companyId) || manifest.companies[0];
|
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];
|
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) {
|
function chartsForTab(company, tab) {
|
||||||
|
const params = paramValues(company, tab);
|
||||||
return tab.chartRefs.map((chartId) => ({
|
return tab.chartRefs.map((chartId) => ({
|
||||||
...company.chartCatalog[chartId],
|
...company.chartCatalog[chartId],
|
||||||
id: chartId,
|
id: chartId,
|
||||||
instanceId: `${company.id}--${tab.id}--${chartId}`,
|
instanceId: `${company.id}--${tab.id}--${chartId}`,
|
||||||
viewKey: `${company.id}--${chartId}`,
|
viewKey: `${company.id}--${chartId}`,
|
||||||
|
params,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,22 +157,39 @@ function chartSource(chart) {
|
|||||||
return chart.views[view];
|
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) {
|
async function renderChart(chart, version, chartVersion) {
|
||||||
const el = document.getElementById(`chart-${chart.instanceId}`);
|
const el = document.getElementById(`chart-${chart.instanceId}`);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const source = chartSource(chart);
|
const source = chartSource(chart);
|
||||||
const view = chart.views
|
|
||||||
? activeViews.get(chart.viewKey) || chart.defaultView
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
try {
|
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;
|
if (version !== renderVersion || chartVersion !== chartRenderVersions.get(chart.instanceId) || !document.body.contains(el)) return;
|
||||||
|
|
||||||
const instance = echarts.init(el, null, { renderer: 'canvas' });
|
const instance = echarts.init(el, null, { renderer: 'canvas' });
|
||||||
instances.set(chart.instanceId, instance);
|
instances.set(chart.instanceId, instance);
|
||||||
const { width, height } = el.getBoundingClientRect();
|
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);
|
captureSnapshot(chart);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (version === renderVersion) showChartError(chart.instanceId, error.message);
|
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) {
|
function renderSingleChart(chart) {
|
||||||
const previous = instances.get(chart.instanceId);
|
const previous = instances.get(chart.instanceId);
|
||||||
previous?.dispose();
|
previous?.dispose();
|
||||||
@@ -297,15 +406,31 @@ function renderCurrent() {
|
|||||||
const company = getCompany(state.companyId);
|
const company = getCompany(state.companyId);
|
||||||
const tab = getTab(company, state.tabId);
|
const tab = getTab(company, state.tabId);
|
||||||
const charts = chartsForTab(company, tab);
|
const charts = chartsForTab(company, tab);
|
||||||
|
shell.layout.classList.toggle('tools-layout', company.kind === 'tools');
|
||||||
const version = ++renderVersion;
|
const version = ++renderVersion;
|
||||||
|
// 整屏重渲染会带上最新参数把图都画一遍,这里再触发一次参数刷新纯属重复劳动
|
||||||
|
clearTimeout(paramTimer);
|
||||||
|
disposeTool?.();
|
||||||
|
disposeTool = undefined;
|
||||||
disposeAll();
|
disposeAll();
|
||||||
|
|
||||||
renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => {
|
renderCompanies(shell.companyNav, [...manifest.companies, valuationMenu], company.id, (companyId) => {
|
||||||
updateHash({ companyId });
|
updateHash({ companyId });
|
||||||
});
|
});
|
||||||
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
|
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
|
||||||
updateHash({ companyId: company.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, {
|
renderCards(shell.cards, charts, activeViews, {
|
||||||
onExport: exportPNG,
|
onExport: exportPNG,
|
||||||
isCarted: (instanceId) => cart.has(instanceId),
|
isCarted: (instanceId) => cart.has(instanceId),
|
||||||
@@ -335,6 +460,7 @@ async function start() {
|
|||||||
shell = createAppShell(document.getElementById('app'), {
|
shell = createAppShell(document.getElementById('app'), {
|
||||||
onReload: () => {
|
onReload: () => {
|
||||||
cacheToken = Date.now();
|
cacheToken = Date.now();
|
||||||
|
rowCache.clear();
|
||||||
renderCurrent();
|
renderCurrent();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -358,6 +484,8 @@ window.addEventListener('hashchange', () => {
|
|||||||
let resizeTimer;
|
let resizeTimer;
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
if (!shell) return;
|
if (!shell) return;
|
||||||
|
// Calculator layout is responsive CSS; rebuilding it would interrupt focused inputs.
|
||||||
|
if (getCompany(state.companyId).kind === 'tools') return;
|
||||||
clearTimeout(resizeTimer);
|
clearTimeout(resizeTimer);
|
||||||
resizeTimer = setTimeout(renderCurrent, 200);
|
resizeTimer = setTimeout(renderCurrent, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
+56
@@ -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) {
|
export function footnote(text) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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]}},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -88,6 +88,80 @@
|
|||||||
{ "id": "price-shareholder", "name": "价格与股东回报", "chartRefs": ["moutai-flying-price", "moutai-price-adjustment", "moutai-dividend"] }
|
{ "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",
|
"id": "brk",
|
||||||
"name": "巴菲特致股东信",
|
"name": "巴菲特致股东信",
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"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": [
|
"keywords": [
|
||||||
"echarts",
|
"echarts",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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、利润来源及边界校验');
|
||||||
@@ -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 低估/合理/高估边界、折溢价、可选股价及图表标记');
|
||||||
@@ -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('✓ 估值示例、现金流反向/正向验证、安全边际、多币种、分红、摊薄及边界条件');
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user