from __future__ import annotations
|
|
from pathlib import Path
|
from math import pow
|
|
from docx import Document
|
from docx.enum.section import WD_SECTION
|
from docx.enum.table import WD_ALIGN_VERTICAL, WD_CELL_VERTICAL_ALIGNMENT
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
from docx.enum.style import WD_STYLE_TYPE
|
from docx.oxml import OxmlElement
|
from docx.oxml.ns import qn
|
from docx.shared import Inches, Pt, RGBColor
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
OUTPUT = ROOT / "股票价格合理性评估操作手册_v1.0.docx"
|
|
# compact_reference_guide preset + named Chinese readability override.
|
PAGE_W_DXA = 12240
|
PAGE_H_DXA = 15840
|
CONTENT_W_DXA = 9360
|
TABLE_INDENT_DXA = 120
|
FONT_LATIN = "Calibri"
|
FONT_EAST_ASIA = "Microsoft YaHei"
|
|
BLUE = "2E74B5"
|
DARK_BLUE = "1F4D78"
|
INK = "0B2545"
|
MUTED = "5F6B7A"
|
LIGHT_BLUE = "E8EEF5"
|
LIGHT_GRAY = "F2F4F7"
|
CALLOUT = "F4F6F9"
|
PALE_GOLD = "FFF6D8"
|
GOLD = "7A5A00"
|
PALE_RED = "FDECEC"
|
RED = "9B1C1C"
|
PALE_GREEN = "EAF4EC"
|
GREEN = "245B36"
|
WHITE = "FFFFFF"
|
BLACK = "000000"
|
|
|
def set_run_font(run, size=None, bold=None, italic=None, color=None, latin=FONT_LATIN, east_asia=FONT_EAST_ASIA):
|
run.font.name = latin
|
if run._element.rPr is None:
|
run._element.get_or_add_rPr()
|
rfonts = run._element.rPr.rFonts
|
if rfonts is None:
|
rfonts = OxmlElement("w:rFonts")
|
run._element.rPr.insert(0, rfonts)
|
rfonts.set(qn("w:ascii"), latin)
|
rfonts.set(qn("w:hAnsi"), latin)
|
rfonts.set(qn("w:eastAsia"), east_asia)
|
if size is not None:
|
run.font.size = Pt(size)
|
if bold is not None:
|
run.bold = bold
|
if italic is not None:
|
run.italic = italic
|
if color is not None:
|
run.font.color.rgb = RGBColor.from_string(color)
|
|
|
def set_style_font(style, size, color=BLACK, bold=False):
|
style.font.name = FONT_LATIN
|
style.font.size = Pt(size)
|
style.font.bold = bold
|
style.font.color.rgb = RGBColor.from_string(color)
|
rpr = style.element.get_or_add_rPr()
|
rfonts = rpr.rFonts
|
if rfonts is None:
|
rfonts = OxmlElement("w:rFonts")
|
rpr.insert(0, rfonts)
|
rfonts.set(qn("w:ascii"), FONT_LATIN)
|
rfonts.set(qn("w:hAnsi"), FONT_LATIN)
|
rfonts.set(qn("w:eastAsia"), FONT_EAST_ASIA)
|
|
|
def set_cell_shading(cell, fill):
|
tc_pr = cell._tc.get_or_add_tcPr()
|
shd = tc_pr.find(qn("w:shd"))
|
if shd is None:
|
shd = OxmlElement("w:shd")
|
tc_pr.append(shd)
|
shd.set(qn("w:fill"), fill)
|
shd.set(qn("w:val"), "clear")
|
|
|
def set_cell_margins(cell, top=80, start=120, bottom=80, end=120):
|
tc_pr = cell._tc.get_or_add_tcPr()
|
tc_mar = tc_pr.first_child_found_in("w:tcMar")
|
if tc_mar is None:
|
tc_mar = OxmlElement("w:tcMar")
|
tc_pr.append(tc_mar)
|
for tag, value in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
|
node = tc_mar.find(qn(f"w:{tag}"))
|
if node is None:
|
node = OxmlElement(f"w:{tag}")
|
tc_mar.append(node)
|
node.set(qn("w:w"), str(value))
|
node.set(qn("w:type"), "dxa")
|
|
|
def set_repeat_table_header(row):
|
tr_pr = row._tr.get_or_add_trPr()
|
tbl_header = tr_pr.find(qn("w:tblHeader"))
|
if tbl_header is None:
|
tbl_header = OxmlElement("w:tblHeader")
|
tr_pr.append(tbl_header)
|
tbl_header.set(qn("w:val"), "true")
|
|
|
def set_table_geometry(table, widths_dxa, indent_dxa=TABLE_INDENT_DXA):
|
assert sum(widths_dxa) == CONTENT_W_DXA, (widths_dxa, sum(widths_dxa))
|
table.autofit = False
|
tbl_pr = table._tbl.tblPr
|
tbl_w = tbl_pr.find(qn("w:tblW"))
|
if tbl_w is None:
|
tbl_w = OxmlElement("w:tblW")
|
tbl_pr.append(tbl_w)
|
tbl_w.set(qn("w:w"), str(CONTENT_W_DXA))
|
tbl_w.set(qn("w:type"), "dxa")
|
tbl_ind = tbl_pr.find(qn("w:tblInd"))
|
if tbl_ind is None:
|
tbl_ind = OxmlElement("w:tblInd")
|
tbl_pr.append(tbl_ind)
|
tbl_ind.set(qn("w:w"), str(indent_dxa))
|
tbl_ind.set(qn("w:type"), "dxa")
|
layout = tbl_pr.find(qn("w:tblLayout"))
|
if layout is None:
|
layout = OxmlElement("w:tblLayout")
|
tbl_pr.append(layout)
|
layout.set(qn("w:type"), "fixed")
|
|
grid = table._tbl.tblGrid
|
for child in list(grid):
|
grid.remove(child)
|
for width in widths_dxa:
|
col = OxmlElement("w:gridCol")
|
col.set(qn("w:w"), str(width))
|
grid.append(col)
|
for row in table.rows:
|
for idx, cell in enumerate(row.cells):
|
tc_pr = cell._tc.get_or_add_tcPr()
|
tc_w = tc_pr.find(qn("w:tcW"))
|
if tc_w is None:
|
tc_w = OxmlElement("w:tcW")
|
tc_pr.append(tc_w)
|
tc_w.set(qn("w:w"), str(widths_dxa[idx]))
|
tc_w.set(qn("w:type"), "dxa")
|
cell.width = Inches(widths_dxa[idx] / 1440)
|
set_cell_margins(cell)
|
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
|
|
def set_cell_borders(cell, color="D9DEE5", size="4"):
|
tc_pr = cell._tc.get_or_add_tcPr()
|
borders = tc_pr.find(qn("w:tcBorders"))
|
if borders is None:
|
borders = OxmlElement("w:tcBorders")
|
tc_pr.append(borders)
|
for edge in ("top", "start", "bottom", "end", "insideH", "insideV"):
|
node = borders.find(qn(f"w:{edge}"))
|
if node is None:
|
node = OxmlElement(f"w:{edge}")
|
borders.append(node)
|
node.set(qn("w:val"), "single")
|
node.set(qn("w:sz"), size)
|
node.set(qn("w:color"), color)
|
|
|
def add_table(doc, headers, rows, widths_dxa, header_fill=LIGHT_BLUE, font_size=9.2,
|
aligns=None, first_col_bold=False):
|
table = doc.add_table(rows=1, cols=len(headers))
|
table.style = "Table Grid"
|
set_table_geometry(table, widths_dxa)
|
header = table.rows[0]
|
set_repeat_table_header(header)
|
for idx, text in enumerate(headers):
|
cell = header.cells[idx]
|
cell.text = str(text)
|
set_cell_shading(cell, header_fill)
|
set_cell_borders(cell)
|
for p in cell.paragraphs:
|
p.paragraph_format.space_before = Pt(0)
|
p.paragraph_format.space_after = Pt(0)
|
p.paragraph_format.line_spacing = 1.12
|
p.alignment = aligns[idx] if aligns else WD_ALIGN_PARAGRAPH.CENTER
|
for run in p.runs:
|
set_run_font(run, size=font_size, bold=True, color=INK)
|
for r_index, row_data in enumerate(rows):
|
cells = table.add_row().cells
|
for idx, value in enumerate(row_data):
|
cells[idx].text = "" if value is None else str(value)
|
if r_index % 2 == 1:
|
set_cell_shading(cells[idx], "FAFBFC")
|
set_cell_borders(cells[idx])
|
for p in cells[idx].paragraphs:
|
p.paragraph_format.space_before = Pt(0)
|
p.paragraph_format.space_after = Pt(0)
|
p.paragraph_format.line_spacing = 1.15
|
p.alignment = aligns[idx] if aligns else (WD_ALIGN_PARAGRAPH.LEFT if idx else WD_ALIGN_PARAGRAPH.CENTER)
|
for run in p.runs:
|
set_run_font(run, size=font_size, bold=(first_col_bold and idx == 0), color=BLACK)
|
doc.add_paragraph().paragraph_format.space_after = Pt(0)
|
return table
|
|
|
def shade_paragraph(paragraph, fill=CALLOUT, border_color="D8E0E8"):
|
p_pr = paragraph._p.get_or_add_pPr()
|
shd = p_pr.find(qn("w:shd"))
|
if shd is None:
|
shd = OxmlElement("w:shd")
|
p_pr.append(shd)
|
shd.set(qn("w:fill"), fill)
|
borders = p_pr.find(qn("w:pBdr"))
|
if borders is None:
|
borders = OxmlElement("w:pBdr")
|
p_pr.append(borders)
|
left = borders.find(qn("w:left"))
|
if left is None:
|
left = OxmlElement("w:left")
|
borders.append(left)
|
left.set(qn("w:val"), "single")
|
left.set(qn("w:sz"), "18")
|
left.set(qn("w:space"), "8")
|
left.set(qn("w:color"), border_color)
|
|
|
def add_callout(doc, label, text, kind="info"):
|
colors = {
|
"info": (CALLOUT, BLUE, INK),
|
"caution": (PALE_GOLD, GOLD, GOLD),
|
"risk": (PALE_RED, RED, RED),
|
"positive": (PALE_GREEN, GREEN, GREEN),
|
}
|
fill, border, text_color = colors[kind]
|
p = doc.add_paragraph()
|
p.paragraph_format.left_indent = Inches(0.08)
|
p.paragraph_format.right_indent = Inches(0.03)
|
p.paragraph_format.space_before = Pt(4)
|
p.paragraph_format.space_after = Pt(8)
|
p.paragraph_format.line_spacing = 1.2
|
shade_paragraph(p, fill, border)
|
r = p.add_run(f"{label} ")
|
set_run_font(r, size=10.5, bold=True, color=text_color)
|
r = p.add_run(text)
|
set_run_font(r, size=10.5, color=BLACK)
|
return p
|
|
|
def add_formula(doc, *lines):
|
p = doc.add_paragraph()
|
p.style = doc.styles["Formula"]
|
shade_paragraph(p, "F7F9FB", "AAB7C4")
|
for idx, line in enumerate(lines):
|
if idx:
|
p.add_run("\n")
|
r = p.add_run(line)
|
set_run_font(r, size=10.2, bold=(idx == 0), color=INK, latin="Consolas", east_asia=FONT_EAST_ASIA)
|
return p
|
|
|
def add_body(doc, text, bold_lead=None):
|
p = doc.add_paragraph()
|
if bold_lead and text.startswith(bold_lead):
|
r = p.add_run(bold_lead)
|
set_run_font(r, size=11, bold=True, color=INK)
|
r = p.add_run(text[len(bold_lead):])
|
set_run_font(r, size=11, color=BLACK)
|
else:
|
r = p.add_run(text)
|
set_run_font(r, size=11, color=BLACK)
|
return p
|
|
|
def add_bullet_list(doc, items, level=0):
|
num_id = create_numbering(doc, kind="bullet")
|
for text in items:
|
p = doc.add_paragraph()
|
apply_num(p, num_id, level)
|
r = p.add_run(text)
|
set_run_font(r, size=11, color=BLACK)
|
|
|
def add_numbered_list(doc, items):
|
num_id = create_numbering(doc, kind="decimal")
|
for text in items:
|
p = doc.add_paragraph()
|
apply_num(p, num_id, 0)
|
r = p.add_run(text)
|
set_run_font(r, size=11, color=BLACK)
|
|
|
def create_numbering(doc, kind="bullet"):
|
numbering = doc.part.numbering_part.element
|
abstract_ids = [int(x.get(qn("w:abstractNumId"))) for x in numbering.findall(qn("w:abstractNum"))]
|
num_ids = [int(x.get(qn("w:numId"))) for x in numbering.findall(qn("w:num"))]
|
abstract_id = (max(abstract_ids) + 1) if abstract_ids else 1
|
num_id = (max(num_ids) + 1) if num_ids else 1
|
abstract = OxmlElement("w:abstractNum")
|
abstract.set(qn("w:abstractNumId"), str(abstract_id))
|
nsid = OxmlElement("w:nsid")
|
nsid.set(qn("w:val"), f"{abstract_id:08X}")
|
abstract.append(nsid)
|
multi = OxmlElement("w:multiLevelType")
|
multi.set(qn("w:val"), "singleLevel")
|
abstract.append(multi)
|
lvl = OxmlElement("w:lvl")
|
lvl.set(qn("w:ilvl"), "0")
|
start = OxmlElement("w:start")
|
start.set(qn("w:val"), "1")
|
lvl.append(start)
|
num_fmt = OxmlElement("w:numFmt")
|
num_fmt.set(qn("w:val"), "bullet" if kind == "bullet" else "decimal")
|
lvl.append(num_fmt)
|
lvl_text = OxmlElement("w:lvlText")
|
lvl_text.set(qn("w:val"), "•" if kind == "bullet" else "%1.")
|
lvl.append(lvl_text)
|
suff = OxmlElement("w:suff")
|
suff.set(qn("w:val"), "tab")
|
lvl.append(suff)
|
p_pr = OxmlElement("w:pPr")
|
tabs = OxmlElement("w:tabs")
|
tab = OxmlElement("w:tab")
|
tab.set(qn("w:val"), "num")
|
tab.set(qn("w:pos"), "540")
|
tabs.append(tab)
|
p_pr.append(tabs)
|
ind = OxmlElement("w:ind")
|
ind.set(qn("w:left"), "540")
|
ind.set(qn("w:hanging"), "270")
|
p_pr.append(ind)
|
spacing = OxmlElement("w:spacing")
|
spacing.set(qn("w:after"), "80")
|
spacing.set(qn("w:line"), "300")
|
spacing.set(qn("w:lineRule"), "auto")
|
p_pr.append(spacing)
|
lvl.append(p_pr)
|
if kind == "bullet":
|
r_pr = OxmlElement("w:rPr")
|
rfonts = OxmlElement("w:rFonts")
|
rfonts.set(qn("w:ascii"), "Arial")
|
rfonts.set(qn("w:hAnsi"), "Arial")
|
r_pr.append(rfonts)
|
lvl.append(r_pr)
|
abstract.append(lvl)
|
numbering.append(abstract)
|
num = OxmlElement("w:num")
|
num.set(qn("w:numId"), str(num_id))
|
abs_id = OxmlElement("w:abstractNumId")
|
abs_id.set(qn("w:val"), str(abstract_id))
|
num.append(abs_id)
|
numbering.append(num)
|
return num_id
|
|
|
def apply_num(paragraph, num_id, level):
|
p_pr = paragraph._p.get_or_add_pPr()
|
num_pr = p_pr.find(qn("w:numPr"))
|
if num_pr is None:
|
num_pr = OxmlElement("w:numPr")
|
p_pr.append(num_pr)
|
ilvl = OxmlElement("w:ilvl")
|
ilvl.set(qn("w:val"), str(level))
|
num_id_el = OxmlElement("w:numId")
|
num_id_el.set(qn("w:val"), str(num_id))
|
num_pr.append(ilvl)
|
num_pr.append(num_id_el)
|
|
|
def add_hyperlink(paragraph, text, url):
|
part = paragraph.part
|
r_id = part.relate_to(url, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", is_external=True)
|
hyperlink = OxmlElement("w:hyperlink")
|
hyperlink.set(qn("r:id"), r_id)
|
new_run = OxmlElement("w:r")
|
r_pr = OxmlElement("w:rPr")
|
r_style = OxmlElement("w:rStyle")
|
r_style.set(qn("w:val"), "Hyperlink")
|
r_pr.append(r_style)
|
r_fonts = OxmlElement("w:rFonts")
|
r_fonts.set(qn("w:ascii"), FONT_LATIN)
|
r_fonts.set(qn("w:hAnsi"), FONT_LATIN)
|
r_fonts.set(qn("w:eastAsia"), FONT_EAST_ASIA)
|
r_pr.append(r_fonts)
|
new_run.append(r_pr)
|
t = OxmlElement("w:t")
|
t.text = text
|
new_run.append(t)
|
hyperlink.append(new_run)
|
paragraph._p.append(hyperlink)
|
return hyperlink
|
|
|
def add_page_field(paragraph):
|
r = paragraph.add_run("第 ")
|
set_run_font(r, size=9, color=MUTED)
|
run = paragraph.add_run()
|
fld_char1 = OxmlElement("w:fldChar")
|
fld_char1.set(qn("w:fldCharType"), "begin")
|
instr_text = OxmlElement("w:instrText")
|
instr_text.set(qn("xml:space"), "preserve")
|
instr_text.text = " PAGE "
|
fld_char2 = OxmlElement("w:fldChar")
|
fld_char2.set(qn("w:fldCharType"), "end")
|
run._r.append(fld_char1)
|
run._r.append(instr_text)
|
run._r.append(fld_char2)
|
set_run_font(run, size=9, color=MUTED)
|
r = paragraph.add_run(" 页")
|
set_run_font(r, size=9, color=MUTED)
|
|
|
def add_section_title(doc, text, level=1):
|
p = doc.add_paragraph(style=f"Heading {level}")
|
p.paragraph_format.keep_with_next = True
|
r = p.add_run(text)
|
set_run_font(r, size={1: 16, 2: 13, 3: 12}[level], bold=True, color={1: BLUE, 2: BLUE, 3: DARK_BLUE}[level])
|
return p
|
|
|
def page_break(doc):
|
doc.add_page_break()
|
|
|
def configure_document(doc):
|
section = doc.sections[0]
|
section.page_width = Inches(8.5)
|
section.page_height = Inches(11)
|
section.top_margin = Inches(1)
|
section.right_margin = Inches(1)
|
section.bottom_margin = Inches(1)
|
section.left_margin = Inches(1)
|
section.header_distance = Inches(0.492)
|
section.footer_distance = Inches(0.492)
|
|
normal = doc.styles["Normal"]
|
set_style_font(normal, 11, BLACK, False)
|
normal.paragraph_format.space_before = Pt(0)
|
normal.paragraph_format.space_after = Pt(6)
|
normal.paragraph_format.line_spacing = 1.25
|
|
for name, size, color, before, after in (
|
("Heading 1", 16, BLUE, 18, 10),
|
("Heading 2", 13, BLUE, 14, 7),
|
("Heading 3", 12, DARK_BLUE, 10, 5),
|
):
|
style = doc.styles[name]
|
set_style_font(style, size, color, True)
|
style.paragraph_format.space_before = Pt(before)
|
style.paragraph_format.space_after = Pt(after)
|
style.paragraph_format.keep_with_next = True
|
style.paragraph_format.keep_together = True
|
|
formula = doc.styles.add_style("Formula", WD_STYLE_TYPE.PARAGRAPH)
|
set_style_font(formula, 10.2, INK, False)
|
formula.paragraph_format.left_indent = Inches(0.12)
|
formula.paragraph_format.right_indent = Inches(0.05)
|
formula.paragraph_format.space_before = Pt(4)
|
formula.paragraph_format.space_after = Pt(8)
|
formula.paragraph_format.line_spacing = 1.2
|
|
# Running header and footer: quiet furniture from compact_reference_guide.
|
header = section.header
|
hp = header.paragraphs[0]
|
hp.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
hp.paragraph_format.space_after = Pt(0)
|
r = hp.add_run("股票价格合理性评估操作手册")
|
set_run_font(r, size=8.5, color=MUTED)
|
footer = section.footer
|
fp = footer.paragraphs[0]
|
fp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
fp.paragraph_format.space_before = Pt(0)
|
add_page_field(fp)
|
|
|
def build_document():
|
doc = Document()
|
configure_document(doc)
|
|
# Cover - editorial_cover pattern.
|
p = doc.add_paragraph()
|
p.paragraph_format.space_before = Pt(78)
|
p.paragraph_format.space_after = Pt(16)
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
r = p.add_run("股票估值 · 操作手册")
|
set_run_font(r, size=10.5, bold=True, color=GOLD)
|
|
p = doc.add_paragraph()
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
p.paragraph_format.space_after = Pt(10)
|
r = p.add_run("如何判断一只股票的价格是否合理")
|
set_run_font(r, size=29, bold=True, color=INK)
|
|
p = doc.add_paragraph()
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
p.paragraph_format.space_after = Pt(22)
|
r = p.add_run("从信息取数、利润还原、质量校验,到估值区间与情景收益率")
|
set_run_font(r, size=14, color=DARK_BLUE)
|
|
p = doc.add_paragraph()
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
p.paragraph_format.space_before = Pt(18)
|
p.paragraph_format.space_after = Pt(96)
|
r = p.add_run("一份可以直接照着执行、复算和更新的通用框架")
|
set_run_font(r, size=10.5, italic=True, color=MUTED)
|
|
p = doc.add_paragraph()
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
r = p.add_run("版本 1.0 | 2026-07-30")
|
set_run_font(r, size=10.5, bold=True, color=INK)
|
p = doc.add_paragraph()
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
p.paragraph_format.space_after = Pt(10)
|
r = p.add_run("适用范围:A股为主,兼顾港股、美股;不针对特定证券")
|
set_run_font(r, size=9.5, color=MUTED)
|
add_callout(doc, "重要说明", "本文档用于研究与估值训练,不构成投资建议。估值结果是条件判断,不是价格保证。", "caution")
|
|
page_break(doc)
|
add_section_title(doc, "先看这一页:最终要算出什么", 1)
|
add_body(doc, "判断价格是否合理,不是找一个精确目标价,而是同时回答四个问题:企业能持续赚多少钱、合理估值是多少、当前价格隐含了什么假设、未来收益和下行风险是否匹配。")
|
add_table(
|
doc,
|
["最终输出", "计算结果", "用途"],
|
[
|
["正常化利润", "历史事实经一次性、周期性和会计口径调整后的可持续利润", "决定估值的利润底座"],
|
["合理价值区间", "悲观 / 基准 / 乐观价值,不用单点目标价", "判断当前价格落在哪个区间"],
|
["预期年化收益率", "基于未来利润、退出估值与分红计算 IRR", "与最低要求回报比较"],
|
["反向估值假设", "当前价格隐含的收入增长、利润率、ROIC 或持续年限", "判断市场预期是否现实"],
|
["悲观损失", "悲观价值相对当前价格的跌幅", "确定风险是否可承受"],
|
],
|
[1800, 4320, 3240],
|
aligns=[WD_ALIGN_PARAGRAPH.CENTER, WD_ALIGN_PARAGRAPH.LEFT, WD_ALIGN_PARAGRAPH.LEFT],
|
first_col_bold=True,
|
)
|
add_callout(doc, "核心判定", "只有当基准回报达标、当前价格位于合理区间、隐含假设可实现、悲观损失可承受时,才称为“价格合理”。合理不等于一定值得买;买入通常还需要安全边际。", "positive")
|
|
add_section_title(doc, "30分钟快速路径", 2)
|
add_numbered_list(doc, [
|
"锁定估值日期、当前股价和完全稀释后股数,计算总市值。",
|
"从最新年报、季报和业绩预告得到归母净利润、扣非归母净利润和未来利润范围。",
|
"剔除一次性损益,检查现金流、负债和周期位置,得到正常化利润。",
|
"按行业选一个主估值模型,再选一个交叉验证模型。",
|
"计算悲观、基准、乐观三种情景的价值和5年预期收益率。",
|
"做反向估值,写清楚当前价格要求公司未来做到什么。",
|
"按本手册的判定矩阵得出:低估、合理、偏贵或暂时无法判断。",
|
])
|
|
page_break(doc)
|
add_section_title(doc, "第一部分 建立统一口径", 1)
|
add_section_title(doc, "1.1 先确定估值任务", 2)
|
add_body(doc, "在取数前先写下四个约束,否则同一家公司会被算出互相冲突的结果。")
|
add_table(doc, ["项目", "必须填写", "示例"], [
|
["估值基准日", "股价、资产负债和已知公告截至哪一天", "2026-07-30收盘"],
|
["持有期限", "用多长时间检验价值兑现", "5年"],
|
["最低要求回报 R", "投资者对该风险要求的年化收益", "示例使用10%,须自行替换"],
|
["风险预算", "悲观情景可接受的永久亏损", "不超过20%"],
|
["估值币种", "股价、现金流、折现率必须使用一致币种", "人民币名义口径"],
|
], [1800, 4440, 3120], first_col_bold=True)
|
|
add_section_title(doc, "1.2 股权价值、企业价值和每股价值", 2)
|
add_formula(doc,
|
"总市值 = 当前股价 × 完全稀释后总股数",
|
"企业价值 EV = 总市值 + 有息负债 + 优先股 + 少数股东权益 - 可支配现金 - 非经营性金融资产",
|
"每股价值 = 普通股股权价值 ÷ 完全稀释后总股数")
|
add_bullet_list(doc, [
|
"不要用流通市值代替总市值。限售股同样属于股权价值。",
|
"完全稀释股数应考虑已授予期权、限制性股票、可转债和潜在增发。",
|
"受限资金、客户备付金或维持经营必需的现金,不应全部当作可加回现金。",
|
"使用 EV/EBITDA、EV/EBIT 或 FCFF-DCF 时必须先算企业价值;使用 P/E、P/B、FCFE 时直接对应股权价值。",
|
])
|
add_callout(doc, "常见错误", "把企业价值除以股数直接当每股价值,会漏减债务和少数股东权益,通常高估股票价值。", "risk")
|
|
add_section_title(doc, "1.3 最低要求回报怎么定", 2)
|
add_formula(doc,
|
"要求回报 R = 同币种长期无风险利率 + 股票风险溢价 + 公司特有风险调整",
|
"公司特有风险调整考虑:规模、杠杆、周期、治理、流动性、国家与汇率风险")
|
add_body(doc, "如果没有能力估计各项风险溢价,可以制定固定投资政策,例如低风险、一般风险、高风险分别用不同的要求回报,并对折现率上下浮动2个百分点做敏感性分析。本文示例用10%,只用于演示计算。")
|
|
add_section_title(doc, "第二部分 信息从哪里获取,以及谁优先", 1)
|
add_section_title(doc, "2.1 可靠性和时效性必须分开排序", 2)
|
add_body(doc, "审计年报可靠性高,但可能已经过时;业绩预告更新,却只是区间估计。正确做法是:历史事实以正式披露为准,未来判断用最新信息更新,并保留每个数据的日期、期间和口径。")
|
add_table(doc, ["等级", "信息类型", "主要用途", "处理规则"], [
|
["A1", "交易所披露的审计年报及审计意见", "历史事实、会计政策、附注、分部", "同期间最高优先级;检查非标审计意见"],
|
["A2", "正式半年报、季报、财务报表", "最近已实现业绩和资产负债", "优先于快报、预告和机构预测"],
|
["A3", "业绩快报、预告及修正公告", "尚未出正式财报的近端业绩", "快报通常高于预告;修正公告覆盖原公告"],
|
["A4", "经营数据、重大合同、产销、价格和资本运作公告", "预测销量、价格、产能和股数", "只使用已生效或高概率事项,避免把合同额当收入"],
|
["B1", "业绩说明会、投资者关系活动记录、公司官网", "解释经营驱动和管理层指引", "只能补充,不能替代法定披露"],
|
["B2", "政府、监管机构、海关、行业协会原始数据", "行业需求、价格、渗透率、宏观变量", "优先于二手媒体转述"],
|
["C1", "机构一致预期、数据库聚合", "市场基准和预测分歧", "用最新中位数;核对更新时间和覆盖机构数"],
|
["C2", "单篇券商报告", "详细业务拆分和预测逻辑", "复算关键假设;不把目标价当证据"],
|
["D", "媒体、自媒体、论坛、所谓渠道消息", "发现线索", "不直接进入估值;必须回到原始来源验证"],
|
], [900, 2520, 2520, 3420], font_size=8.8, first_col_bold=True)
|
|
add_section_title(doc, "2.2 同一数据发生冲突时的规则", 2)
|
add_numbered_list(doc, [
|
"先确认是否属于同一报告期、同一会计口径和同一币种。",
|
"同期间优先使用公司在交易所披露的最新正式文件;修正公告覆盖原公告。",
|
"原始文件优先于数据库摘录,数据库只用于检索和批量整理。",
|
"实际数优先于预测数;预测不可与实际重复相加。",
|
"归母、扣非归母、净利润、EBITDA 和自由现金流不可互换。",
|
"不能消除的冲突保留为范围,并降低估值置信度。",
|
])
|
|
add_section_title(doc, "2.3 主要入口", 2)
|
source_rows = [
|
["A股公告", "巨潮资讯、上交所、深交所、北交所", "年报、季报、快报、预告、问询回复、投资者关系记录"],
|
["港股公告", "披露易 HKEXnews", "年报、中报、公告、股本变化"],
|
["美股公告", "SEC EDGAR", "10-K、10-Q、8-K、20-F、6-K"],
|
["行业与宏观", "国家统计局、海关总署、央行、行业监管及协会", "销量、价格、产量、利率、进出口"],
|
["机构预期", "Wind、Choice、iFinD、Bloomberg、FactSet、LSEG等", "一致预期、历史预测、估值可比"],
|
["市场价格", "交易所、公司公告、可信行情终端", "基准日股价、总股本、复权与公司行动"],
|
]
|
add_table(doc, ["市场/用途", "入口", "获取内容"], source_rows, [1440, 3000, 4920], first_col_bold=True)
|
|
add_callout(doc, "留痕要求", "每个关键输入至少记录:数值、单位、报告期、发布日期、来源文件、页码或章节、实际/预测标记、是否经审计、调整说明。", "info")
|
|
add_section_title(doc, "2.4 最小数据包", 2)
|
add_bullet_list(doc, [
|
"最近5个完整年度的利润表、资产负债表、现金流量表及分部数据。",
|
"最近8个季度的收入、归母利润、扣非归母、经营现金流及关键经营指标。",
|
"估值日的完全稀释股数、现金、有息负债、少数股东权益和潜在融资。",
|
"未来2至3年的公司指引、机构一致预期及支撑预测的行业量价数据。",
|
])
|
|
add_section_title(doc, "第三部分 算清利润", 1)
|
add_section_title(doc, "3.1 财报利润链条", 2)
|
add_formula(doc,
|
"营业收入 = 销量 × 售价(或客户数 × 单客收入)",
|
"毛利润 = 营业收入 - 营业成本",
|
"核心经营利润 ≈ 毛利润 - 销售费用 - 管理费用 - 研发费用 - 正常财务费用",
|
"归母净利润 = 净利润 - 少数股东损益")
|
add_body(doc, "直接从利润表取得营业收入、营业利润、利润总额、净利润和归母净利润;再从附注、管理层讨论与分析和分部信息中找到各业务的收入、成本、毛利率及同比变化原因。")
|
|
add_section_title(doc, "3.2 估值要保留的四个利润", 2)
|
add_table(doc, ["利润口径", "计算方法", "作用"], [
|
["报表归母利润", "利润表或主要财务指标中的归母净利润", "与市场常见P/E对齐"],
|
["扣非归母利润", "公司披露的扣除非经常性损益后归母净利润", "正常化利润的起点"],
|
["正常化利润", "扣非归母 + 应加回的真正一次性损失 - 未扣掉的不可持续收益 ± 周期调整", "估计持续盈利能力"],
|
["未来12个月利润 NTM", "未来连续四个季度预计归母或正常化利润之和", "计算前瞻P/E和预期收益"],
|
], [1800, 4860, 2700], first_col_bold=True)
|
|
add_section_title(doc, "3.3 TTM和单季度利润", 2)
|
add_formula(doc,
|
"TTM利润 = 最近完整年度利润 + 本年累计利润 - 上年同期累计利润",
|
"单季度利润 = 本期累计利润 - 上一期累计利润",
|
"预告隐含季度利润 = 本期累计预告区间 - 已披露季度实际利润")
|
add_body(doc, "例:第一季度归母净利润3亿元,半年度预告8至10亿元,则第二季度隐含利润为5至7亿元。基准情景可先用中值6亿元,悲观情景接近5亿元,并检查预告是否包含一次性项目。")
|
|
add_section_title(doc, "3.4 正常化利润调整", 2)
|
add_table(doc, ["调整项目", "常见处理", "证据位置"], [
|
["资产出售、债务重组、诉讼赔偿", "通常剔除;若商业模式本来依赖资产交易则单独判断", "非经常性损益明细、附注"],
|
["政府补助和税收优惠", "判断与主营业务相关性、标准是否明确、能否持续", "其他收益、政府补助、所得税附注"],
|
["公允价值和投资收益", "经营性金融业务可保留;非主营投资单独估值", "投资收益、公允价值附注"],
|
["资产减值", "真正一次性且资产经济价值仍在时才考虑加回;重复减值属于经营成本", "减值明细、资产附注"],
|
["股权激励", "属于股东承担的经济成本,不宜简单加回;同时考虑稀释", "股份支付、股本变动"],
|
["周期性利润", "不用峰值利润;按中周期价格、产能利用率和成本计算", "3至10年历史、行业价格"],
|
["汇兑损益", "经常发生且与经营/负债结构相关时保留正常水平", "财务费用附注"],
|
["少数股东损益", "必须扣除不属于上市公司普通股股东的部分", "利润表、子公司权益结构"],
|
], [1740, 4620, 3000], font_size=8.8, first_col_bold=True)
|
|
add_formula(doc,
|
"正常化归母利润 = 扣非归母利润 + 合理的一次性损失加回 - 未被扣除的不可持续收益 ± 周期和会计口径调整")
|
add_callout(doc, "审慎原则", "不能证明某项损失真正一次性,就不要加回;不能证明某项收益可持续,就不要把它资本化为高估值。", "caution")
|
|
add_section_title(doc, "第四部分 追踪利润来源", 1)
|
add_section_title(doc, "4.1 用利润桥解释变化", 2)
|
add_body(doc, "利润增长只有被拆成业务驱动后才可用于预测。建议把本期相对上期的利润变化拆成以下七项。")
|
add_formula(doc,
|
"利润变化 = 销量贡献 + 售价贡献 + 产品/客户结构贡献 + 单位成本贡献",
|
" + 期间费用贡献 + 财务与税率贡献 + 一次性项目贡献")
|
add_table(doc, ["驱动", "计算思路", "优先信息源"], [
|
["销量", "销量变化 × 上期单位毛利", "公司产销公告、分部数据、行业销量"],
|
["售价", "售价变化 × 本期销量", "公司披露、产品价格、行业价格"],
|
["产品结构", "高毛利产品占比变化造成的毛利变化", "分产品收入和毛利率"],
|
["单位成本", "原材料、人工、折旧和制造费用的单位变化", "成本附注、原材料价格、产能利用率"],
|
["期间费用", "各费用率变化 × 收入", "利润表、费用附注"],
|
["融资与税率", "净利息、汇兑、实际税率变化", "财务费用、所得税附注"],
|
["一次性项目", "非经常性损益的税后归母影响", "非经常性损益明细"],
|
], [1380, 4260, 3720], first_col_bold=True)
|
|
add_section_title(doc, "4.2 未来利润怎么预测", 2)
|
add_formula(doc,
|
"未来收入 = 市场规模 × 市占率;或 销量 × 单价;或 客户数 × 单客收入",
|
"未来毛利 = 未来收入 × 预计毛利率",
|
"未来归母利润 = 毛利 - 期间费用 ± 其他持续项目 - 税 - 少数股东损益")
|
add_numbered_list(doc, [
|
"先预测业务量,不从历史利润增速直接外推。",
|
"将价格、产品结构、单位成本和费用率分别设定,不把所有变化塞进一个净利率。",
|
"完整反映增长所需的资本开支、营运资金和股份稀释。",
|
"把已发生的季度作为实际数,只预测剩余季度,确保不重复计算。",
|
"机构预测使用最近更新的中位数,并记录预测分歧和上调/下调方向。",
|
])
|
|
add_section_title(doc, "4.3 不同类型公司的利润底座", 2)
|
add_table(doc, ["公司类型", "建议利润底座", "避免"], [
|
["成熟稳定企业", "TTM正常化利润 + NTM利润", "过度依赖遥远增长"],
|
["高增长盈利企业", "NTM利润和3至5年业务驱动预测", "直接把最近高增速永久外推"],
|
["强周期企业", "中周期商品价格、成本和产量下的利润", "用景气顶峰P/E判断便宜"],
|
["亏损成长企业", "收入、毛利、单位经济性和达到正现金流所需资金", "用负P/E或远期极端利润"],
|
["金融企业", "正常信用成本、ROE、资本充足率和可分配利润", "机械套用EV/EBITDA"],
|
], [1800, 4500, 3060], first_col_bold=True)
|
|
add_section_title(doc, "第五部分 利润质量与生存能力", 1)
|
add_section_title(doc, "5.1 现金流校验", 2)
|
add_formula(doc,
|
"经营现金含金量 = 3年累计经营现金流 ÷ 3年累计归母净利润",
|
"简化自由现金流 = 经营现金流 - 资本开支",
|
"FCFE = 经营现金流 - 资本开支 + 可持续净借款",
|
"自由现金流收益率 = 正常化FCFE ÷ 总市值")
|
add_body(doc, "现金含金量要看多年累计,避免单季营运资金波动。大于1通常说明利润现金兑现较好,但预收款、金融企业、地产和高速扩张企业需要结合商业模式解释。FCFE中的净借款只能使用与目标资本结构一致的可持续水平,不能用一次性举债美化现金流。")
|
|
add_section_title(doc, "5.2 回报率与增长质量", 2)
|
add_formula(doc,
|
"NOPAT = EBIT × (1 - 正常税率)",
|
"ROIC = NOPAT ÷ 平均投入资本",
|
"投入资本 ≈ 平均(股东权益 + 有息负债 - 可支配现金)",
|
"可持续增长 g ≈ 再投资率 × 新增资本回报率")
|
add_body(doc, "增长只有在新增资本回报率长期高于资本成本时才创造价值。若收入增长依赖大量应收、存货、资本开支或并购,必须降低增长的价值权重。")
|
|
add_section_title(doc, "5.3 负债与稀释", 2)
|
add_formula(doc,
|
"净负债 = 有息负债 - 可支配现金",
|
"净负债/EBITDA = 净负债 ÷ 正常化EBITDA",
|
"利息保障倍数 = EBIT ÷ 利息费用",
|
"股数稀释率 = 完全稀释股数同比增长率")
|
add_callout(doc, "硬门槛", "若公司未来12至24个月存在明显偿债、再融资或持续经营风险,普通相对估值可能失效。此时先做困境概率、债务到期和融资稀释分析,再谈便宜。", "risk")
|
|
add_section_title(doc, "5.4 估值前排雷清单", 2)
|
add_bullet_list(doc, [
|
"经营现金流长期显著低于利润,应收、存货或合同资产异常增长。",
|
"毛利率显著高于同行却缺乏业务解释,或收入与税费、现金不匹配。",
|
"资本化研发、利息资本化或在建工程快速增长,美化当期利润。",
|
"频繁并购、商誉过高、反复减值或依赖出售资产维持利润。",
|
"关联交易、资金占用、担保、非标审计意见或管理层无法保证财报真实完整。",
|
"股份支付、增发、可转债和员工期权导致持续稀释。",
|
"周期顶部利润、高景气价格或异常低信用成本被当成永久水平。",
|
])
|
|
add_section_title(doc, "第六部分 选择正确的估值模型", 1)
|
add_section_title(doc, "6.1 模型选择总表", 2)
|
add_table(doc, ["企业类型", "主模型", "交叉验证", "关键输入"], [
|
["一般非金融盈利企业", "P/E或FCFF-DCF", "EV/EBIT、FCF收益率、反向DCF", "正常化利润、增长、ROIC、负债"],
|
["资本开支较重企业", "FCFF-DCF、EV/EBIT", "FCF收益率、重置价值", "维持性资本开支、周期利用率"],
|
["银行", "P/B-ROE、股利/剩余收益模型", "P/E、股息率", "正常ROE、信用成本、资本充足率"],
|
["保险", "P/EV或剩余收益", "P/B、股息率", "内含价值、新业务价值、偿付能力"],
|
["REIT/物业", "NAV、P/FFO", "股息率、资本化率", "可持续租金、空置率、净负债"],
|
["资源/强周期", "中周期利润、NAV", "EV/EBITDA、股息率", "中周期价格、储量、成本曲线"],
|
["亏损成长企业", "情景DCF、EV/收入", "单位经济性、资金缺口", "毛利率、获客成本、留存、稀释"],
|
["多业务控股公司", "SOTP分部加总", "合并DCF、控股折价", "各分部价值、净负债、税费"],
|
], [1740, 2340, 2520, 2760], font_size=8.7, first_col_bold=True)
|
add_callout(doc, "模型纪律", "至少用一个主模型和一个不同逻辑的交叉验证。若两个结果相差超过25%,先解释口径、周期、负债、资本开支或终值假设,不要直接平均。", "caution")
|
|
add_section_title(doc, "6.2 相对估值的可比对象", 2)
|
add_body(doc, "同行可比不是业务名字相似就够了。至少匹配收入来源、增长、利润率、ROIC、负债、会计口径、地区风险和生命周期。建议用中位数,并说明公司相对同行应该溢价还是折价。")
|
|
add_section_title(doc, "第七部分 估值标准与计算公式", 1)
|
add_section_title(doc, "7.1 P/E与利润收益率", 2)
|
add_formula(doc,
|
"TTM P/E = 总市值 ÷ TTM归母净利润",
|
"正常化P/E = 总市值 ÷ 正常化归母利润",
|
"前瞻P/E = 总市值 ÷ NTM归母利润",
|
"利润收益率 = 正常化利润 ÷ 总市值 = 1 ÷ 正常化P/E")
|
add_body(doc, "合理P/E必须与利润增长、回报率、风险和资本需求匹配。低P/E可能来自周期顶部、利润下滑或高负债;高P/E只有在高质量增长可持续时才合理。")
|
add_table(doc, ["检查项", "计算/观察", "通过标准"], [
|
["历史位置", "当前正常化P/E对比自身5至10年分位", "解释经营结构变化后再比较"],
|
["同行位置", "对比可比公司中位数", "溢折价能由增长、ROIC、风险解释"],
|
["增长匹配", "前瞻P/E与3至5年利润CAGR", "不依赖不现实的长期高增速"],
|
["现金匹配", "利润收益率与FCF收益率", "差异可由营运资金和资本开支解释"],
|
], [1800, 3960, 3600], first_col_bold=True)
|
|
add_section_title(doc, "7.2 EV/EBIT、EV/EBITDA和FCF收益率", 2)
|
add_formula(doc,
|
"EBITDA = EBIT + 折旧摊销",
|
"EV/EBIT = 企业价值 ÷ 正常化EBIT",
|
"EV/EBITDA = 企业价值 ÷ 正常化EBITDA",
|
"企业自由现金流收益率 = FCFF ÷ EV",
|
"股权自由现金流收益率 = FCFE ÷ 总市值")
|
add_bullet_list(doc, [
|
"EV倍数适合资本结构不同的企业比较。",
|
"EBITDA忽略资本开支;重资产企业不能只看EV/EBITDA。",
|
"FCF收益率最接近股东实际可获得现金,但要区分维持性和增长性资本开支。",
|
"同一分析中不得用股权现金流除以EV,也不得用企业现金流除以总市值。",
|
])
|
|
add_section_title(doc, "7.3 P/B与ROE", 2)
|
add_formula(doc,
|
"P/B = 总市值 ÷ 归母净资产",
|
"ROE = 归母净利润 ÷ 平均归母净资产",
|
"稳定期理论P/B ≈ (ROE - g) ÷ (要求回报R - g)")
|
add_body(doc, "P/B主要适合资产与盈利能力关系紧密的银行、保险和部分资产型公司。低P/B若对应低ROE、资产减值或资本约束,并不代表低估。")
|
|
add_section_title(doc, "7.4 股东收益率", 2)
|
add_formula(doc,
|
"股东收益率 = (现金分红 + 净回购) ÷ 总市值",
|
"净回购 = 回购注销金额 - 新股发行及股权融资金额")
|
add_body(doc, "回购只有在未被股权激励和新增发行抵消、且回购价格合理时,才真正增加每股价值。")
|
|
add_section_title(doc, "第八部分 DCF与反向估值", 1)
|
add_section_title(doc, "8.1 FCFF-DCF", 2)
|
add_formula(doc,
|
"FCFF = EBIT × (1 - 税率) + 折旧摊销 - 资本开支 - 营运资金增加",
|
"股权成本 Ke = 无风险利率 + Beta × 股票风险溢价 + 特有风险调整",
|
"WACC = Ke × E/(D+E) + 税前债务成本Kd × (1-税率) × D/(D+E)",
|
"终值 TV = FCFF(n+1) ÷ (WACC - 永续增长率g)",
|
"企业价值 = Σ[FCFF(t) ÷ (1 + WACC)^t] + TV ÷ (1 + WACC)^n",
|
"股权价值 = 企业价值 + 现金及非经营资产 - 债务 - 优先股 - 少数股东权益")
|
add_body(doc, "预测期通常覆盖企业从当前状态过渡到稳定状态的时间。终值增长率必须低于折现率,并与估值币种下的长期经济增长相容。WACC中的E和D使用市场价值权重。现金流和折现率必须匹配:FCFF用WACC,FCFE用股权成本。")
|
|
add_section_title(doc, "8.2 DCF必做的敏感性分析", 2)
|
add_table(doc, ["变量", "基准", "悲观", "乐观"], [
|
["收入增长", "业务驱动中值", "低2至5个百分点", "高2至5个百分点"],
|
["稳态利润率", "历史/同行可实现水平", "成本或竞争恶化", "接近优秀同行但不超常"],
|
["WACC/股权成本", "要求回报对应", "提高1至2个百分点", "降低1个百分点"],
|
["永续增长率", "低于长期名义经济增长", "降低0.5至1个百分点", "最多小幅提高"],
|
["终值占比", "单独披露", "若高于70%降低置信度", "不可用乐观终值掩盖近端弱现金流"],
|
], [1800, 2880, 2340, 2340], first_col_bold=True)
|
|
add_section_title(doc, "8.3 反向DCF", 2)
|
add_body(doc, "反向估值把当前市值设为已知数,反求能使模型成立的收入增长率、利润率、ROIC或竞争优势持续年限。它不是第三个目标价,而是检查市场正在要求公司做到什么。")
|
add_numbered_list(doc, [
|
"将当前总市值转换为企业价值。",
|
"锁定合理的折现率、终值增长率和资本需求。",
|
"反求未来5至10年的收入增长或稳态利润率,使DCF价值等于当前EV。",
|
"把隐含假设与历史最好水平、同行、行业空间和产能约束比较。",
|
"若当前价格只有在乐观假设同时成立时才合理,应判为偏贵。",
|
])
|
add_callout(doc, "最重要的问题", "不要只问“我算出的价值是多少”,还要问“市场价格要求未来发生什么,而这些事情发生的概率有多大”。", "positive")
|
|
add_section_title(doc, "第九部分 预期收益率与最终判定", 1)
|
add_section_title(doc, "9.1 五年持有收益率", 2)
|
add_formula(doc,
|
"第N年EPS = 当前基准EPS × (1 + 可持续增长率)^N",
|
"第N年价格 = 第N年EPS × 合理退出P/E",
|
"简化年化回报IRR = [(第N年价格 + 累计分红) ÷ 当前价格]^(1/N) - 1")
|
add_body(doc, "更精确时应把每年分红按实际时点计入XIRR。退出P/E应使用企业进入更成熟阶段后的正常倍数,通常不应高于当前高增长期倍数。")
|
|
add_section_title(doc, "9.2 安全边际和下行风险", 2)
|
add_formula(doc,
|
"安全边际 = (基准合理价值 - 当前价格) ÷ 基准合理价值",
|
"悲观下行 = (当前价格 - 悲观价值) ÷ 当前价格")
|
add_body(doc, "安全边际需要随不确定性增加。稳定、高透明度企业可以要求较小折价;周期、杠杆、治理或技术不确定性高的企业应要求更大折价。不要把固定20%或30%当作普遍真理。")
|
|
add_section_title(doc, "9.3 默认判定矩阵", 2)
|
add_table(doc, ["结论", "默认量化标准", "必要条件"], [
|
["明显低估", "基准IRR ≥ R+3个百分点,且价格低于基准价值下沿", "隐含假设不激进;悲观损失在风险预算内"],
|
["基本合理", "基准IRR在R上下3个百分点内,且价格位于合理区间", "业务假设可实现;至少两种模型方向一致"],
|
["偏贵", "基准IRR < R-3个百分点,或价格高于合理价值上沿", "只有乐观情景才能达标,或当前价格隐含超常经营"],
|
["无法判断", "主模型与验证模型差异>25%,或利润无法正常化", "信息不足、会计可信度低、重大二元事件未决"],
|
], [1560, 4260, 3540], first_col_bold=True)
|
add_callout(doc, "阈值说明", "3个百分点和25%是便于执行的默认值,可按个人投资政策调整;一旦选定,应在不同股票之间保持一致。", "info")
|
|
add_section_title(doc, "9.4 最终结论必须写成条件句", 2)
|
add_formula(doc,
|
"结论模板:在收入增速__%、正常化净利率__%、要求回报__%、退出P/E__倍的基准假设下,",
|
"合理价值为__至__元,当前价格__元,5年预期IRR为__%,悲观下行__%。",
|
"因此判定为__;若__指标跌破/超过__,需重新估值。")
|
|
add_section_title(doc, "第十部分 完整示例:甲公司", 1)
|
add_body(doc, "以下为虚构公司,仅用于演示。单位为亿元,股数为亿股。估值日价格22.50元,持有期5年,最低要求回报10%。")
|
add_section_title(doc, "10.1 资本结构与利润", 2)
|
add_table(doc, ["项目", "数值", "计算/说明"], [
|
["当前股价", "22.50元", "估值日收盘价"],
|
["完全稀释股数", "10亿股", "含潜在稀释"],
|
["总市值", "225亿元", "22.50 × 10"],
|
["现金", "40亿元", "假设均可支配"],
|
["有息负债", "60亿元", "短债+长债"],
|
["少数股东权益", "5亿元", "企业价值口径需加"],
|
["企业价值EV", "250亿元", "225 + 60 + 5 - 40"],
|
["TTM归母利润", "18亿元", "报表数"],
|
["一次性资产出售收益", "3亿元", "税后归母影响"],
|
["正常化利润", "15亿元", "18 - 3"],
|
["未来12个月基准利润", "16.5亿元", "业务驱动预测"],
|
["正常化FCFE", "13亿元", "用于FCF收益率"],
|
], [2880, 1800, 4680], first_col_bold=True)
|
|
add_section_title(doc, "10.2 相对估值", 2)
|
add_formula(doc,
|
"报表TTM P/E = 225 ÷ 18 = 12.5倍",
|
"正常化P/E = 225 ÷ 15 = 15.0倍",
|
"前瞻P/E = 225 ÷ 16.5 = 13.6倍",
|
"FCF收益率 = 13 ÷ 225 = 5.8%")
|
add_body(doc, "假设公司历史、同行和经营质量支持基准15倍前瞻P/E,则相对估值为16.5 × 15 = 247.5亿元,即每股24.75元。")
|
|
add_section_title(doc, "10.3 简化DCF", 2)
|
add_body(doc, "假设第一年FCFF为16亿元,未来5年增长7%,WACC为10%,永续增长率3%,净债务及少数股东权益合计25亿元。")
|
dcf_rows = [
|
["1", "16.00", "14.55"],
|
["2", "17.12", "14.15"],
|
["3", "18.32", "13.76"],
|
["4", "19.60", "13.39"],
|
["5", "20.97", "13.02"],
|
["终值", "308.60", "191.62"],
|
]
|
add_table(doc, ["年度", "FCFF/终值", "折现现值"], dcf_rows, [1800, 3240, 4320], first_col_bold=True)
|
add_formula(doc,
|
"企业价值 = 68.87 + 191.62 = 260.49亿元",
|
"股权价值 = 260.49 - 25 = 235.49亿元",
|
"每股价值 = 235.49 ÷ 10 = 23.55元")
|
add_callout(doc, "解释", "相对估值24.75元、DCF 23.55元,差异约5%,方向一致。DCF终值占企业价值约74%,因此仍须对WACC和永续增长率做敏感性分析。", "info")
|
|
add_section_title(doc, "10.4 情景收益率", 2)
|
scenario_data = []
|
for name, growth, exit_pe, div in [("悲观", 0.02, 12, 1.80), ("基准", 0.07, 15, 2.25), ("乐观", 0.11, 18, 2.70)]:
|
eps5 = 1.65 * pow(1 + growth, 5)
|
total5 = eps5 * exit_pe + div
|
irr = pow(total5 / 22.5, 1 / 5) - 1
|
scenario_data.append([name, f"{growth:.0%}", f"{eps5:.2f}元", f"{exit_pe}倍", f"{div:.2f}元", f"{irr:.1%}"])
|
add_table(doc, ["情景", "EPS增长", "第5年EPS", "退出P/E", "累计分红", "5年IRR"], scenario_data,
|
[1200, 1440, 1680, 1440, 1680, 1920], font_size=9.0,
|
aligns=[WD_ALIGN_PARAGRAPH.CENTER] * 6, first_col_bold=True)
|
add_body(doc, "基准情景IRR约10.4%,刚超过10%的最低要求;悲观情景仅约1.0%。用基准情景未来总价值36.96元按10%折现,当前合理价格约22.95元。")
|
|
add_section_title(doc, "10.5 反向估值与结论", 2)
|
add_body(doc, "当前22.50元大致要求公司未来5年EPS增长约7%、退出P/E约15倍并累计分红2.25元。这些假设若与历史增长、行业空间、ROIC和资本需求相符,则价格可以被经营事实支撑。")
|
add_callout(doc, "示例结论", "综合DCF 23.55元、前瞻P/E价值24.75元、基准IRR 10.4%,甲公司当前22.50元处于合理区间偏低位置,但安全边际不大,因此判定“基本合理”,而非“明显低估”。若正常化利润下调超过10%或合理退出P/E降至12倍,应重新估值。", "positive")
|
|
page_break(doc)
|
add_section_title(doc, "附录A 一页式计算模板", 1)
|
add_body(doc, "复制本页填写。所有金额必须使用同一币种和单位。")
|
add_table(doc, ["模块", "输入/结果", "数值", "来源/说明"], [
|
["任务", "估值日 / 持有期 / 要求回报 / 风险预算", "", ""],
|
["价格", "当前股价 / 稀释股数 / 总市值", "", ""],
|
["资本", "现金 / 有息负债 / 少数股东权益 / EV", "", ""],
|
["利润", "TTM归母 / 扣非归母 / 正常化利润 / NTM利润", "", ""],
|
["现金流", "CFO / Capex / FCFE / FCF收益率", "", ""],
|
["质量", "现金含金量 / ROIC / 净负债EBITDA / 稀释率", "", ""],
|
["主估值", "模型 / 关键假设 / 价值区间", "", ""],
|
["验证估值", "模型 / 关键假设 / 价值区间", "", ""],
|
["情景", "悲观价值 / 基准价值 / 乐观价值", "", ""],
|
["收益", "悲观IRR / 基准IRR / 乐观IRR", "", ""],
|
["反向", "当前价格隐含增长 / 利润率 / 退出倍数", "", ""],
|
["结论", "低估 / 合理 / 偏贵 / 无法判断", "", "触发重新估值的条件:"],
|
], [1260, 3540, 1740, 2820], font_size=8.8, first_col_bold=True)
|
|
add_section_title(doc, "一页式判定", 2)
|
add_bullet_list(doc, [
|
"基准IRR是否达到最低要求回报?",
|
"当前价格是否位于至少两种模型共同支持的价值区间?",
|
"当前价格隐含的增长、利润率和ROIC是否可实现?",
|
"悲观下行是否处于风险预算内?",
|
"利润是否真实、可持续,并能转换成现金?",
|
"未来12至24个月是否存在偿债、稀释或持续经营风险?",
|
])
|
|
page_break(doc)
|
add_section_title(doc, "附录B 利润正常化与预测工作表", 1)
|
add_section_title(doc, "B1 正常化利润", 2)
|
add_table(doc, ["项目", "金额", "加/减", "税后归母影响", "依据与来源"], [
|
["报表归母净利润", "", "起点", "", ""],
|
["公司披露非经常性损益", "", "减", "", ""],
|
["未扣除的不可持续收益", "", "减", "", ""],
|
["可合理加回的一次性损失", "", "加", "", ""],
|
["周期调整", "", "加/减", "", ""],
|
["会计政策和其他调整", "", "加/减", "", ""],
|
["正常化归母利润", "", "结果", "", ""],
|
], [2520, 1320, 1080, 1800, 2640], font_size=8.8, first_col_bold=True)
|
|
add_section_title(doc, "B2 未来利润驱动", 2)
|
add_table(doc, ["驱动", "最近实际", "悲观", "基准", "乐观", "证据"], [
|
["销量/客户数", "", "", "", "", ""],
|
["售价/单客收入", "", "", "", "", ""],
|
["营业收入", "", "", "", "", ""],
|
["毛利率", "", "", "", "", ""],
|
["销售费用率", "", "", "", "", ""],
|
["管理费用率", "", "", "", "", ""],
|
["研发费用率", "", "", "", "", ""],
|
["财务费用", "", "", "", "", ""],
|
["正常税率", "", "", "", "", ""],
|
["少数股东损益", "", "", "", "", ""],
|
["正常化归母利润", "", "", "", "", ""],
|
], [1800, 1320, 1320, 1320, 1320, 2280], font_size=8.5, first_col_bold=True)
|
|
page_break(doc)
|
add_section_title(doc, "附录C 信息证据登记表", 1)
|
add_table(doc, ["数据项", "数值", "期间", "发布日期", "来源文件/章节", "实际/预测", "等级", "调整说明"], [
|
["当前股价", "", "", "", "", "实际", "A", ""],
|
["完全稀释股数", "", "", "", "", "实际/估计", "", ""],
|
["归母利润", "", "", "", "", "实际", "", ""],
|
["扣非归母", "", "", "", "", "实际", "", ""],
|
["业绩预告", "", "", "", "", "公司估计", "", ""],
|
["机构一致预期", "", "", "", "", "预测", "", ""],
|
["销量/价格", "", "", "", "", "", "", ""],
|
["现金与负债", "", "", "", "", "实际", "", ""],
|
["资本开支", "", "", "", "", "实际/预测", "", ""],
|
["估值参数", "", "", "", "", "假设", "", ""],
|
], [1080, 900, 900, 1080, 1980, 1080, 720, 1620], font_size=7.8,
|
aligns=[WD_ALIGN_PARAGRAPH.CENTER] * 4 + [WD_ALIGN_PARAGRAPH.LEFT] + [WD_ALIGN_PARAGRAPH.CENTER] * 2 + [WD_ALIGN_PARAGRAPH.LEFT],
|
first_col_bold=True)
|
add_callout(doc, "使用规则", "每次财报、预告或重大经营信息发布后,仅更新受影响的输入和情景;保留旧版本,以便检查预测偏差和机构修正。", "info")
|
|
add_section_title(doc, "附录D 无法正常估值的情形", 1)
|
add_bullet_list(doc, [
|
"财务真实性存在重大疑问,或审计意见、监管问询尚未消除。",
|
"公司价值主要取决于单一诉讼、审批、勘探或临床试验等二元事件。",
|
"未来融资缺口巨大,稀释比例无法合理估计。",
|
"商业模式尚未验证,单位经济性为负且没有可信的改善路径。",
|
"主模型与验证模型差异持续超过25%,且不能由周期、负债或口径解释。",
|
"行业规则、价格机制或资产权属发生重大变化,历史利润失去参考意义。",
|
])
|
add_callout(doc, "正确结论", "“无法判断”是一种有效结论。信息不足时强行给出目标价,会把不确定性伪装成精确数字。", "caution")
|
|
page_break(doc)
|
add_section_title(doc, "附录E 权威规则与估值参考", 1)
|
add_body(doc, "以下链接用于查找原始披露规则和估值方法。规则可能修订,使用时应以估值日最新版本为准。")
|
references = [
|
("巨潮资讯网(深交所法定信息披露平台)", "https://www.cninfo.com.cn/"),
|
("上海证券交易所", "https://www.sse.com.cn/"),
|
("深圳证券交易所", "https://www.szse.cn/"),
|
("北京证券交易所", "https://www.bse.cn/"),
|
("香港交易所披露易", "https://www.hkexnews.hk/"),
|
("美国SEC EDGAR", "https://www.sec.gov/edgar/search/"),
|
("证监会《上市公司信息披露管理办法》(2025-07-01起施行)", "https://www.csrc.gov.cn/shanghai/c105565/c7549909/content.shtml"),
|
("证监会《非经常性损益(2023年修订)》", "https://www.csrc.gov.cn/csrc/c101954/c7451398/content.shtml"),
|
("上交所《股票上市规则(2026年4月修订)》", "https://www.sse.com.cn/lawandrules/sselawsrules2025/stocks/mainipo/c/c_20260424_10816589.shtml"),
|
("Damodaran:估值框架与DCF", "https://pages.stern.nyu.edu/~adamodar/New_Home_Page/lectures/val.html"),
|
("Damodaran:每股股权价值的调整", "https://pages.stern.nyu.edu/adamodar/New_Home_Page/littlebook/valuepershare.htm"),
|
("Investor.gov:P/E定义", "https://www.investor.gov/introduction-investing/investing-basics/glossary/price-earnings-pe-ratio"),
|
]
|
num_id = create_numbering(doc, kind="decimal")
|
for title, url in references:
|
p = doc.add_paragraph()
|
apply_num(p, num_id, 0)
|
add_hyperlink(p, title, url)
|
r = p.add_run(f" {url}")
|
set_run_font(r, size=8.5, color=MUTED)
|
|
add_section_title(doc, "最后检查:交付前的十个问题", 2)
|
add_numbered_list(doc, [
|
"估值日期、币种和持有期是否明确?",
|
"总市值是否使用完全稀释后总股数?",
|
"利润是否区分报表、扣非、正常化和未来12个月?",
|
"一次性和周期性项目是否有证据支持调整?",
|
"利润增长是否拆成销量、价格、结构、成本和费用?",
|
"利润是否通过现金流、ROIC、负债和稀释校验?",
|
"是否选对行业模型,并使用不同逻辑交叉验证?",
|
"是否完成悲观、基准、乐观情景和敏感性分析?",
|
"当前价格隐含的经营假设是否明确且可实现?",
|
"结论是否包含合理价值区间、IRR、悲观下行和重新估值触发条件?",
|
])
|
|
# Core properties.
|
doc.core_properties.title = "如何判断一只股票的价格是否合理"
|
doc.core_properties.subject = "股票估值操作手册"
|
doc.core_properties.author = "Codex"
|
doc.core_properties.keywords = "股票估值, 正常化利润, DCF, 反向DCF, 预期收益率, 信息源"
|
doc.core_properties.comments = "版本1.0;通用研究框架;不构成投资建议。"
|
|
doc.save(OUTPUT)
|
return OUTPUT
|
|
|
if __name__ == "__main__":
|
path = build_document()
|
print(path)
|