diff --git a/README.md b/README.md index 678297b..2d294ce 100644 --- a/README.md +++ b/README.md @@ -188,18 +188,16 @@ A:编辑 `js/theme.js` 的 `PALETTE` 对象,所有图表自动同步。 单独运行估值检查:`npm run test:valuation`;`npm test` 同时运行图表回归与估值检查。 -「DCF 估值」Tab(`#/valuation-tools/dcf`)采用分段工作台,可设置 1—5 个增长阶段,每段输入年数与现金流年增长率,总预测期不超过 30 年。支持 FCFF/WACC 与 FCFE/股权资本成本两种口径、永续增长终值、股权价值调整和逐年折现明细。 +「DCF 估值」Tab(`#/valuation-tools/dcf`)采用独立的现金流工作台,可设置 1—5 个增长阶段,每段输入年数与现金流年增长率,总预测期不超过 30 年。支持 FCFF/WACC 与 FCFE/股权资本成本两种口径、永续增长终值、股权价值调整和逐年折现明细。 总股本在基础参数区单独填写,单位为亿股;每股价值 = 股权价值(亿元)÷ 当前总股本(亿股)。每家公司的股本、股本日期、现金流和增长假设独立保存在当前浏览器,也可新增公司。真实公司初次使用时,现金流与股本留空,需自行核实后填写;教学示例的 10 亿股仅为假设。当前模型使用人民币金额与预测期内不变的股本。 公司以按钮形式展示,点击即可切换;当前股价、股价日期和区间阈值也按公司自动保存到 LocalStorage,兼容原有 DCF 本地记录。股价需要手动输入,不自动请求行情。未填或填错股价不影响已完成的 DCF 计算,也不会保留上次股价标记。 -ECharts 区间图以 DCF 每股价值为基准,默认低于其 80% 为低估、高于其 120% 为高估,两端边界均包含在合理区间内。「区间设置」可分别修改低估折价(同时作为安全边际)与高估溢价阈值。这是当前假设下的自定义价格分区,不是独立的投资结论;无正股权价值时不显示区间。 +ECharts 区间图以 DCF 每股价值为基准,默认低于其 80% 为低估、高于其 120% 为高估,两端边界均包含在合理区间内。股价旁直接展示低估折价(同时作为安全边际)与高估溢价阈值,可随时修改。安全边际价、低估价格及折价使用绿色,溢价与高估价格使用红色。这是当前假设下的自定义价格分区,不是独立的投资结论;无正股权价值时不显示区间。 -「目标收益交叉验证」共用当前公司的股价、股本、基期和 DCF 参考价。输入独立的归母净利润及预计利润增速后,可比较现价、DCF 含安全边际参考价两种买入情景的所需利润 CAGR 与预计 IRR,并查看悲观/基准/乐观退出 PE 情景。计算复用独立「目标收益反推增长率」工具的数学引擎。 +首屏顶部并列展示 DCF 每股内在价值、安全边际参考价、当前股价及相对内在价值的折溢价。下方左侧编辑基础参数、分段增长率和股权调整,右侧查看现金流现值、终值与股权价值拆解。逐年现金流图表始终独立展示,计算明细可折叠展开;小屏幕自动改为上下排列。 -首屏顶部并列展示当前股价、按现价计算的预计年化 IRR、达到目标收益所需的利润年增速;后两项采用基准退出 PE,与下方现价情景结果同步。修改参数或切换公司后即时更新,参数不完整或无效时清除旧值。 +DCF 页只计算现金流估值。预计 IRR、利润增长门槛和退出 PE 情景使用单独的「收益反推」Tab。现有公司的 DCF 记录沿用原有 LocalStorage 键,旧版额外字段不会参与 DCF 计算。 -FCFE 模式可选择以用户提供的基期现金转化率(FCFE ÷ 归母净利润)估算利润;该值会明确标注为估算,不作为独立财报验证。FCFF 模式要求单独填写归母净利润。真实公司的利润、转化率和预计利润增速默认留空,新增验证参数仍按公司保存在原有 LocalStorage 记录中。 - -双买入价格表按各自实际买入价反推增速及计算 IRR,不额外叠加安全边际。「共同参考买入上限」取 DCF 参考价和目标收益模型参考价的较低值,两边各应用一次安全边际。分红按独立路径计算;交叉验证中的股本变化仅影响收益模型,DCF 仍采用固定股本假设。验证参数缺失或无效时只隐藏验证结果,不影响有效的 DCF 结果。 +标题栏右侧的问号打开「估值概念说明」,包含 FCFF、FCFE、口径选择、折现率、永续终值、安全边际与区间说明,支持目录定位和键盘关闭。 diff --git a/css/dcf.css b/css/dcf.css index 63d76df..10db764 100644 --- a/css/dcf.css +++ b/css/dcf.css @@ -1,6 +1,6 @@ -/* Share the application palette; keep semantic valuation bands subdued. */ +/* DCF workbench uses the application's paper, grey and brown palette. */ .dcf-calculator { - --df-bg:var(--surface); + --df-bg:var(--bg); --df-paper:var(--surface); --df-ink:var(--ink); --df-muted:#75716c; @@ -12,81 +12,209 @@ --df-chart-secondary:#cbbba9; --df-error:#a63f32; --df-error-bg:#fff1ee; - --df-low:#e8ece5; - --df-fair:#eee9df; - --df-high:#eee5e1; - --df-good:#64715e; + --df-low:#c4e6d1; + --df-fair:#f1e0b3; + --df-high:#efc7bd; + --df-good:#287c54; --df-fair-ink:#786950; - --df-high-ink:#866d65; + --df-high-ink:#b94c42; --df-warning-bg:#f6f2eb; - background:var(--df-bg); - border:1px solid var(--df-line); - color:var(--df-ink); - border-radius:16px; + display:grid; grid-template-columns:minmax(0,1fr); gap:20px; - padding:28px; + min-width:0; + padding:26px; + background:var(--df-bg); + color:var(--df-ink); + border:1px solid var(--df-line); + border-radius:12px; 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); + line-height:1.65; +} +.dcf-calculator * {box-sizing:border-box;} +.dcf-calculator>* {min-width:0;} +.dcf-calculator [hidden] {display:none!important;} +.dcf-calculator :is(h2,h3,h4,p) {margin:0;} +.dcf-calculator h2 {font-size:26px;font-weight:500;letter-spacing:-.7px;} +.dcf-calculator h3 {font-size:14px;font-weight:500;} +.dcf-calculator h4 {display:flex;align-items:center;gap:9px;font-size:12px;font-weight:500;} +.dcf-calculator h4>span {font-size:11px;color:var(--df-accent);} +.dcf-calculator b,.dcf-calculator strong {font-weight:500;font-variant-numeric:tabular-nums;} +.dcf-calculator .df-muted {font-size:11px;color:var(--df-muted);line-height:1.7;} +.dcf-calculator .df-header {display:flex;align-items:center;justify-content:space-between;gap:16px;} +.dcf-calculator .df-eyebrow {font-size:11px;letter-spacing:1.5px;color:var(--df-accent);margin-bottom:4px;} +.dcf-calculator .df-subtitle {margin-top:5px;font-size:12px;color:var(--df-muted);} +.dcf-calculator .df-header-actions {display:flex;align-items:center;gap:12px;flex-shrink:0;} +.dcf-calculator .df-header-mark {font-size:11px;white-space:nowrap;color:var(--df-muted);} +.dcf-calculator button { + font:inherit; 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)} + background:var(--df-paper); + border:1px solid var(--df-line); + border-radius:6px; + padding:7px 11px; + cursor:pointer; + 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:2px solid var(--df-accent);outline-offset:3px;} +.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-profile-bar {display:flex;flex-wrap:wrap;align-items:center;gap:10px;} +.dcf-calculator .df-company-buttons {display:flex;flex-wrap:wrap;gap:10px;min-width:0;} +.dcf-calculator .df-company-chip {position:relative;display:inline-flex;min-width:0;max-width:100%;} +.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:500;} +.dcf-calculator [data-new-company] {border-style:dashed;background:transparent;} +.dcf-calculator .df-save-status {margin-left:auto;color:var(--df-muted);font-size:11px;} +.dcf-calculator .df-new-company {display:flex;flex-wrap:wrap;align-items:end;gap:10px;} +.dcf-calculator .df-new-company label {flex:1;min-width:160px;} +.dcf-calculator .df-company-error {color:var(--df-error);font-size:12px;} +.dcf-calculator label {display:grid;align-content:start;gap:6px;min-width:0;font-size:11px;color:var(--df-muted);} +.dcf-calculator input,.dcf-calculator select { + min-width:0; + width:100%; + height:39px; + padding:8px 10px; + font:inherit; + font-size:13px; + font-variant-numeric:tabular-nums; + color:var(--df-ink); + background:var(--df-field); + border:1px solid var(--df-line); + border-radius:5px; +} +.dcf-calculator input:focus,.dcf-calculator select:focus {border-color:var(--df-accent);background:var(--df-paper);} +.dcf-calculator input::placeholder {font-size:11px;color:var(--muted);} +.dcf-calculator details {padding:0;border:0;} +.dcf-calculator summary {font-size:11px;color:var(--df-accent);cursor:pointer;} +.dcf-calculator .df-overview {display:grid;grid-template-columns:1.2fr 1fr 1fr 1.2fr;background:var(--df-paper);border:1px solid var(--df-line);border-radius:10px;overflow:hidden;} +.dcf-calculator .df-stat {display:flex;flex-direction:column;gap:8px;min-width:0;padding:20px 18px;} +.dcf-calculator .df-stat+.df-stat {border-left:1px solid var(--df-line);} +.dcf-calculator .df-stat h3 {font-size:11px;color:var(--df-muted);font-weight:400;} +.dcf-calculator .df-stat-value {font-size:30px;font-weight:500;letter-spacing:-1px;line-height:1.4;font-variant-numeric:tabular-nums;overflow-wrap:anywhere;} +.dcf-calculator .df-stat-main {background:var(--df-soft);} +.dcf-calculator .df-stat-main .df-stat-value {color:var(--df-accent);} +.dcf-calculator [data-buy],.dcf-calculator [data-zone-low],.dcf-calculator [data-deviation][data-price-relation=discount] {color:var(--df-good);} +.dcf-calculator [data-zone-high],.dcf-calculator [data-deviation][data-price-relation=premium] {color:var(--df-high-ink);} +.dcf-calculator .df-stat-position .df-stat-value {display:flex;align-items:center;min-height:42px;font-size:22px;letter-spacing:-.5px;} +.dcf-calculator :is(.df-market,.df-form,.df-results,.df-cashflow-section) {min-width:0;padding:20px;background:var(--df-paper);border:1px solid var(--df-line);border-radius:10px;} +.dcf-calculator .df-section-head,.dcf-calculator .df-market-head {display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;} +.dcf-calculator .df-section-head {margin-bottom:16px;} +.dcf-calculator .df-market-head p {margin-top:3px;} +.dcf-calculator .df-zone-badge {padding:3px 8px;font-size:11px;white-space:nowrap;border-radius:4px;} +.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-price-controls {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:end;gap:16px;margin-top:14px;} +.dcf-calculator .df-price-fields {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;min-width:0;} +.dcf-calculator .df-interval-fields {min-width:0;} +.dcf-calculator .df-price-chart {width:100%;min-width:0;height:180px;overflow:hidden;} +.dcf-calculator .df-zone-keys {display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;} +.dcf-calculator .df-zone-key {display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:5px;padding:8px 10px;background:var(--df-field);border-radius:5px;font-size:11px;} +.dcf-calculator .df-zone-key strong {font-size:11px;} +.dcf-calculator .df-zone-dot {display:inline-block;width:5px;height:5px;margin-right:6px;border-radius:50%;background:var(--df-good);} +.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-market-message,.dcf-calculator .df-interval-note {margin-top:12px;} +.dcf-calculator .df-market .df-error {margin-top:12px;} +.dcf-calculator .df-layout {display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);align-items:start;gap:20px;} +.dcf-calculator .df-parameter-group {border-top:1px solid var(--df-line);padding-top:18px;margin-top:18px;} +.dcf-calculator .df-parameter-group .df-section-head {margin-bottom:12px;} +.dcf-calculator .df-fields {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;} +.dcf-calculator .df-parameter-group>h4 {margin-bottom:12px;} +.dcf-calculator .df-parameter-group>.df-muted {margin-top:10px;} +.dcf-calculator .df-quiet-button {border:0;background:transparent;padding:2px 0;} +.dcf-calculator .df-stages {display:grid;gap:8px;} +.dcf-calculator .df-stage-row {display:grid;grid-template-columns:minmax(55px,1fr) 65px minmax(70px,1fr) 22px;align-items:center;gap:8px;padding:10px;background:var(--df-field);border:1px solid var(--df-line);border-radius:6px;} +.dcf-calculator .df-stage-row input {height:34px;padding:6px;background:var(--df-paper);} +.dcf-calculator .df-stage-row button {padding:2px;border:0;background:transparent;color:var(--df-muted);font-size:17px;} +.dcf-calculator .df-stage-name {min-width:0;font-size:12px;} +.dcf-calculator .df-stage-name small {display:block;font-size:11px;color:var(--df-muted);} +.dcf-calculator .df-stage-key {font-size:11px;color:var(--df-muted);} +.dcf-calculator .df-total {margin-top:10px;} +.dcf-calculator .df-result-line {display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:9px 0;font-size:12px;} +.dcf-calculator .df-result-line>* {min-width:0;overflow-wrap:anywhere;} +.dcf-calculator .df-result-line b {text-align:right;} +.dcf-calculator .df-sum {border-top:1px solid var(--df-line);margin:6px 0;} +.dcf-calculator .df-equity {border-top:1px solid var(--df-line);margin-top:7px;padding-top:13px;} +.dcf-calculator .df-equity b {font-size:22px;} +.dcf-calculator .df-shares-note {text-align:right;} +.dcf-calculator .df-value-per-share {display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;padding:12px;margin-top:14px;background:var(--df-soft);border-radius:6px;font-size:12px;} +.dcf-calculator .df-value-per-share strong {font-size:24px;color:var(--df-accent);overflow-wrap:anywhere;} +.dcf-calculator .df-value-per-share small {font-size:11px;} +.dcf-calculator .df-composition {display:flex;gap:2px;height:7px;margin:23px 0 8px;border-radius:4px;overflow:hidden;background:var(--df-line);} +.dcf-calculator .df-segment-a {background:var(--df-accent);} +.dcf-calculator .df-segment-b {background:var(--df-chart-secondary);} +.dcf-calculator .df-legend {display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;font-size:11px;color:var(--df-muted);} +.dcf-calculator .df-terminal-note {padding:12px;margin-top:18px;background:var(--df-field);border-radius:6px;font-size:11px;color:var(--df-muted);} +.dcf-calculator .df-cashflow-chart {height:300px;width:100%;min-width:0;} +.dcf-calculator .df-cashflow-section h3 {margin-bottom:4px;} +.dcf-calculator .df-table-section {margin-top:16px;padding-top:14px;border-top:1px solid var(--df-line);} +.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:11px;} +.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-foot {display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;color:var(--df-muted);font-size:11px;} +.dcf-calculator .df-error {color:var(--df-error);background:var(--df-error-bg);border:1px solid var(--df-high);border-radius:6px;padding:12px 14px;font-size:12px;} +.dcf-calculator .df-warning {color:var(--df-fair-ink);background:var(--df-warning-bg);border-radius:6px;padding:12px 14px;font-size:12px;} +/* Keep the existing three-second dwell behavior for company deletion. */ +.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-overview,.df-market,.df-layout,.df-cashflow-section,.df-foot,.df-error,.df-warning) {display:none!important;} +@media (width>=1500px) {.tools-layout .content:has(.dcf-calculator) {max-width:1280px;}} +@media (width<=1100px) { + .dcf-calculator {padding:20px;gap:18px;} + .dcf-calculator .df-stat {padding:16px 12px;} + .dcf-calculator .df-stat-value {font-size:27px;} + .dcf-calculator .df-stat-position .df-stat-value {font-size:20px;min-height:38px;} + .dcf-calculator .df-layout {gap:16px;} + .dcf-calculator :is(.df-form,.df-results) {padding:16px;} + .dcf-calculator .df-stage-row {grid-template-columns:minmax(48px,1fr) 52px minmax(65px,1fr) 20px;gap:6px;padding:8px;} + .dcf-calculator .df-save-status {width:100%;margin-left:0;} +} +@media (width<=950px) { + .dcf-calculator .df-layout {grid-template-columns:1fr;} + .dcf-calculator .df-overview {grid-template-columns:repeat(2,minmax(0,1fr));} + .dcf-calculator .df-stat:nth-child(3) {border-left:0;border-top:1px solid var(--df-line);} + .dcf-calculator .df-stat:nth-child(4) {border-top:1px solid var(--df-line);} +} +@media (width<=680px) { + .dcf-calculator {padding:16px;gap:16px;border-radius:10px;} + .dcf-calculator h2 {font-size:23px;} + .dcf-calculator .df-header-mark {display:none;} + .dcf-calculator .df-header-actions {gap:6px;} + .dcf-calculator .df-market {padding:16px;} + .dcf-calculator .df-price-controls {grid-template-columns:minmax(0,1fr);gap:12px;} + .dcf-calculator .df-zone-keys {gap:8px;} + .dcf-calculator .df-zone-key {display:grid;padding:8px;gap:4px;} + .dcf-calculator .df-cashflow-section {padding:16px;} + .dcf-calculator .df-cashflow-chart {height:260px;} +} +@media (width<=460px) { + .dcf-calculator {padding:12px;} + .dcf-calculator input,.dcf-calculator select {font-size:16px;} + .dcf-calculator .df-fields {gap:10px;} + .dcf-calculator .df-stage-row {grid-template-columns:minmax(42px,1fr) 48px minmax(56px,1fr) 20px;gap:5px;padding:7px;} + .dcf-calculator .df-stage-name {font-size:11px;} + .dcf-calculator .df-stage-name small {font-size:11px;} + .dcf-calculator .df-stat-value {font-size:25px;} + .dcf-calculator .df-stat-position .df-stat-value {font-size:20px;min-height:35px;} +} +@media (hover:none) {.dcf-calculator .df-company-buttons .df-company-delete {opacity:1;visibility:visible;transition:none;}} +@media (pointer:coarse) { + .dcf-calculator input,.dcf-calculator select {min-height:44px;height:auto;font-size:16px;} + .dcf-calculator button:not(.df-company-delete) {min-height:44px;} + .dcf-calculator .df-help-trigger {width:44px;} +} +@media (prefers-reduced-motion:reduce) {.dcf-calculator * {transition:none!important;}} +@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;}} + +/* Concept documentation dialog. */ .dcf-calculator .df-help-dialog { width:min(860px,calc(100% - 32px)); max-width:none; @@ -184,801 +312,3 @@ .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;}} diff --git a/js/tools/dcf-calculator.html b/js/tools/dcf-calculator.html index 615eb57..c93c74a 100644 --- a/js/tools/dcf-calculator.html +++ b/js/tools/dcf-calculator.html @@ -1,99 +1,71 @@ -
-
VALUATION WORKBENCH / 估值研究

DCF 估值工作台

从现金流价值出发,用盈利增长检验回报。

参数调整 · 即时试算
-
-
- -每家公司的参数独立保存在本机浏览器 -
- - - -
DCF 每股内在价值
—
人民币/股 · 当前现金流假设
20% 安全边际参考价 — 元/股
-
-

当前股价

-
—元/股
-
填写当前股价后查看所处区间
-
-

预计年化 IRR

-
—
-
补全交叉验证参数后显示
-
-

目标收益所需利润年增速(含边际)

-
—
-
按现价与基准退出 PE 反推
-
-
-
双模型共同参考上限—元/股

填写当前股价及利润假设,比较现金流价值与目标收益门槛。

-
-
-
PRICE POSITION

现价在估值的什么位置?

以当前 DCF 假设为基准的价格区间
-
- - +
+
+
VALUATION WORKBENCH

DCF 估值工作台

从未来自由现金流,衡量今天的企业价值。

+
参数调整 · 即时试算
+
+
+
+ + 每家公司的参数独立保存在本机浏览器 +
+ + +
+

DCF 每股内在价值

—

元/股 · FCFF 口径

+

安全边际参考价

—

按设定的安全边际折价

+

当前股价

—

元/股 · 手动输入

+

现价相对内在价值

—

填写当前股价后查看所处区间

+
+
+

现价在估值的什么位置?

以当前 DCF 假设为基准的价格区间

+
+
+
+ + +

请补全现金流与股本后查看估值区间

+ +

区间由当前 DCF 假设与折溢价阈值划分,不自动获取行情。

+
+
+
+

估值假设

教学示例 · 人民币
+

01基础参数

+ + + + + + +

先折现企业现金流,再调整为股权价值。

+

02分段增长率

+

03永续经营与股权调整

+ + + + +

所有金额使用人民币,股本在预测期内保持不变。净债务与非经营资产中的超额现金避免重复计入。

+
+
+

价值从哪里来?

亿元
+
预测期现金流现值—
永续终值现值—
企业经营价值—
+
− 净债务—
+ 非经营资产净额—
− 少数股东权益—
+
股权价值—

补全总股本后换算每股价值

+
每股内在价值— 元/股
+
预测期 —永续终值 —

补全有效参数后查看价值构成

+
- - -
请补全现金流与股本后查看估值区间
- -
- 区间依据当前 DCF 假设与折溢价阈值划分,不自动获取行情 -
区间设置 -
- - -
-
-
-
-
-
01

现金流估值

企业能产生多少可分配现金流

DCF
-

基础参数

教学示例 · 人民币
-
- - - - - - -
-

分段增长率

-
-
-

永续经营与股权换算

-
- - - - - - -
-

价值拆解 /亿元

预测期现金流现值—
永续终值现值—
企业经营价值—
− 净债务 + 非经营资产 − 少数股东权益—
股权价值—
预测期现金流永续终值
- -
-
-
- - - -
逐年现金流与折现明细
年份/阶段增长率自由现金流(亿元)折现因子现值(亿元)
- -
假设示例 · 年末现金流 · 永续增长法
-

净利润不能直接代替自由现金流。FCFF 按 WACC 折现后调整为股权价值,FCFE 按股权资本成本折现。非经营资产不得与净债务中的超额现金重复计入。所有金额使用人民币,股本在预测期内保持不变,终值采用持续经营假设。

- + + +
人民币口径 · 年末现金流 · 永续增长法
diff --git a/js/tools/dcf-calculator.js b/js/tools/dcf-calculator.js index 2bdd632..630812b 100644 --- a/js/tools/dcf-calculator.js +++ b/js/tools/dcf-calculator.js @@ -3,22 +3,16 @@ 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: '', +const defaults = () => ({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'}); + premium: '20', marketPrice: '', quoteDate: ''}); 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}); + inputs: {...defaults(), ...(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() { @@ -40,32 +34,7 @@ export function renderDCFCalculator(container, companies = []) { 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) => { @@ -82,15 +51,14 @@ export function renderDCFCalculator(container, companies = []) { 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; + // Retain legacy saved fields without using them in this DCF-only interface. + active.inputs = {...active.inputs, ...Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.value]))}; + active.stages = stages.map(stage => ({...stage})); + active.detailOpen = q('detail').open; } memory = store; try { @@ -102,7 +70,7 @@ export function renderDCFCalculator(container, companies = []) { } function deleteCompany(item) { - if (!window.confirm(`删除「${item.name}」?\n该公司保存在本机的 DCF 与收益校验参数将一并删除,无法撤销。`)) return; + if (!window.confirm(`删除「${item.name}」?\n该公司保存在本机的估值参数将一并删除,无法撤销。`)) return; save(); const index = store.profiles.findIndex(profile => profile.id === item.id); if (index < 0) return; @@ -191,7 +159,9 @@ export function renderDCFCalculator(container, companies = []) { 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('deviation').textContent = '—'; + delete q('deviation').dataset.priceRelation; + q('price-position').textContent = '待补全有效的估值参数'; q('market-message').textContent = message; q('market-message').hidden = false; } @@ -210,7 +180,7 @@ export function renderDCFCalculator(container, companies = []) { q('zone-high').textContent = `> ${fmt(range.high)} 元`; q('zone-keys').hidden = false; q('market-message').hidden = true; - q('deviation').textContent = '填写当前股价后查看所处区间'; + q('price-position').textContent = '填写当前股价后查看所处区间'; let price; if (fields.marketPrice.value.trim()) { const entered = Number(fields.marketPrice.value); @@ -221,7 +191,9 @@ export function renderDCFCalculator(container, companies = []) { 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('deviation').textContent = `${position.deviation <= 0 ? '折价' : '溢价'} ${fmt(Math.abs(position.deviation) * 100)}%`; + q('deviation').dataset.priceRelation = position.deviation <= 0 ? 'discount' : 'premium'; + q('price-position').textContent = `处于${position.zone}区间`; q('market-message').hidden = true; } catch (error) { q('market-error').textContent = error.message; @@ -311,6 +283,8 @@ export function renderDCFCalculator(container, companies = []) { 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('basis-caption').textContent = `${fcfe ? 'FCFE' : 'FCFF'} 口径`; + q('basis-note').textContent = fcfe ? '直接折现股东现金流,不再重复扣减净债务。' : '先折现企业现金流,再调整为股权价值。'; q('model-note').textContent = fcfe ? 'FCFE 按股权资本成本折现,不再扣减净债务' : 'FCFF 按 WACC 折现,再调整为股权价值'; let first = 1; stages.forEach((stage, index) => { @@ -326,13 +300,17 @@ export function renderDCFCalculator(container, companies = []) { 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}; + const values = {pv: r.forecastPV, tv: r.terminalPV, ev: r.operatingValue, equity: r.equityValue, + 'debt-value': p.debt, 'assets-value': p.assets, 'minority-value': p.minority}; for (const [name, value] of Object.entries(values)) q(name).textContent = fmt(value); q('price').textContent = r.equityValue > 0 ? fmt(r.perShare) : '无正股权价值'; + q('value-per-share').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('discount-note').textContent = `内在价值打 ${fmt((1 - p.margin) * 10)} 折`; q('shares-note').textContent = `按 ${fmt(p.shares)} 亿股换算${fields.shareDate.value ? ` · 股本日期 ${fields.shareDate.value}` : ''}`; - q('terminal-share').textContent = `永续终值占比 ${fmt(r.terminalWeight * 100)}%`; + q('forecast-share').textContent = `${fmt((1 - r.terminalWeight) * 100)}%`; + q('terminal-share').textContent = `${fmt(r.terminalWeight * 100)}%`; + q('terminal-note').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)]) { @@ -352,13 +330,9 @@ export function renderDCFCalculator(container, companies = []) { 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; @@ -367,12 +341,14 @@ export function renderDCFCalculator(container, companies = []) { cashflowRows = undefined; cashflowChart?.clear(); q('cashflow-section').hidden = true; - root.querySelector('.df-output').hidden = true; - root.querySelector('.df-results').hidden = true; + for (const name of ['price', 'buy', 'value-per-share', 'pv', 'tv', 'ev', 'equity', 'debt-value', 'assets-value', 'minority-value', 'forecast-share', 'terminal-share']) q(name).textContent = '—'; + q('shares-note').textContent = '补全总股本后换算每股价值'; + q('discount-note').textContent = '按设定的安全边际折价'; + q('terminal-note').textContent = '补全有效参数后查看价值构成'; + q('composition-forecast').style.width = '0%'; + q('composition-terminal').style.width = '0%'; q('duration').textContent = '请补全有效参数后查看结果'; clearMarket('请补全有效的现金流、股本与折现参数后查看估值区间'); - lastDCFInput = undefined; - crosscheck.update(undefined); root.dataset.status = 'invalid'; } } @@ -382,7 +358,7 @@ export function renderDCFCalculator(container, companies = []) { q('company-empty').hidden = Boolean(active); if (!active) { stages = []; - lastDCFInput = chartData = cashflowRows = undefined; + chartData = cashflowRows = undefined; chart?.clear(); cashflowChart?.clear(); root.dataset.status = 'empty'; @@ -391,11 +367,10 @@ export function renderDCFCalculator(container, companies = []) { renderCompanies(); return; } - const values = {...defaults(active.id === 'example'), ...active.inputs}; + const values = {...defaults(), ...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(); @@ -407,13 +382,12 @@ export function renderDCFCalculator(container, companies = []) { 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(); - }); + for (const field of Object.values(fields)) { + const onChange = () => { update(); save(); }; + field.addEventListener('input', onChange); + if (field.tagName === 'SELECT') field.addEventListener('change', onChange); + } 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 = ''; @@ -433,12 +407,6 @@ export function renderDCFCalculator(container, companies = []) { 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); diff --git a/js/tools/dcf-crosscheck-math.js b/js/tools/dcf-crosscheck-math.js deleted file mode 100644 index acc5817..0000000 --- a/js/tools/dcf-crosscheck-math.js +++ /dev/null @@ -1,50 +0,0 @@ -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}; -} diff --git a/js/tools/dcf-crosscheck.html b/js/tools/dcf-crosscheck.html deleted file mode 100644 index 9907753..0000000 --- a/js/tools/dcf-crosscheck.html +++ /dev/null @@ -1,69 +0,0 @@ -
-
02

目标收益交叉验证

以当前价格买入,需要怎样的盈利增长

IRR
-
共用当前公司的股价、股本、基期及 DCF 参考价
-
利润与收益假设 独立于 DCF 现金流增速
-
- - - -
- -
-
-
- - -
-
- - - -
-
退出 PE 为情景假设,不是未来市场定价承诺。目标收益率不会改写 DCF 折现率。
-
-
-
- 分红与股本变化 -
- - - -
-
每年末收到股息,末年同时收到股息与退出款;股息路径独立于利润,不计再投资与税费。DCF 按固定股本计算,本区股本变化仅用于收益验证。
-
-
- - -
diff --git a/js/tools/dcf-crosscheck.js b/js/tools/dcf-crosscheck.js deleted file mode 100644 index 6748dfb..0000000 --- a/js/tools/dcf-crosscheck.js +++ /dev/null @@ -1,130 +0,0 @@ -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}; -} diff --git a/js/tools/dcf-help.html b/js/tools/dcf-help.html index f6f8d2c..3dee3f8 100644 --- a/js/tools/dcf-help.html +++ b/js/tools/dcf-help.html @@ -7,7 +7,8 @@ - + +

FCFF 看企业能为股东和债权人整体创造多少现金;FCFE 看处理完债务融资后,能留给股东多少现金。

@@ -36,13 +37,20 @@

根据手上数据的实际定义选择。切换下拉选项不会自动把 FCFF 转换为 FCFE,也不会自动换算折现率;现金流金额、预测增长率与折现率都要使用匹配的假设。

财报中的“经营活动现金流净额”还没有扣除资本开支,不能直接当作自由现金流。“经营现金流减资本开支”也需核对利息分类与借款处理,才能确定其口径。

-
-

为什么还要输入归母净利润?

-

DCF 使用自由现金流;目标收益交叉验证通过利润、股本和退出 PE 推算回报。利润和现金流会因折旧、资本开支、应收账款与存货占款等产生差异。

-
现金转化率 = FCFE ÷ 归母净利润
估算归母净利润 = FCFE ÷ 现金转化率
-

例如 FCFE 为 80 亿元、现金转化率假设为 80%,估算利润为 100 亿元。该比例是用户假设;填 100% 表示假设两者相等。

-

本页的选择限制:“共用 FCFE + 现金转化率估算”仅在基础参数选择 FCFE 时启用。FCFF 模式需手动输入归母净利润。使用独立财报利润更有利于交叉验证;共用现金流估算的利润会依赖同一套假设。

-

与“收益反推”对照:目标收益参考价和所需利润增速均计入本页设置的安全边际。两页对照时,需同时保持买入价、利润、股本、持有年限、退出 PE、目标收益率、安全边际、分红与股本变化假设一致。预计 IRR 始终按实际买入价计算。

+
+

折现率与永续终值

+

折现率将未来现金流换算为今天的价值。FCFF 对应 WACC;FCFE 对应股权资本成本。同样的现金流假设下,折现率越高,现值越低。

+

分段增长率用于显式预测期,合计最多 30 年。永续增长率用于预测期之后持续经营的现金流,须小于所用折现率。

+
预测期末终值 = 下一年自由现金流 ÷(折现率 − 永续增长率)
+

终值还需折现回基期。“价值从哪里来?”展示预测期与永续终值的现值及占比;终值占比较高时,应更谨慎地核对折现率与永续增长率。

+

自由现金流会受到资本开支、营运资金及融资活动等影响,不能直接用归母净利润替代。

+
+
+

安全边际与现价区间

+
安全边际参考价 = DCF 每股内在价值 ×(1 − 安全边际)
+

例如 DCF 每股价值为 100 元,安全边际设为 20%,参考价为 80 元。页面的区间设置同时用该折价作为低估区间上界;默认高估区间从 DCF 每股价值的 120% 以上开始。

+

低估上界与高估下界均计入合理区间。现价相对内在价值的折溢价以 DCF 每股价值为基准计算,不以安全边际参考价为基准。

+

这些区间由当前现金流假设和用户设定的阈值决定,不自动获取行情,也不代表未来股价预测。

参考:CFA Institute · Free Cash Flow Valuation(英文)

diff --git a/package.json b/package.json index d04f9eb..aa4cd93 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "vite build", "preview": "vite preview", "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:valuation": "node test/stock_return_valuation.mjs && node test/dcf_valuation.mjs", "test:param": "node test/sweet_spot_param.mjs" }, "keywords": [ diff --git a/test/dcf_crosscheck.mjs b/test/dcf_crosscheck.mjs deleted file mode 100644 index 1280106..0000000 --- a/test/dcf_crosscheck.mjs +++ /dev/null @@ -1,89 +0,0 @@ -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、利润来源及边界校验');