// 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(); }); }