Files
smart-fin-chart/js/tools/stock-return-calculator.js
T
2026-10-22 15:43:17 +08:00

145 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
});
}