diff --git a/README.md b/README.md
index 688a3fd..678297b 100644
--- a/README.md
+++ b/README.md
@@ -179,3 +179,27 @@ A:编辑 `js/theme.js` 的 `PALETTE` 对象,所有图表自动同步。
## 许可
本项目代码采用 MIT 协议,数据仅供示例。
+
+## 估值工具
+
+左侧选择「估值工具」,打开「目标收益反推增长率」Tab。支持反推所需归母利润年复合增长率、计算预期年化 IRR、反推最高买入价;可调整安全边际、股息、股本变化率、基期和币种汇率。参数自动保存在当前浏览器,点击「恢复示例参数」可重置。
+
+入口:`#/valuation-tools/stock-return-valuation`。默认数字是教学情景假设,不是实时行情或财报。计算口径来自 `stock-return-valuation` 技能 v1.2.0,浏览器引擎位于 `js/tools/stock-return-math.js`,界面位于 `js/tools/stock-return-calculator.js` 和同名 HTML 模板。
+
+单独运行估值检查:`npm run test:valuation`;`npm test` 同时运行图表回归与估值检查。
+
+「DCF 估值」Tab(`#/valuation-tools/dcf`)采用分段工作台,可设置 1—5 个增长阶段,每段输入年数与现金流年增长率,总预测期不超过 30 年。支持 FCFF/WACC 与 FCFE/股权资本成本两种口径、永续增长终值、股权价值调整和逐年折现明细。
+
+总股本在基础参数区单独填写,单位为亿股;每股价值 = 股权价值(亿元)÷ 当前总股本(亿股)。每家公司的股本、股本日期、现金流和增长假设独立保存在当前浏览器,也可新增公司。真实公司初次使用时,现金流与股本留空,需自行核实后填写;教学示例的 10 亿股仅为假设。当前模型使用人民币金额与预测期内不变的股本。
+
+公司以按钮形式展示,点击即可切换;当前股价、股价日期和区间阈值也按公司自动保存到 LocalStorage,兼容原有 DCF 本地记录。股价需要手动输入,不自动请求行情。未填或填错股价不影响已完成的 DCF 计算,也不会保留上次股价标记。
+
+ECharts 区间图以 DCF 每股价值为基准,默认低于其 80% 为低估、高于其 120% 为高估,两端边界均包含在合理区间内。「区间设置」可分别修改低估折价(同时作为安全边际)与高估溢价阈值。这是当前假设下的自定义价格分区,不是独立的投资结论;无正股权价值时不显示区间。
+
+「目标收益交叉验证」共用当前公司的股价、股本、基期和 DCF 参考价。输入独立的归母净利润及预计利润增速后,可比较现价、DCF 含安全边际参考价两种买入情景的所需利润 CAGR 与预计 IRR,并查看悲观/基准/乐观退出 PE 情景。计算复用独立「目标收益反推增长率」工具的数学引擎。
+
+首屏顶部并列展示当前股价、按现价计算的预计年化 IRR、达到目标收益所需的利润年增速;后两项采用基准退出 PE,与下方现价情景结果同步。修改参数或切换公司后即时更新,参数不完整或无效时清除旧值。
+
+FCFE 模式可选择以用户提供的基期现金转化率(FCFE ÷ 归母净利润)估算利润;该值会明确标注为估算,不作为独立财报验证。FCFF 模式要求单独填写归母净利润。真实公司的利润、转化率和预计利润增速默认留空,新增验证参数仍按公司保存在原有 LocalStorage 记录中。
+
+双买入价格表按各自实际买入价反推增速及计算 IRR,不额外叠加安全边际。「共同参考买入上限」取 DCF 参考价和目标收益模型参考价的较低值,两边各应用一次安全边际。分红按独立路径计算;交叉验证中的股本变化仅影响收益模型,DCF 仍采用固定股本假设。验证参数缺失或无效时只隐藏验证结果,不影响有效的 DCF 结果。
diff --git a/css/dcf.css b/css/dcf.css
new file mode 100644
index 0000000..63d76df
--- /dev/null
+++ b/css/dcf.css
@@ -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;}}
diff --git a/css/style.css b/css/style.css
index dabbac5..c968dbd 100644
--- a/css/style.css
+++ b/css/style.css
@@ -533,3 +533,53 @@ button {
.chart { min-width: 560px; }
.card { overflow-x: auto; }
}
+
+/* 估值工具沿用图表页的纸白与棕色主题。 */
+.tools-title { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
+.app-layout.tools-layout { grid-template-columns: 184px minmax(0, 1fr); }
+.tools-layout .content { max-width: 1120px; width: 100%; }
+.chart-sidebar[hidden] { display: none; }
+.return-calculator {
+ display: grid;
+ gap: 22px;
+ min-width: 0;
+ padding: 26px;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+}
+.return-calculator [hidden] { display: none !important; }
+.return-calculator h2 { margin: 0 0 10px; font-size: 22px; font-weight: 600; }
+.return-calculator [data-company] { margin-bottom: 6px; font-size: 13px; color: var(--accent); }
+.return-calculator .text-small { font-size: 12px; line-height: 1.8; }
+.return-calculator .text-muted { color: #75716c; }
+.return-calculator .viz-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 16px; }
+.return-calculator .form-label { display: grid; align-content: start; gap: 8px; min-width: 0; font-size: 12px; line-height: 1.6; }
+.return-calculator .form-control,
+.return-calculator .form-select {
+ width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid var(--line);
+ border-radius: 6px; background: #faf9f7; color: var(--ink); font: inherit;
+ font-size: 15px; font-variant-numeric: tabular-nums;
+}
+.return-calculator .form-control:focus,
+.return-calculator .form-select:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
+.return-calculator details { padding: 14px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
+.return-calculator summary { cursor: pointer; font-size: 13px; color: var(--accent); }
+.return-calculator .calc-advanced { padding-top: 20px; }
+.return-calculator .calc-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
+.return-calculator .calc-message { margin: 0; }
+.return-calculator .text-destructive { padding: 12px; border-radius: 6px; color: #a63f32; background: #fff1ee; font-size: 13px; }
+.return-calculator .calc-section { display: grid; gap: 12px; min-width: 0; }
+.return-calculator .viz-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+.return-calculator .viz-stat { padding: 18px 14px; border: 1px solid var(--line); box-shadow: none; background: #faf9f7; font-size: 12px; overflow-wrap: anywhere; }
+.return-calculator .viz-stat:first-child { background: var(--accent-soft); border-color: var(--accent-soft); }
+.return-calculator .viz-stat-value { margin-top: 12px; font-size: clamp(18px, 1.7vw, 26px); font-weight: 600; color: var(--accent); font-variant-numeric: tabular-nums; }
+.return-calculator .calc-notes { padding-top: 8px; border-top: 1px solid var(--line); }
+.return-calculator .calc-notes p { margin: 8px 0 0; }
+@media (max-width: 680px) {
+ .return-calculator { padding: 20px 16px; gap: 20px; }
+ .return-calculator h2 { font-size: 20px; }
+ .return-calculator .viz-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; }
+ .return-calculator .viz-grid { grid-template-columns: 1fr; }
+ .return-calculator .viz-stat-value { font-size: 24px; }
+}
diff --git a/js/main.js b/js/main.js
index 44339c6..c84389e 100644
--- a/js/main.js
+++ b/js/main.js
@@ -1,6 +1,8 @@
import { loadCSV } from './csv.js';
import { getChartBuilder } from './charts/index.js';
import { downloadCanvas, mergeImages } from './merge.js';
+import { renderStockReturnCalculator, valuationMenu } from './tools/stock-return-calculator.js';
+import { renderDCFCalculator } from './tools/dcf-calculator.js';
import {
createAppShell,
renderAnchors,
@@ -33,6 +35,7 @@ let state;
let renderVersion = 0;
let cacheToken = Date.now();
let paramTimer;
+let disposeTool;
function validateManifest(value) {
if (!Array.isArray(value?.companies) || value.companies.length === 0) {
@@ -89,6 +92,7 @@ function validateControls(companyId, tab) {
}
function getCompany(companyId) {
+ if (companyId === valuationMenu.id) return valuationMenu;
return manifest.companies.find((company) => company.id === companyId) || manifest.companies[0];
}
@@ -402,18 +406,31 @@ function renderCurrent() {
const company = getCompany(state.companyId);
const tab = getTab(company, state.tabId);
const charts = chartsForTab(company, tab);
+ shell.layout.classList.toggle('tools-layout', company.kind === 'tools');
const version = ++renderVersion;
// 整屏重渲染会带上最新参数把图都画一遍,这里再触发一次参数刷新纯属重复劳动
clearTimeout(paramTimer);
+ disposeTool?.();
+ disposeTool = undefined;
disposeAll();
- renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => {
+ renderCompanies(shell.companyNav, [...manifest.companies, valuationMenu], company.id, (companyId) => {
updateHash({ companyId });
});
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
updateHash({ companyId: company.id, tabId });
});
renderTabControls(shell.params, tab.controls, paramValues(company, tab), applyTabParam);
+ if (company.kind === 'tools') {
+ if (tab.id === 'dcf') {
+ disposeTool = renderDCFCalculator(shell.cards, manifest.companies.filter(item => ['ppmt', 'moutai', 'catl'].includes(item.id)));
+ } else {
+ renderStockReturnCalculator(shell.cards);
+ }
+ shell.chartNav.hidden = true;
+ return;
+ }
+ shell.chartNav.hidden = false;
renderCards(shell.cards, charts, activeViews, {
onExport: exportPNG,
isCarted: (instanceId) => cart.has(instanceId),
@@ -467,6 +484,8 @@ window.addEventListener('hashchange', () => {
let resizeTimer;
window.addEventListener('resize', () => {
if (!shell) return;
+ // Calculator layout is responsive CSS; rebuilding it would interrupt focused inputs.
+ if (getCompany(state.companyId).kind === 'tools') return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(renderCurrent, 200);
});
diff --git a/js/tools/dcf-calculator.html b/js/tools/dcf-calculator.html
new file mode 100644
index 0000000..615eb57
--- /dev/null
+++ b/js/tools/dcf-calculator.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+
每家公司的参数独立保存在本机浏览器
+
+
+还没有估值公司,添加一家公司开始估值。
+
+DCF 每股内在价值
—
人民币/股 · 当前现金流假设
+
+
当前股价
+
—元/股
+
填写当前股价后查看所处区间
+
+
预计年化 IRR
+
—
+
补全交叉验证参数后显示
+
+
目标收益所需利润年增速(含边际)
+
—
+
按现价与基准退出 PE 反推
+
+
+双模型共同参考上限—元/股
填写当前股价及利润假设,比较现金流价值与目标收益门槛。
+
+
+
PRICE POSITION
现价在估值的什么位置?
以当前 DCF 假设为基准的价格区间
+
+
+
+
+
+
+
+
低估区间—
+
合理区间—
+
高估区间—
+
+ 请补全现金流与股本后查看估值区间
+
+
+
+
+
+
+
+逐年现金流与折现明细
| 年份/阶段 | 增长率 | 自由现金流(亿元) | 折现因子 | 现值(亿元) |
|---|
+
+
+
+
+ 浅棕为当年自由现金流,深棕为折算到基期的现值;仅展示显式预测期,不含永续终值。悬停或轻触柱形可查看年份、增长阶段及折现因子。
+
+
+净利润不能直接代替自由现金流。FCFF 按 WACC 折现后调整为股权价值,FCFE 按股权资本成本折现。非经营资产不得与净债务中的超额现金重复计入。所有金额使用人民币,股本在预测期内保持不变,终值采用持续经营假设。
+
+
diff --git a/js/tools/dcf-calculator.js b/js/tools/dcf-calculator.js
new file mode 100644
index 0000000..2bdd632
--- /dev/null
+++ b/js/tools/dcf-calculator.js
@@ -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();
+ };
+}
diff --git a/js/tools/dcf-cashflow-chart.js b/js/tools/dcf-cashflow-chart.js
new file mode 100644
index 0000000..2c09f49
--- /dev/null
+++ b/js/tools/dcf-cashflow-chart.js
@@ -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}
自由现金流:${fmt(row.cash)} 亿元
折现后现值:${fmt(row.presentValue)} 亿元
折现因子:${row.factor.toFixed(4)}
现金流增长率:${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]}},
+ ],
+ };
+}
diff --git a/js/tools/dcf-crosscheck-math.js b/js/tools/dcf-crosscheck-math.js
new file mode 100644
index 0000000..acc5817
--- /dev/null
+++ b/js/tools/dcf-crosscheck-math.js
@@ -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};
+}
diff --git a/js/tools/dcf-crosscheck.html b/js/tools/dcf-crosscheck.html
new file mode 100644
index 0000000..9907753
--- /dev/null
+++ b/js/tools/dcf-crosscheck.html
@@ -0,0 +1,69 @@
+
+ 02目标收益交叉验证
以当前价格买入,需要怎样的盈利增长
IRR
+ 共用当前公司的股价、股本、基期及 DCF 参考价
+ 利润与收益假设 独立于 DCF 现金流增速
+
+
+
+
已关联基础参数:— 亿元 FCFE
+
+
基期归母净利润(估算)—
+
基期转化率为用户假设;100% 表示假设基期 FCFE 等于归母利润。
+
+
+
+
+
+
+
+
+ 分红与股本变化
+
+
+
+
+
+ 每年末收到股息,末年同时收到股息与退出款;股息路径独立于利润,不计再投资与税费。DCF 按固定股本计算,本区股本变化仅用于收益验证。
+
+
+
+
+
+
+
DCF 参考价
+
目标收益参考价(含边际)
+
共同参考买入上限
+
+
+
+
+
+ 退出 PE 情景验证
表中为预计年化 IRR
+
+
+
展开双价格验证与条件拆解
+ 两种买入价格,分别验证
+ | 买入情景 | 买入价(元) | 所需利润年增速 | 预计年化 IRR | 目标回报条件 |
|---|
+
+
+
+
+
+
+
diff --git a/js/tools/dcf-crosscheck.js b/js/tools/dcf-crosscheck.js
new file mode 100644
index 0000000..6748dfb
--- /dev/null
+++ b/js/tools/dcf-crosscheck.js
@@ -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};
+}
diff --git a/js/tools/dcf-help.html b/js/tools/dcf-help.html
new file mode 100644
index 0000000..f6f8d2c
--- /dev/null
+++ b/js/tools/dcf-help.html
@@ -0,0 +1,49 @@
+
diff --git a/js/tools/dcf-help.js b/js/tools/dcf-help.js
new file mode 100644
index 0000000..bae9c8c
--- /dev/null
+++ b/js/tools/dcf-help.js
@@ -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();
+ };
+}
diff --git a/js/tools/dcf-math.js b/js/tools/dcf-math.js
new file mode 100644
index 0000000..ae6013e
--- /dev/null
+++ b/js/tools/dcf-math.js
@@ -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};
+}
diff --git a/js/tools/dcf-price-chart.js b/js/tools/dcf-price-chart.js
new file mode 100644
index 0000000..03ce6a2
--- /dev/null
+++ b/js/tools/dcf-price-chart.js
@@ -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,
+ };
+}
diff --git a/js/tools/stock-return-calculator.html b/js/tools/stock-return-calculator.html
new file mode 100644
index 0000000..ddbc2d7
--- /dev/null
+++ b/js/tools/stock-return-calculator.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 分红、股本、币种与基期
+
+
+
参数自动保存在本机浏览器
+
+
+
+
+
所需增速是达到目标回报的门槛,不是企业盈利预测。退出 PE 是用户假设,本模型依赖未来市场估值,不等于企业内在价值。
+
每年末收取股息,末年同时收到股息和退出款;股息按独立增速计算,不自动与利润联动,不计股息再投资和交易税费。股本增加会摊薄每股盈利。
+
安全边际按股息与退出款的总现值打折,20% 表示打八折。币种切换不会自动换算已填金额,跨币种请填写有来源日期的固定汇率;本工具不联网获取行情或财报。
+
+
diff --git a/js/tools/stock-return-calculator.js b/js/tools/stock-return-calculator.js
new file mode 100644
index 0000000..4632453
--- /dev/null
+++ b/js/tools/stock-return-calculator.js
@@ -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();
+ });
+}
diff --git a/js/tools/stock-return-math.js b/js/tools/stock-return-math.js
new file mode 100644
index 0000000..481afb4
--- /dev/null
+++ b/js/tools/stock-return-math.js
@@ -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 };
diff --git a/js/ui.js b/js/ui.js
index 46f9b63..13a3f62 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -62,13 +62,14 @@ export function createAppShell(root, { onReload }) {
cartBar.hidden = true;
root.append(layout, reloadButton, cartBar);
- return { companyNav, tabs, params, cards, chartNav, cartBar };
+ return { layout, companyNav, tabs, params, cards, chartNav, cartBar };
}
export function renderCompanies(container, companies, activeCompanyId, onSelect) {
container.replaceChildren(element('h2', 'sidebar-title', '公司'));
const list = element('div', 'company-list');
companies.forEach((company) => {
+ if (company.kind === 'tools') list.append(element('h2', 'sidebar-title tools-title', '工具'));
list.append(button(company.name, {
active: company.id === activeCompanyId,
onClick: () => onSelect(company.id),
diff --git a/package.json b/package.json
index b7b0b1b..d04f9eb 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
- "test": "node test/regress.mjs",
+ "test": "node test/regress.mjs && 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": [
diff --git a/test/dcf_crosscheck.mjs b/test/dcf_crosscheck.mjs
new file mode 100644
index 0000000..1280106
--- /dev/null
+++ b/test/dcf_crosscheck.mjs
@@ -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、利润来源及边界校验');
diff --git a/test/dcf_valuation.mjs b/test/dcf_valuation.mjs
new file mode 100644
index 0000000..b0e4af5
--- /dev/null
+++ b/test/dcf_valuation.mjs
@@ -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 低估/合理/高估边界、折溢价、可选股价及图表标记');
diff --git a/test/stock_return_valuation.mjs b/test/stock_return_valuation.mjs
new file mode 100644
index 0000000..7166f02
--- /dev/null
+++ b/test/stock_return_valuation.mjs
@@ -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('✓ 估值示例、现金流反向/正向验证、安全边际、多币种、分红、摊薄及边界条件');