Files
xbtlin 8ab676f18c terminal_value.py 新增 audit 准出检查;接进 investment-research skill 成为流程硬约束
一、tools/terminal_value.py 新增 audit 子命令
把三条纪律从"文档里的提醒"变成程序判定的【准出】/【打回】(退出码 0/1):

  C1 币种一致性 — r 落在别的币种区间、或基准档 g 超过本币上限即打回。
     内置护栏:CNY r∈[6%,9%] g≤2% Rf=1.70%;USD/HKD r∈[9%,11.5%] g≤4% Rf=4.70%。
     两个币种 r 区间差的3个百分点全部是国债利差,不含风险判断差异。
     乐观档按设计是基准+1pct,故额外放宽1pct。
  C2 分母宽度 — 任一档 r-g < 5pct 即打回,≤0 判模型失效。
     确需做上行/下行情景可加 --upside-only 显式声明,但报告须写明"是情景不是估值"。
  C3 离散风险归属 — 风险归属写成 折现率/r/beta 一律打回;未建模仅警告但须进"限制"章节。
     β 偏离 1.0 必须给 --beta-justification。

二、skills/investment-research.md 第七步新增"长期折现估值(十年尺度)"
四步流程:定输入(r/ROIC/g 三张判断表 + 常见错误)→ audit 准出 → 出数 → 报告必写四件事。
明确终值倍数只有永续增长模型一个合法来源,不许用同业类比(循环论证)。
明确 g 不随 r 变:g 是对终值年后经济的判断,r 是要求回报,做敏感性时只动 r。

三、准出流程新增 Step 4 估值口径复检
第七步的 audit 在算数之前,而写报告时常回头调 g 或 r——准出前最后一次 audit
才是对报告负责的那一次。退出码 1 即打回。
2026-08-17 03:18:42 +08:00

597 lines
29 KiB
Python
Executable File
Raw Permalink 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.
#!/usr/bin/env python3
"""终值倍数与十年 IRR 推演工具 (Terminal Value Toolkit for AI Berkshire).
把「7公司10年投资价值横评」第三节的估值框架做成可复用工具。核心是永续增长模型:
PE(终值) = (1 - g/ROIC) / (r - g)
分子 1 - g/ROIC = 派息率。g/ROIC 是留存率——想每年长 g,每留存一块钱赚 ROIC,
就必须留下利润的 g/ROIC 再投,剩下的才能分给股东。
分母 r - g = 永续年金分母。你要 r 的回报,东西自己长 g,净差额就是 r-g。
三条必须记住的纪律(全部来自那份报告踩过的坑):
1. r 和 g 必须同币种。3% 的名义增长,在 2.5% 通胀的美国是 0.5% 实际增长(保守),
在 1% 通胀的中国是 2% 实际增长(激进)。同一个数字换币种就换了含义。
本工具用 --g-shift 显式表达币种换算,不允许隐式混用。
2. 分母 r-g 至少要有 5 个百分点,戈登模型才有意义。低于 5pct 时 g 动一点点估值就翻天,
那不是估值,是放大偏见。本工具对每一格做体检并显式警告。
3. 离散风险(退市/VIE失效/地缘断供/监管重击)不能放进 r 或 beta。抬 r 三个百分点对第 10 年
现金流的惩罚是第 1 年的 2.6 倍,而退市是大致均匀甚至前置的年度危害率——用折现率处理它
会把风险的时间分布搞反。正确做法是单列一个尾部情景档。
零外部依赖,仅用 Python 标准库。要求 Python >= 3.7。
用法:
# 单点退出 PE,打印完整算式
python3 tools/terminal_value.py pe --roic 0.20 --g 0.02 --r 0.06
# 单公司三档推演(用内置预设)
python3 tools/terminal_value.py company --name 腾讯 --r 0.06 --g-shift -0.01
# 七家横评表 + 排序
python3 tools/terminal_value.py table --r 0.06 --g-shift -0.01 --rf 0.017
# 多档 r 敏感性 + 排序稳定性检验
python3 tools/terminal_value.py sweep --r 0.06,0.08,0.10,0.12 --g-shift -0.01 --rf 0.017
# 分母宽度体检(哪些格子跌破 5pct 有效性下限)
python3 tools/terminal_value.py check --r 0.06 --g-shift -0.01
# 从零算 IRR(不依赖预设,给利润和市值即可)
python3 tools/terminal_value.py irr --profit 5390 --mcap 34420 --pe 22.5 --years 10 --payout 0.015
# 用自己的公司配置
python3 tools/terminal_value.py table --r 0.08 --config my_companies.json
"""
import argparse
import json
import sys
# ---------------------------------------------------------------------------
# 输出编码:Windows 控制台默认 GBK,本工具的 ⚠ / ✓ 会抛 UnicodeEncodeError
# ---------------------------------------------------------------------------
def _force_utf8_stdio():
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8")
except Exception:
pass
_force_utf8_stdio()
# 戈登模型的有效性下限:分母 r-g 至少 5 个百分点
MIN_SPREAD = 0.05
# ---------------------------------------------------------------------------
# 内置预设:7公司10年投资价值横评(基准日 2026-08-14,口径修订 2026-08-17
#
# roic — 2036 年稳态增量 ROIC。报告 3.2 节的判断值,夹在存量 ROIC77%-180%
# 与派息反解的隐含增量 ROIC(2%-14.3%)之间。这是判断不是计算。
# g — 永续增速三档 (悲/基/乐),人民币口径「前」的原始取值。
# 规则:悲观 = 基准 - 1.5pct,乐观 = 基准 + 1.0pct(故意不对称,
# 因为下行侧有已入账的硬证据,上行侧多为未证实事项)。
# 注意这组 g 是报告 3.6 节三张表反解出来的,反算可完整复现原表。
# p — 三档概率权重 (悲/基/乐),来自第四节对抗验证后的调整。
# irr10 — 报告 3.6 节 r=10% 主口径下的三档 IRR,作为 rescale 模式的基准点。
# k — 股息率 - 股权稀释率,年化。IRR 中不随退出倍数变化的那部分。
# 结果对 k 极不敏感(k 变动 3pct 只影响 IRR 约 0.07pct),故用估计值。
# ---------------------------------------------------------------------------
PRESET = {
"腾讯": dict(roic=0.20, g=(0.015, 0.030, 0.040), p=(0.35, 0.50, 0.15),
irr10=(0.3, 9.3, 15.4), k=0.020),
"阿里巴巴": dict(roic=0.15, g=(0.015, 0.030, 0.040), p=(0.35, 0.45, 0.20),
irr10=(-1.3, 7.8, 16.3), k=0.000),
"拼多多": dict(roic=0.40, g=(0.005, 0.020, 0.030), p=(0.30, 0.55, 0.15),
irr10=(-9.7, 8.2, 15.0), k=0.000),
"贵州茅台": dict(roic=0.25, g=(0.010, 0.025, 0.035), p=(0.35, 0.45, 0.20),
irr10=(-5.3, 3.4, 7.2), k=0.045),
"泡泡玛特": dict(roic=0.25, g=(0.015, 0.030, 0.040), p=(0.38, 0.44, 0.18),
irr10=(-8.2, 4.9, 10.9), k=0.018),
"美团": dict(roic=0.18, g=(0.015, 0.030, 0.040), p=(0.40, 0.45, 0.15),
irr10=(-12.1, 1.8, 8.7), k=0.000),
"MiniMax": dict(roic=0.20, g=(0.025, 0.040, 0.050), p=(0.55, 0.30, 0.15),
irr10=(-30.8, -4.7, 9.6), k=-0.030),
}
BASE_R = 0.10 # 预设 irr10 对应的折现率
# 无风险利率实测值(2026-08),用于风险调整后回报的分子
RF = {
"CNY": 0.0170, # 中国 10 年期国债,2026-08-07
"USD": 0.0470, # 美国 10 年期国债,2026-08-14
}
LABELS = ("悲观", "基准", "乐观")
# ---------------------------------------------------------------------------
# 币种口径护栏(audit 子命令用)
#
# r 区间下沿 = 观测无风险利率 + 约 4.3pct 溢价,上沿 = 合成无风险利率 + 约 6pct 溢价。
# 两个币种的区间差的 3 个百分点全部是国债利差,不含任何风险判断差异。
# g 上限 = 该币种长期通胀 + 约 1pct 实际增长;超过等于假设公司长成整个经济体。
# 数据来源见 reports/7公司10年投资价值横评-确定性调整后回报-20260814.md 第 3.4 节。
# ---------------------------------------------------------------------------
CURRENCY_BANDS = {
"CNY": dict(r=(0.06, 0.09), g_max=0.02, rf=0.0170,
note="中国10年期国债 1.70%(观测)/ 2.5-3.0%(合成)+ ERP 5.18%"),
"USD": dict(r=(0.09, 0.115), g_max=0.040, rf=0.0470,
note="美国10年期国债 4.70% + 中国总ERP 5.18%(含1.01%国别溢价)"),
"HKD": dict(r=(0.09, 0.115), g_max=0.040, rf=0.0470,
note="港币与美元挂钩,口径同 USD"),
}
# 离散风险的合法归属。写进折现率或 beta 一律打回——抬 r 三个百分点对第 10 年现金流的
# 惩罚是第 1 年的 2.6 倍,而退市/断供是大致均匀甚至前置的年度危害率,会把时间分布搞反。
RISK_PLACEMENT_OK = {"情景", "尾部档", "概率"}
RISK_PLACEMENT_BAD = {"折现率", "r", "beta", "β"}
RISK_PLACEMENT_WARN = {"未建模"}
# ---------------------------------------------------------------------------
# 核心计算
# ---------------------------------------------------------------------------
def exit_pe(roic, g, r):
"""永续增长模型的终值 PE。
返回 (pe, 留存率, 分子, 分母)。分母 <= 0 时返回 pe=None(模型失效)。
"""
spread = r - g
retention = g / roic
numerator = 1.0 - retention
if spread <= 0:
return None, retention, numerator, spread
return numerator / spread, retention, numerator, spread
def irr_from_terminal(profit_2036, mcap_today, pe, years=10, payout=0.0):
"""从零算 IRR:终值市值 = 2036 利润 x 退出 PE。
payout = 股息率 - 稀释率,年化,直接加在几何回报上(报告口径,未做再投资复利)。
"""
return (profit_2036 * pe / mcap_today) ** (1.0 / years) - 1.0 + payout
def rescale_irr(irr_base, pe_base, pe_new, k, years=10):
"""把基准 r 下的 IRR 换算到新的退出倍数。
IRR 中只有资本利得部分随退出倍数变化,k(股息-稀释)不变:
(1 + IRR_new - k) = (1 + IRR_base - k) x (PE_new / PE_base)^(1/years)
"""
return (1.0 + irr_base - k) * (pe_new / pe_base) ** (1.0 / years) - 1.0 + k
def weighted_stats(values, probs):
"""概率加权的期望值与标准差。"""
mean = sum(v * p for v, p in zip(values, probs))
var = sum(p * (v - mean) ** 2 for v, p in zip(values, probs))
return mean, var ** 0.5
def evaluate(spec, r, g_shift=0.0, years=10):
"""算一家公司在给定 r 下的三档退出 PE 与 IRR。
g_shift 是币种换算:把原始 g 平移到目标币种的通胀口径。
人民币口径相对报告原值取 -0.01(中国长期通胀约 1% vs 美国约 2.5%)。
"""
roic = spec["roic"]
rows = []
for label, g0, irr_b in zip(LABELS, spec["g"], spec["irr10"]):
g = g0 + g_shift
pe_new, retention, numerator, spread = exit_pe(roic, g, r)
# 基准 PE 必须用「平移前」的 g:irr10 这个锚点对应的是 (BASE_R, 原始 g)。
# 用平移后的 g 算基准会把币种换算的影响漏掉一半,IRR 被系统性高估。
pe_base, _, _, _ = exit_pe(roic, g0, BASE_R)
if pe_new is None or pe_base is None:
irr = None
else:
irr = rescale_irr(irr_b / 100.0, pe_base, pe_new, spec["k"], years) * 100.0
rows.append(dict(label=label, g=g, pe=pe_new, irr=irr,
retention=retention, numerator=numerator, spread=spread))
return rows
def summarize(rows, probs, rf):
"""期望 IRR、标准差、风险调整后回报。任一档失效则整体不可用。"""
irrs = [row["irr"] for row in rows]
if any(x is None for x in irrs):
return None, None, None
mean, sd = weighted_stats(irrs, probs)
ratio = (mean - rf * 100.0) / sd if sd > 0 else None
return mean, sd, ratio
def load_companies(path):
if not path:
return dict(PRESET)
with open(path, encoding="utf-8") as fh:
raw = json.load(fh)
out = {}
for name, spec in raw.items():
out[name] = dict(roic=spec["roic"], g=tuple(spec["g"]), p=tuple(spec["p"]),
irr10=tuple(spec["irr10"]), k=spec.get("k", 0.0))
return out
def warn_spread(spread):
"""分母宽度体检标记。"""
if spread <= 0:
return "✗失效"
if spread < MIN_SPREAD:
return "⚠窄"
return ""
# ---------------------------------------------------------------------------
# 子命令
# ---------------------------------------------------------------------------
def cmd_pe(args):
pe, retention, numerator, spread = exit_pe(args.roic, args.g, args.r)
print(f"\nPE(终值) = (1 - g/ROIC) / (r - g)\n")
print(f" ROIC = {args.roic:.1%} g = {args.g:.2%} r = {args.r:.2%}\n")
print(f" 留存率 g/ROIC = {args.g:.4f} / {args.roic:.2f} = {retention:.4f} ({retention:.1%})")
print(f" 分子 1 - 留存率 = {numerator:.4f} (即派息率 {numerator:.1%}")
print(f" 分母 r - g = {args.r:.4f} - {args.g:.4f} = {spread:.4f} ({spread*100:.1f}pct)")
if pe is None:
print(f"\n ✗ 分母 <= 0,模型失效。g 必须小于 r。\n")
return 1
print(f"\n 退出 PE = {numerator:.4f} / {spread:.4f} = {pe:.1f}x\n")
if spread < MIN_SPREAD:
print(f" ⚠ 分母仅 {spread*100:.1f}pct,低于 {MIN_SPREAD*100:.0f}pct 有效性下限。")
bumped, _, _, _ = exit_pe(args.roic, args.g + 0.005, args.r)
if bumped:
print(f" g 只要再加 0.5pctPE 就变成 {bumped:.1f}x{bumped/pe-1:+.0%})。")
print(f" 这一格该当作方向性参考,不能当估值用。\n")
return 0
def cmd_company(args):
companies = load_companies(args.config)
if args.name not in companies:
print(f"未知公司:{args.name}。可选:{'、'.join(companies)}", file=sys.stderr)
return 1
spec = companies[args.name]
rows = evaluate(spec, args.r, args.g_shift, args.years)
rf = args.rf if args.rf is not None else RF["CNY"]
print(f"\n{args.name} | r = {args.r:.1%} ROIC = {spec['roic']:.0%} "
f"g平移 {args.g_shift:+.1%} Rf = {rf:.2%}\n")
print(f"{'档位':<6}{'概率':>6}{'g':>8}{'留存率':>8}{'分子':>8}{'分母':>9}{'退出PE':>9}{'IRR':>9} 体检")
print("-" * 74)
for row, p in zip(rows, spec["p"]):
pe = f"{row['pe']:.1f}x" if row["pe"] else "—"
irr = f"{row['irr']:+.1f}%" if row["irr"] is not None else "—"
print(f"{row['label']:<6}{p:>6.0%}{row['g']:>8.1%}{row['retention']:>8.1%}"
f"{row['numerator']:>8.3f}{row['spread']*100:>7.1f}pct{pe:>9}{irr:>9} {warn_spread(row['spread'])}")
mean, sd, ratio = summarize(rows, spec["p"], rf)
print("-" * 74)
if mean is None:
print("\n期望值不可用:至少一档的分母 <= 0。\n")
return 1
print(f"{'期望IRR':<12}{mean:+.2f}% 标准差 {sd:.1f}% 风险调整后 {ratio:+.2f}\n")
narrow = [r_["label"] for r_ in rows if 0 < r_["spread"] < MIN_SPREAD]
if narrow:
print(f"⚠ {'/'.join(narrow)} 档分母低于 {MIN_SPREAD*100:.0f}pct,退出倍数对 g 极度敏感。\n")
return 0
def cmd_table(args):
companies = load_companies(args.config)
rf = args.rf if args.rf is not None else RF["CNY"]
results = []
for name, spec in companies.items():
rows = evaluate(spec, args.r, args.g_shift, args.years)
mean, sd, ratio = summarize(rows, spec["p"], rf)
results.append((name, spec, rows, mean, sd, ratio))
ok = [x for x in results if x[5] is not None]
bad = [x for x in results if x[5] is None]
ok.sort(key=lambda x: -x[5])
print(f"\nr = {args.r:.1%} g平移 {args.g_shift:+.1%} Rf = {rf:.2%} 持有期 {args.years}\n")
print(f"{'排名':<5}{'公司':<10}{'退出PE(悲/基/乐)':<24}{'IRR(悲/基/乐)':<30}"
f"{'期望IRR':>9}{'σ':>8}{'风险调整后':>11}")
print("-" * 98)
for i, (name, spec, rows, mean, sd, ratio) in enumerate(ok, 1):
pes = "/".join(f"{r_['pe']:.1f}" for r_ in rows)
irrs = "/".join(f"{r_['irr']:+.1f}%" for r_ in rows)
print(f"{i:<5}{name:<10}{pes:<26}{irrs:<32}{mean:>8.2f}%{sd:>7.1f}%{ratio:>11.2f}")
for name, spec, rows, mean, sd, ratio in bad:
print(f"{'—':<5}{name:<10}{'模型失效(分母 <= 0':<26}")
narrow = sum(1 for _, _, rows, _, _, _ in results
for r_ in rows if 0 < r_["spread"] < MIN_SPREAD)
total = sum(len(rows) for _, _, rows, _, _, _ in results)
print("-" * 98)
if narrow:
print(f"\n{total} 格里有 {narrow} 格的分母低于 {MIN_SPREAD*100:.0f}pct 有效性下限。"
f"跑 `check --r {args.r}` 看明细。")
if narrow > total / 2:
print(f" 过半格子失效——这一档应当作「上行/下行情景」看,不能当另一个同等可信的估值。")
print()
return 0
def cmd_sweep(args):
companies = load_companies(args.config)
rf = args.rf if args.rf is not None else RF["CNY"]
rates = [float(x) for x in args.r.split(",")]
order = {}
print(f"\ng平移 {args.g_shift:+.1%} Rf = {rf:.2%} 持有期 {args.years} 年")
print(f"\n期望IRR\n")
header = f"{'公司':<10}" + "".join(f"{'r=' + format(x, '.0%'):>11}" for x in rates)
print(header)
print("-" * len(header))
for name, spec in companies.items():
cells = []
for r in rates:
rows = evaluate(spec, r, args.g_shift, args.years)
mean, sd, ratio = summarize(rows, spec["p"], rf)
cells.append((mean, ratio))
order.setdefault(r, []).append((name, ratio))
line = f"{name:<10}" + "".join(
f"{m:>10.1f}%" if m is not None else f"{'—':>11}" for m, _ in cells)
print(line)
print(f"\n风险调整后回报排序:\n")
ranks = {}
for r in rates:
entries = [(n, x) for n, x in order[r] if x is not None]
entries.sort(key=lambda t: -t[1])
for pos, (n, _) in enumerate(entries, 1):
ranks.setdefault(n, {})[r] = pos
header = f"{'公司':<10}" + "".join(f"{'r=' + format(x, '.0%'):>11}" for x in rates)
print(header)
print("-" * len(header))
for name in companies:
cells = "".join(f"{ranks.get(name, {}).get(r, '—'):>11}" for r in rates)
print(f"{name:<10}{cells}")
moved = [n for n in companies
if len({ranks.get(n, {}).get(r) for r in rates}) > 1]
print()
if moved:
print(f"排序随 r 变动的公司:{'、'.join(moved)}")
print(f"其余 {len(companies) - len(moved)} 家位次恒定——这是比绝对数字稳健得多的结论。\n")
else:
print(f"全部 {len(companies)} 家位次恒定,排序完全不受 r 影响。\n")
return 0
def cmd_check(args):
companies = load_companies(args.config)
print(f"\n分母宽度体检 r = {args.r:.1%} g平移 {args.g_shift:+.1%} "
f"下限 {MIN_SPREAD*100:.0f}pct\n")
print(f"{'公司':<10}{'档位':<6}{'g':>8}{'r-g':>9}{'退出PE':>9}"
f"{'g+0.5pct后':>12}{'PE变化':>9} 体检")
print("-" * 76)
flagged = 0
for name, spec in companies.items():
for label, g0 in zip(LABELS, spec["g"]):
g = g0 + args.g_shift
pe, _, _, spread = exit_pe(spec["roic"], g, args.r)
bumped, _, _, _ = exit_pe(spec["roic"], g + 0.005, args.r)
mark = warn_spread(spread)
if mark:
flagged += 1
pe_s = f"{pe:.1f}x" if pe else "—"
bumped_s = f"{bumped:.1f}x" if bumped else "—"
delta = f"{bumped/pe-1:+.0%}" if (pe and bumped) else "—"
print(f"{name if label == '悲观' else '':<10}{label:<6}{g:>8.1%}"
f"{spread*100:>7.1f}pct{pe_s:>9}{bumped_s:>12}{delta:>9} {mark}")
print("-" * 76)
total = len(companies) * 3
print(f"\n{total} 格里 {flagged} 格触发警告。")
print(f"「g+0.5pct后」那一列是这条规矩的意义所在:分母越窄,永续增速动一点点估值就翻天。\n")
return 0
def cmd_audit(args):
"""三条硬约束的准出检查。任一条不过 → 【打回】,退出码 1。"""
band = CURRENCY_BANDS.get(args.currency.upper())
if band is None:
print(f"未知币种 {args.currency},可选:{'、'.join(CURRENCY_BANDS)}", file=sys.stderr)
return 1
gs = [float(x) for x in args.g.split(",")]
fails, warns = [], []
print(f"\n{'='*72}\n估值口径准出检查 币种={args.currency.upper()} r={args.r:.2%} "
f"ROIC={args.roic:.0%} g={'/'.join(f'{x:.1%}' for x in gs)}\n{'='*72}")
# --- C1 币种一致性:r 与 g 必须同币种 -----------------------------------
lo, hi = band["r"]
print(f"\n【C1】币种一致性 — r 与 g 必须用同一个币种")
print(f" {args.currency.upper()} 口径基准:{band['note']}")
print(f" r 合理区间 [{lo:.1%}, {hi:.1%}] g 上限 {band['g_max']:.1%} Rf {band['rf']:.2%}")
if not (lo <= args.r <= hi):
other = [c for c, b in CURRENCY_BANDS.items()
if c != args.currency.upper() and b["r"][0] <= args.r <= b["r"][1]]
hint = f"(这个 r 落在 {'/'.join(other)} 的区间里——是不是拿错币种了?)" if other else ""
fails.append(f"C1: r={args.r:.2%} 不在 {args.currency.upper()} 的 [{lo:.1%},{hi:.1%}] 区间内 {hint}")
# g 上限卡在基准档。乐观档按设计是基准 +1pct,故额外放宽 1pct。
g_base = gs[1] if len(gs) >= 2 else gs[0]
if g_base > band["g_max"] or max(gs) > band["g_max"] + 0.01:
other = [c for c, b in CURRENCY_BANDS.items()
if c != args.currency.upper() and g_base <= b["g_max"]]
hint = (f"(这是 {'/'.join(other)} 的量级——r 用了本币而 g 用了外币,"
f"正是最常见的口径混用)") if other else ""
fails.append(f"C1: 基准档 g={g_base:.1%} 超过 {args.currency.upper()} 上限 "
f"{band['g_max']:.1%}(乐观档另放宽 1pct 至 {band['g_max']+0.01:.1%}{hint}")
if args.rf is not None and abs(args.rf - band["rf"]) > 0.005:
fails.append(f"C1: 无风险利率 {args.rf:.2%}{args.currency.upper()}{band['rf']:.2%} 不符")
print(f" → {'✗ 不通过' if any(f.startswith('C1') for f in fails) else '✓ 通过'}")
# --- C2 分母宽度 ---------------------------------------------------------
print(f"\n【C2】分母宽度 — r-g 至少 {MIN_SPREAD*100:.0f} 个百分点")
narrow = []
for label, g in zip(LABELS, gs):
pe, _, _, spread = exit_pe(args.roic, g, args.r)
bumped, _, _, _ = exit_pe(args.roic, g + 0.005, args.r)
tag = warn_spread(spread)
delta = f" g+0.5pct → {bumped:.1f}x ({bumped/pe-1:+.0%})" if (pe and bumped) else ""
pe_s = f"{pe:.1f}x" if pe else "模型失效"
print(f" {label} g={g:>5.1%} r-g={spread*100:>4.1f}pct PE={pe_s:>10}{delta} {tag}")
if spread <= 0:
fails.append(f"C2: {label}档 r-g={spread*100:.1f}pct <= 0,模型失效")
elif spread < MIN_SPREAD:
narrow.append(label)
if narrow:
if args.upside_only:
warns.append(f"C2: {'/'.join(narrow)}档分母不足 {MIN_SPREAD*100:.0f}pct"
f"已声明 --upside-only,仅可作情景参考")
else:
fails.append(f"C2: {'/'.join(narrow)}档分母不足 {MIN_SPREAD*100:.0f}pct。"
f"要么调窄 g,要么加 --upside-only 声明这是情景而非估值")
print(f" → {'✗ 不通过' if any(f.startswith('C2') for f in fails) else '✓ 通过'}")
# --- C3 离散风险归属 -----------------------------------------------------
print(f"\n【C3】离散风险归属 — 退市/VIE/地缘/监管必须归情景,不得进 r 或 β")
if args.beta != 1.0 and not args.beta_justification:
fails.append(f"C3: β={args.beta} 偏离 1.0 但未给 --beta-justification。"
f"回归 β 的标准误就有 ±0.2-0.3,调它必须说明用的是自下而上的基本面 β")
for item in [x for x in args.discrete_risks.split(",") if x.strip()]:
if ":" not in item:
fails.append(f"C3: '{item}' 格式应为 风险名:归属")
continue
name, place = (p.strip() for p in item.split(":", 1))
if place in RISK_PLACEMENT_BAD:
fails.append(f"C3: 「{name}」被放进了 {place}。抬 r 对第10年现金流的惩罚是第1年的 2.6 倍,"
f"而这类风险是均匀甚至前置的年度危害率——会把风险的时间分布搞反")
mark = "✗"
elif place in RISK_PLACEMENT_WARN:
warns.append(f"C3: 「{name}」未建模,必须写进报告的「限制」章节")
mark = "⚠"
elif place in RISK_PLACEMENT_OK:
mark = "✓"
else:
fails.append(f"C3: 「{name}」的归属 '{place}' 无法识别,"
f"合法值:{'/'.join(sorted(RISK_PLACEMENT_OK | RISK_PLACEMENT_WARN))}")
mark = "✗"
print(f" {mark} {name}{place}")
if args.beta == 1.0:
print(f" β = 1.0(默认值,无需理由)")
else:
print(f" β = {args.beta}(理由:{args.beta_justification or '未给出'}")
print(f" → {'✗ 不通过' if any(f.startswith('C3') for f in fails) else '✓ 通过'}")
# --- 判决 ----------------------------------------------------------------
print(f"\n{'='*72}")
for w in warns:
print(f"⚠ {w}")
if fails:
print(f"\n【打回】{len(fails)} 项不通过:\n")
for i, msg in enumerate(fails, 1):
print(f" {i}. {msg}")
print(f"\n修正后重跑本命令,通过才能把估值写进报告。\n")
return 1
print(f"\n【准出】三条硬约束全部通过,可以把估值写进报告。")
if warns:
print(f" 但上述 {len(warns)} 条警告必须在报告里显式写出。")
print()
return 0
def cmd_irr(args):
irr = irr_from_terminal(args.profit, args.mcap, args.pe, args.years, args.payout)
terminal = args.profit * args.pe
mult = terminal / args.mcap
print(f"\nIRR = (2036市值 / 今日市值)^(1/{args.years}) - 1 + 股息率 - 稀释率\n")
print(f" 2036 市值 = {args.profit:,.0f} x {args.pe:.1f}x = {terminal:,.0f}")
print(f" 今日市值 = {args.mcap:,.0f}")
print(f" 总倍数 = {mult:.4f}")
print(f" 几何年化 = {mult ** (1.0/args.years) - 1:+.2%}")
print(f" 股息-稀释 = {args.payout:+.2%}")
print(f"\n IRR = {irr:+.2%}\n")
return 0
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="终值倍数与十年 IRR 推演(永续增长模型)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__.split("用法:")[-1])
sub = parser.add_subparsers(dest="cmd")
def add_common(p, need_r=True):
if need_r:
p.add_argument("--r", type=float, required=True, help="资本成本,小数(如 0.06")
p.add_argument("--g-shift", type=float, default=0.0,
help="g 的币种平移,小数。人民币口径相对报告原值取 -0.01")
p.add_argument("--years", type=int, default=10, help="持有期年数,默认 10")
p.add_argument("--config", help="自定义公司配置 JSON,缺省用内置 7 家预设")
p = sub.add_parser("pe", help="单点退出 PE,打印完整算式")
p.add_argument("--roic", type=float, required=True)
p.add_argument("--g", type=float, required=True)
p.add_argument("--r", type=float, required=True)
p.set_defaults(func=cmd_pe)
p = sub.add_parser("company", help="单公司三档推演")
p.add_argument("--name", required=True)
p.add_argument("--rf", type=float, help="无风险利率,缺省用人民币 1.70%%")
add_common(p)
p.set_defaults(func=cmd_company)
p = sub.add_parser("table", help="多公司横评表 + 排序")
p.add_argument("--rf", type=float, help="无风险利率,缺省用人民币 1.70%%")
add_common(p)
p.set_defaults(func=cmd_table)
p = sub.add_parser("sweep", help="多档 r 敏感性 + 排序稳定性检验")
p.add_argument("--r", required=True, help="逗号分隔,如 0.06,0.08,0.10,0.12")
p.add_argument("--rf", type=float, help="无风险利率,缺省用人民币 1.70%%")
add_common(p, need_r=False)
p.set_defaults(func=cmd_sweep)
p = sub.add_parser("check", help="分母宽度体检")
add_common(p)
p.set_defaults(func=cmd_check)
p = sub.add_parser("audit", help="三条硬约束准出检查(【准出】/【打回】)")
p.add_argument("--currency", required=True, help="现金流币种:CNY / USD / HKD")
p.add_argument("--r", type=float, required=True, help="资本成本,小数")
p.add_argument("--roic", type=float, required=True, help="2036 稳态增量 ROIC,小数")
p.add_argument("--g", required=True, help="三档永续增速,逗号分隔,如 0.005,0.02,0.03")
p.add_argument("--rf", type=float, help="无风险利率,小数。给了就校验与币种是否匹配")
p.add_argument("--beta", type=float, default=1.0, help="股票风险系数,默认 1.0")
p.add_argument("--beta-justification", help="β 偏离 1.0 时必填")
p.add_argument("--discrete-risks", default="",
help="离散风险归属,格式 风险名:归属,逗号分隔。"
"合法归属:情景/尾部档/概率(通过)、未建模(警告)。"
"写成 折现率/r/beta 一律打回")
p.add_argument("--upside-only", action="store_true",
help="声明本档仅作上行/下行情景参考,允许分母不足 5pct")
p.set_defaults(func=cmd_audit)
p = sub.add_parser("irr", help="从零算 IRR(给利润与市值)")
p.add_argument("--profit", type=float, required=True, help="终值年利润")
p.add_argument("--mcap", type=float, required=True, help="今日市值,与利润同单位同币种")
p.add_argument("--pe", type=float, required=True, help="退出 PE")
p.add_argument("--years", type=int, default=10)
p.add_argument("--payout", type=float, default=0.0, help="股息率 - 稀释率,年化小数")
p.set_defaults(func=cmd_irr)
args = parser.parse_args()
if not args.cmd:
parser.print_help()
return 0
return args.func(args)
if __name__ == "__main__":
sys.exit(main())