ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用python-pptx解析股利理论课件:从PPT到财务计算模型

2026/9/18 17:24:11 拓冰建站 浏览量
用python-pptx解析股利理论课件:从PPT到财务计算模型 简介这份资源是中国人民大学会计系列教材《财务管理学》荆新主编第11章的配套授课课件面向会计、财务管理专业学生及备考相关课程的读者用于系统梳理股利理论与政策这一企业财务决策中的核心章节。压缩包共1个ppt文件约639KB以幻灯片形式呈现课堂讲义框架包含知识点罗列、公式推导与案例数据便于直接用于复习、备课或课堂展示。内容围绕股利及其分配、股利理论、股利政策及其选择、股票分割与股票回购四大板块展开具体涵盖利润分配程序中弥补亏损、计提法定与任意公积金、向股东支付股利的顺序现金股利、股票股利、财产股利与负债股利的区别及对每股收益、每股市价的影响宣布日、股权登记日、除息日与股利发放日的完整流程并以宝钢股份2007年分红方案为实例演示同时结合A股上市公司分红比例偏低、连续多年不分红的现实讨论与成熟市场形成对照帮助读者理解股利政策背后的公司治理与投资价值逻辑。目前已有53人学习。1. 一份股利理论课件为什么值得当成可解析的数据源人大《财务管理学》第 11 章《股利理论与政策》这套课件多数人拿到手的第一反应是打开翻一遍把 11.1 到 11.4 的知识点背下来交差。但换个用法把它当成结构化数据源来处理收获完全不同这份 PPT 里同时躺着三类可编程的东西——可计算模型利润分配顺序、股票股利对每股收益和每股市价的稀释公式、可校验的日期链宝钢股份 2007 年分红从预案公布日到派息日的完整时间轴、以及可复现的统计口径62 家铁公鸡筛选规则从 1826 家一路筛到 62 家。这三类内容恰好对应财务信息化里最常见的三种工作算得对、对得上、查得出。适合读这份材料的人一类是做财务系统或报表工具的工程师需要把制度性的分配顺序翻译成代码分支一类是做教学辅助、题库生成的开发者需要把课件正文里的公式和案例抽成可参数化的模板还有一类是纯自学者想用可运行的数字去验证课件里那些看起来很明白、算起来容易错的结论。课件本身是静态的但里面的每一个数字都能被重算一遍这才是它真正的价值所在。2. 用 python-pptx 拆解课件结构从幻灯片树到知识点索引2.1 先做目录映射别急着抽正文课件的组织方式是典型的章—节—小节三级标题11.1 股利及其分配下面挂 11.1.1 利润分配程序、11.1.2 股利的种类、11.1.3 股利发放程序。这个层级本身就是天然的主键直接按幻灯片顺序把文字全抽出来得到的是一坨没有归属的字符串按标题切段才拿到可检索的知识点索引。处理的思路是先做一张映射表明确每类内容最终要变成什么再决定抽取粒度课件层级原始内容可编程要素落地形式11.1.1利润分配程序四步顺序约束有序步骤函数11.1.2现金/股票/财产/负债股利枚举 计算公式枚举类 计算函数11.1.3宣布日→登记日→除息日→发放日日期链datetime 序列11.2MM 无关论与相关论前提假设集合条件分支11.3股利政策选择决策因子打分/权重表11.4股票分割与股票回购比例变换换算函数这张表决定了一件事不要试图把课件整段转成 Markdown 就完事。公式要单独走一条清洗管线日期单独存成结构化字段案例数字单独进表其余描述性文字才进全文检索。2.2 递归遍历 shape 树提取标题与正文PPT 的文本不在一个平面结构里。组合形状GroupShape会嵌套正文可能被拆成多个占位符标题占位符的idx通常是 0但版式被改过的页面未必如此。所以遍历必须递归并且对没有文本帧的形状直接跳过。from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE def iter_text_shapes(shapes, depth0): 递归展开组合形状产出 (shape, 嵌套深度) for sh in shapes: if sh.shape_type MSO_SHAPE_TYPE.GROUP: yield from iter_text_shapes(sh.shapes, depth 1) else: yield sh, depth def parse_deck(path): prs Presentation(path) index [] for page, slide in enumerate(prs.slides, start1): title, body, tables , [], [] for sh, _ in iter_text_shapes(slide.shapes): if getattr(sh, has_table, False) and sh.has_table: rows [[c.text.strip() for c in r.cells] for r in sh.table.rows] tables.append(rows) continue if not getattr(sh, has_text_frame, False) or not sh.has_text_frame: continue # 段落级拼接段落之间的换行在后续清洗里统一处理 text \n.join( p.text.strip() for p in sh.text_frame.paragraphs if p.text.strip() ) if not text: continue # idx 0 视为标题占位符非占位符形状一律归为正文 if getattr(sh, is_placeholder, False) and sh.placeholder_format.idx 0: title text else: body.append(text) index.append({page: page, title: title, body: body, tables: tables}) return indexiter_text_shapes里的depth主要给调试用嵌套超过两层的形状通常是被包装过的图示抽出来的文字顺序会乱可以单独标记人工核对。parse_deck的容错点在于has_table和has_text_frame都要用getattr兜一层不同版本的库对图形、图片占位符的属性支持并不一致硬写属性名会在某几页直接抛异常。标题判断只认idx 0是有代价的——课件里如果有封面页用了自定义文本框当标题这一页的title会是空串后面按标题建索引时记得跳过空标题页而不是让它们覆盖上一页的标题。2.3 文本清洗与公式归一原始课件里的文字有两个明显特征。一是重复11.1.2 讲现金股利的那段现金股利是以现金支付的股利它是股利支付的主要方式连着出现了两遍二是符号混用公式写成E0÷(1Ds)全角除号、上下标和普通字符混排。这两件事都得在入库前解决否则检索会命中重复条目公式也没法参与计算。import re DUP_TAIL re.compile(r^(.{6,}?)\1$) # 整段自我复制 NBSP re.compile(r[\u00a0\u3000]) MULTISPACE re.compile(r[ \t]{2,})def clean_lines(lines, min_len4): seen, out set(), [] for ln in lines: ln MULTISPACE.sub( , NBSP.sub( , ln)).strip() m DUP_TAIL.match(ln) if m: # 整段复制时只保留前半段 ln m.group(1) key ln.replace( , ) # 去空格后再判重避免排版空格造成漏判 if len(key) min_len or key in seen: continue seen.add(key) out.append(ln) return outmin_len4是为了过滤掉页码、一、这类噪声DUP_TAIL的.{6,}?用了非贪婪匹配长度小于 6 的短句不会误判成复制因为短句重复更可能是正常的排比表述。符号层面做一层归一表就够了不必上符号计算库课件写法归一回说明E0÷(1Ds)E0/(1Ds)除号转斜杠可直接参与计算M÷(1Ds)M/(1Ds)除息后每股市价公式10 派 3.5(含税)cash_per_share 0.35每 10 股派 3.5 元换算到每股税后派 3.15net_cash_per_share 0.315含税口径与税后口径必须分字段存注意含税和税后这两个口径一旦混用后面算除息参考价会直接错一个数量级。建议在数据结构里就用两个字段名把它们钉死不要依赖注释说明。3. 股利计算模型的代码化分配顺序、股票股利稀释与除权除息价3.1 把利润分配程序写成有顺序的函数11.1.1 给的是一个严格顺序弥补亏损 → 计提法定公积金 → 计提任意公积金 → 向股东支付股利。顺序不能调换因为后一步的基数依赖前一步扣完之后的余额。写代码时最容易犯的错是把它写成四个独立计算再相加那样只要某一年亏损没弥补完结果就全错。def allocate(profit_before_tax, accumulated_loss, registered_capital, legal_reserve0.0, tax_rate0.25, legal_ratio0.10, legal_cap_ratio0.50, arbitrary_ratio0.0): 按 11.1.1 的四步顺序推演一次分配返回每步剩余可分配额 net profit_before_tax * (1 - tax_rate) # 税后利润 flow [(税后利润, round(net, 2))] net - min(accumulated_loss, net) # 1 弥补以前年度亏损 flow.append((弥补亏损后, round(net, 2))) cap registered_capital * legal_cap_ratio # 法定公积金计提上限 legal 0.0 if legal_reserve cap else min(net * legal_ratio, cap - legal_reserve) net - legal # 2 计提法定公积金 flow.append((计提法定公积金, round(net, 2))) arbitrary net * arbitrary_ratio # 3 计提任意公积金 net - arbitrary flow.append((计提任意公积金, round(net, 2))) flow.append((可向股东分配, round(net, 2))) # 4 支付股利的上限 return flow参数含义accumulated_loss是年初未弥补亏损用min截断是因为亏损大于当年税后利润时只能弥补一部分剩余结转到下一年legal_cap_ratio对应法定公积金累计额达到注册资本 50% 时可不再计提这条规则legal_reserve传入当前累计余额函数内部用cap - legal_reserve做封顶arbitrary_ratio默认 0因为任意公积金的计提比例由股东会决定没有法定下限。提示allocate返回的是分配上限不是最终方案。真实决策里还要叠加现金余额约束——课件 11.1.2 明确写了支付现金股利除了要有累计盈余外还要有足够的现金账上有利润不等于账上有钱。3.2 股票股利的稀释公式落地股票股利不改变资产总额、负债总额和所有者权益总额改变的是所有者权益的内部结构和股数。课件给的两个公式是核心def after_stock_dividend(e0, m, ds): e0发放前每股收益, m发放前每股市价, ds每股送股比例(10送2 - 0.2) return round(e0 / (1 ds), 4), round(m / (1 ds), 4)ds的口径必须和公告口径对齐公告写10 送 2 转 3那么ds (2 3) / 10 0.5送股和转增在这个公式里是合并计算的因为两者对股数的影响一致区别只在于资金来源是未分配利润还是资本公积。用e01.20、m18.00、ds0.5代进去得到每股收益 0.80、每股市价 12.00股数增加 50%正好印证课件里股数增加、每股收益下降、每股市价下降但股东持股的市场价值总额不变的结论——前提是盈利总额和市盈率不变这个前提不能丢。项目股票股利影响是否变化所有者权益内部结构有影响变股数有影响增加每股收益、每股市价有影响下降每股面值无影响不变资产总额、负债总额、所有者权益总额无影响不变股东持股比例无影响不变3.3 宝钢股份 2007 分红方案的时间轴与除息价课件给的案例是 10 派 3.5含税税后派 3.15预案公布日 2008-03-27股东大会审议日 2008-04-28宣布日 2008-05-07股权登记日 2008-05-12除权除息日 2008-05-13派息日 2008-05-16。这张时间轴的关键在于股权登记日收盘时持有的人有资格拿股利除息日当天买入的人没有价格在除息日开盘时做技术性下调。from datetime import date def ex_rights_price(prev_close, cash_per_share, share_ratio): 除权除息参考价 (前收盘价 - 每股现金股利) / (1 每股送转比例) return round((prev_close - cash_per_share) / (1 share_ratio), 2) # 宝钢案例只有现金分红无送转 print(ex_rights_price(9.80, 0.35, 0.0)) # 9.45 print(ex_rights_price(9.80, 0.315, 0.0)) # 9.48按税后口径同一个前收盘价 9.80含税口径算出 9.45税后口径算出 9.48差 3 分钱。差异来源不是公式错而是股东实际到手多少这个口径不一致——含税口径下扣掉的 0.35 里有 0.035 是代扣的税交易所公布的除息参考价通常按含税股利计算所以对账时先确认对方用的哪个口径。这里share_ratio传 0 是因为宝钢该次方案只派现不送转如果方案是10 派 3.5 送 2cash_per_share 0.35、share_ratio 0.2两步同时生效先减后除顺序同样不能颠倒。4. 复现62 家铁公鸡筛选口径pandas 与 SQL 双路校验4.1 把筛选规则拆成可执行的条件链课件里那段统计描述信息量很大但写成文字后步骤是纠缠的。把它拆成条件链是这样的在 1826 家 A 股公司里先剔除过去三年期末平均未分配利润为负值的剩 1388 家计算三年平均未分配利润 / 三年平均净资产全市场该比值平均为 11.95%剔除低于 11.95% 的再剔除三年平均未分配利润低于 1 亿元的剩 852 家最后剔除三年内有过分红记录的公司和期间新上市的公司得到 62 家。按行业分布化工 8 家居首医药生物 6 家房地产和机械设备各 5 家。筛选环节数据操作易错点剔除亏损avg_up 0用期末值还是三年均值课件用的是均值计算分红能力比值avg_up / avg_na分母是三年平均净资产不是平均总资产门槛过滤ratio 0.1195均值随样本变化硬编码后换年份会失真规模过滤avg_up 1e8亿元与元单位换算分红历史过滤三年内dividend_paid 0曾经分红指预案还是实际派息口径要统一上市年限过滤三年数据齐全新上市公司数据不足三年需单独排除4.2 pandas 实现import pandas as pd MIN_RATIO, MIN_PROFIT 0.1195, 1e8 # df 长表结构code, year, undist_profit(期末未分配利润), net_assets(期末净资产), dividend_paid g df.groupby(code).agg( avg_up(undist_profit, mean), avg_na(net_assets, mean), div_years(dividend_paid, lambda s: (s 0).sum()), yrs(year, nunique), ) pool g[g.avg_up 0].copy() # 剔除平均未分配利润为负 pool[ratio] pool.avg_up / pool.avg_na pool pool[(pool.ratio MIN_RATIO) (pool.avg_up MIN_PROFIT)] iron pool[(pool.div_years 0) (pool.yrs 3)] # 有分红能力但三年未分红 print(len(pool), len(iron)) # 期望 852 / 62agg里用nunique而不是count统计年数是因为财务表里同一家公司同一年可能因为更正公告出现两条记录count会把它算成两年。div_years用匿名函数统计派息大于 0 的年份数直接写(s 0).sum()比先过滤再计数少一次数据搬运。ratio用pool.avg_up / pool.avg_na而不是逐行除是为了避免对已过滤掉的空值做无意义的运算。4.3 SQL 版本与结果对账生产环境里这类筛选往往跑在数仓上把同样的口径翻译成 SQL 便于复用和审计WITH base AS ( SELECT code, AVG(CASE WHEN year BETWEEN 2007 AND 2009 THEN undist_profit END) AS avg_up, AVG(CASE WHEN year BETWEEN 2007 AND 2009 THEN net_assets END) AS avg_na, SUM(CASE WHEN year BETWEEN 2007 AND 2009 AND dividend_paid 0 THEN 1 ELSE 0 END) AS div_years, COUNT(DISTINCT year) AS yrs FROM fin_year GROUP BY code ) SELECT code, avg_up, avg_na, ROUND(avg_up / avg_na, 4) AS ratio FROM base WHERE avg_up 0 AND avg_up / avg_na 0.1195 -- 分红能力比值门槛 AND avg_up 100000000 -- 平均未分配利润不低于 1 亿元 AND div_years 0 AND yrs 3 ORDER BY ratio DESC;跑完两条路径重点比对家数和排序。按课件口径比值最高的是辽宁成大三年年均未分配利润 34.59 亿元、年均净资产 56.7 亿元比值 61.02%紧随其后的七喜控股是 2.77 亿 / 6.95 亿 39.78%比值超过 30% 的一共 16 家。如果两个实现出来的头部名单不一致八成是分母口径或年份范围出了问题而不是筛选逻辑本身。注意课件里的 11.95% 是全市场均值属于样本相关的门槛。把它硬编码进脚本只适合复现当期结论换成其他年份的数据应当先重算均值再套用否则筛出来的公司数量会剧烈漂移。5. 占位符批量替换把课件改造成可复用的股利政策教学模板课件里的案例数字是死的但结构是可复用的。想批量生成不同参数下的讲解版本最省事的做法是把案例页做成占位符模板再用脚本灌数据。坑在于 PPT 的文本经常被拆成多个 run——同一段话说了一半换字体就断成两个 run直接对text_frame.text整体赋值会把字体、字号、颜色全部冲掉。from pptx import Presentation def fill_slide(slide, mapping): run 级替换保留原有字符格式 for sh in slide.shapes: if not getattr(sh, has_text_frame, False) or not sh.has_text_frame: continue for para in sh.text_frame.paragraphs: # 先尝试把跨 run 的占位符合并到首 run避免 {{ }} 被拆开 joined .join(r.text for r in para.runs) for key, val in mapping.items(): if key in joined and not any(key in r.text for r in para.runs): para.runs[0].text joined.replace(key, str(val)) for r in para.runs[1:]: r.text break for run in para.runs: for key, val in mapping.items(): if key in run.text: run.text run.text.replace(key, str(val)) prs Presentation(ch11_template.pptx) ctx {{{EPS0}}: 1.20, {{PRICE}}: 18.00, {{DS}}: 0.5, {{CASH}}: 0.35, {{REG_DATE}}: 2008-05-12} fill_slide(prs.slides[8], ctx) prs.save(ch11_case_v2.pptx)替换分两条路径joined那条处理占位符被 run 拆开的情况代价是丢失该段内部的混合格式所以只在检测到占位符不完整落在任何单个 run 里时才走正常情况走最后那段逐 run 替换格式完全保留。mapping的键用双花括号包住是为了避免正文里正常出现的每股收益被误替换成变量值。生成完必须回读校验不能只看文件能不能打开校验项检查方法失败信号占位符残留全文搜{{有残留说明该段被拆 run 或形状未遍历数值一致性回读页面数字与计算结果比对小数位不符、口径混用格式完整性抽查字体与字号整段变默认宋体说明走了合并分支页数结构len(prs.slides)与模板比对页数变化通常是母版被改写回读校验可以直接复用第 2 章的parse_deck把生成后的文件重新解析成 JSON再和灌进去的ctx做集合比对——模板数量一多人工翻页是翻不过来的。这套流程跑顺之后把ctx换成从 Excel 读进来的一批案例参数一次生成几十份不同分红方案的教学材料也只是循环次数的问题真正需要盯紧的还是那两个老地方股利口径是含税还是税后以及送转比例有没有把送股和转增合并计算。本文还有配套的精品资源点击获取