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}; }