Files
zszq-trs/corp-action-analysis.md
hjhan 4e0a9d40be docs: 公司行为(corp action)处理能力分析 + 改造方案(登记日快照/份额类接入口)
含两份分析产物:
- corp-action-analysis.md: 现有 corp action 处理覆盖审计(双轨并行/缺统一模型/复权半成品/税务割裂)
- corp-action-refactor-proposal.md: 债券付息登记日快照修复 + 份额类(ex-date盘前因子)接入口盘点
均依后续对话澄清校准了 ex-date 开盘前生效/CAF 两种含义/三日期精确映射。
2026-08-09 10:24:34 +08:00

17 KiB
Raw Permalink Blame History

zszq-trs 公司行为(Corporate Action)处理能力分析报告

分析范围:/Users/hjhan/code/zszq-trsC# / ASP.NET Core net6.0,债券 TRS 期间结算系统 + 场外期权定价) 分析日期:2026-08-09 方法:静态代码考古 + 多子代理并行探索(ex_dividend_info / EodPriceProvider / EOD 收盘链 / 债券付息 / 设计模式)


0. 结论速览(TL;DR

维度 现状 评级
统一 CorporateAction 领域模型 (全局搜索 CorporateAction/CorporateActionType/ICorpActionHandler = 0 命中)
复权(前/后复权) 仅单向 P/ratio(前复权方向);HisPriceDividendAdjustEnum 前后复权枚举零引用(死代码) ⚠️
调整因子 adjust factor 无独立模型、不持久化,每次 GetRatio 现算 ⚠️
现金分红(股票) ex_dividend_info 有字段 + DividendService 公式,但仅服务期权/结算,TRS 未接 🟡
送股 / 配股 / 拆股(份额类) ex_dividend_info 有字段,但全系统仅期权侧数量调整,TRS 完全缺失
债券付息 bond_payment_info + 自动互换事件机制已实现,但归属日期错(缺登记日快照、用支付日/期初持仓算票息) 🟡
税务 两套割裂内联公式(增值税 + 分红税),无通用模型 ⚠️
日期语义(record/ex/effective/payment 仅 payment date(债券)与"股权登记日"(股票),无 record/ex/effective 独立字段
处理时机 日终收盘消费"重新收盘"提示 + "已被执行"状态),无日中实时处理 🟡
设计模式 每种行为一张表 + 一段 if;行为靠魔法字符串 EventReason;无策略/工厂/事件总线

一句话结论:系统对"公司行为"是零散、双轨、而非领域化的实现——债券付息走一套、股票除权走另一套、彼此不相通;TRS 仅实现了现金类的"债券票息→现金流",份额类行为(拆股/送股)在 TRS 侧完全是空位(EodSwapPosition.TdChangedQty 是预留却从未接线的死字段)。没有统一的 CorpAction 抽象,新增一种行为成本高、易引入回归。


1. 数据模型与数据库表

1.1 股票除权除息表 ex_dividend_info

YLErpDAL/Model/ExDividendInfo.cs:8-61

字段 含义
ExDividendDate 命名像"除权日",但 DisplayName 是**"股权登记日"**(差一日,易 T/T-1 错配)
GiveCashAmount 派息金额(现金分红)
GiveShareAmount 送股股数
RationedSharesAmount / RationedSharesPrice 配股股数 / 配股价
ValidStatus 是否有效(软删)
UnderlyingId / UnderlyingCode 标的
DividRate 税率,但 [NotMapped]——不持久化

⚠️ 没有 record date / ex-date / effective date / payment date 的独立字段,只有一个语义含糊的 ExDividendDate

1.2 债券付息表 bond_payment_info

Framework/YLErp.Core/DBModels/BondPayment.cs:15

字段 含义
pay_date_act 实际付息日
paying_interest 每百元利息
paying_principal 每百元本金(含本付息)

⚠️ 也只有付息日,无登记日/除权日(注:缺的是模型层字段,上游聚源付息日历本就含债权登记日/除息日,加列 + 导入映射即可,非数据源问题)。

1.3 缺失:统一 CorporateAction 模型

全仓无任何 CorporateAction 表 / 类型枚举 / 状态机。两种行为各用一张独立表,且字段语义不一致(股票用"股权登记日",债券用"付息日")。


2. 复权(前复权/后复权)与调整因子 adjust factor

2.1 实现现状

YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs:235-261

var dividends = dividendService.GetExDividends(valueDate, unids).ToArray();
foreach (var ep in stockQuery) {
    var dividend = dividends.FirstOrDefault(n => n.UnderlyingId == ep.UnderlyingId);
    if (dividend != null)
        ep.ClosePrice = ep.SettlePrice = dividendService.GetPrice(ep.ClosePrice, dividend);
}
  • GetExDividendsDividendService.cs:768-776)用 ExDividendDate == valueDate 精确等值过滤——只调当日一条,无历史遍历
  • 调整方向:price / ratio(前复权方向,向下调)。
  • ratio 计算(DividendService.cs:710-732,标准 A 股除权参考价公式):
ratio = closePrice / cDivdPrice
cDivdPrice = (closePrice*10 - GiveCashAmount*(1-dividendRate) + RationedSharesAmount*RationedSharesPrice)
             / (10 + GiveShareAmount + RationedSharesAmount)
调整价 = price / ratio
  • 枚举 HisPriceDividendAdjustEnum.cs:6-19 定义了 None / Forward(前复权) / Backward(后复权)全仓库零引用(死代码)——后复权从未实现。

2.2 缺陷

  1. 无累计复权因子、无历史序列复权ExDividendDate == valueDate 只调当日,跨多个除权事件的历史价格序列不连续;业界通行做法是持久化累计因子形成连续序列。
  2. GetRatio 内部又 new EodPriceProvider(info.ExDividendDate) 反查收盘价DividendService.cs:728)——递归/重复查询,潜在开销。
  3. 无除零防护cDivdPrice == 0closePrice == 0 时得 Infinity/NaN
  4. 魔数 10 硬编码(每 10 股基准),公式可读性差、不可配置。
  5. Math.Round(...,4) 反复截断,多次调整累积舍入误差。

「因子」的两种含义(易混,务必分清)实时持仓的 ratio:在 ex-date 一次性PosiQuantity ×ratioPosiGrossPrice ÷ratio 写死进持仓记录,之后该持仓永久带新数量/新成本,后续每天不再有因子(等价=1)、无每日重算;因子只作用于"穿越 ex-date 的存量持仓"ex-date 当日新成交已在新尺度、不吃因子。 ② 复权价格序列的 CAF:是证券历史价格的属性,CAF(t) = ∏(发生在 t 之后的所有公司行为的 ratio)t ≥ ex-date 时 =1(这些价格本就除权后尺度),t < ex-date 时 = ratio(把老价格抬到新尺度可比)。CAF 活在历史、活在查询时,不是天天跑。 本系统当前的「复权」只实现了 ② 的单向(前复权)且每次现算、不持久化;① 完全缺失(TRS 从未做份额类调整)。


3. 分红 / 送股 / 配股的处理

3.1 字段覆盖

ex_dividend_info已含 派息/送股/配股字段,DividendService 也有对应数量调整逻辑(GetPositionAmountDividendService.cs:754amount * (1 + GiveShareAmount/10))。

3.2 实际接线(关键发现)

  • ex_dividend_info / DividendService主要消费方是 EOD 价格复权(EodPriceProvider.cs:251)、期权 FixingFixingService.cs:180)、期权结算时 DividendService.Execute 对 trade 的 Strike/Barrier/SpotPrice 做同比率调整。
  • YLErpDAL/Modules/SwapModuleex_dividend_info / DividendService 零引用 —— 即 TRS 模块完全没有接入股票的送股/配股/分红份额调整。

这意味着:即便股票除权表"看起来"支持送股配股,TRS 持仓也永远不会因送股而增加股数、因配股而改变成本价。


4. 债券付息处理

4.1 数据流

SwapPositionComposeSwapEodPositionService.cs:358)日终编排:DealInterestsDealAutoInterestsDealDividends(:428/:690)。 DealDividends 触发条件 PosiDividendSum != 0:693),复用"分红"通道,最终汇入 SaveAutoSwapDeal(:771)

EventType = (int)SwapEventTypeEnum.自动互换,
EventReason = "系统操作-分红",
PayDate = dividendPayDate,
DividendIn = eodPosi.PosiDividendSum,

AddClientCash(...) 写资金流(:782/:789/:799)。

金额来源:BondPaymentService.CalcPaymentBondPaymentService.cs:122-140)按 payment_date ∈ (startDate, endDate] 取票息 × 持仓数量 ÷ 100,再扣增值税 payment/(1+tax)*(1-tax)。与测试 oracle 一致(BondTrsAutoSwapScenarioTest.cs:2845000万×0.1808/100 = 90,400)。

4.2 "按登记日持仓算"的真相

测试中"部分平仓剩 60% → 54,240"看似是按登记日持仓缩放,实为按付息日当日持仓数量线性缩放——PosiDividendSum 按"前日 + 当日新计 - 当日实现"逐日递推(:1837),当日新计用当日 EOD 持仓 curretEod.PosiQuantity:1821)。系统并未真正建模 record date


5. 税务处理

无通用税务模型,两套割裂机制:

  • 增值税underlying_manager.ValueAddedTax 内联公式 payment/(1+tax)*(1-tax),散布于 SwapEodPositionService.cs:1715/1720/1809/2010SwapDealService.cs:1750
  • 分红税Valuedate.DividendRateValuedate.cs:341-343)全局单一税率,仅用于 DividendService.cs:728

无 withholding/代扣实体、无税种枚举、无按客户/税收居民地的差异化。


6. 各类日期语义(record / ex / effective / payment

语义 系统中表示 位置
payment date BondPayment.pay_date_act / swap_flow_event.PayDate BondPayment.cs:67 / SwapFlowEvent.cs:354
派息资金偏移 trade_extend.ExtendObj.DividendPayDate0=到期结算日/1=派息日+0/2=+1/3=+2 TradeExtend.cs:102-105,换算 SwapEodPositionService.cs:696-700
股权登记日 ex_dividend_info.ExDividendDate(命名歧义) ExDividendInfo.cs:17
record date 无字段(测试里 RegDate 只是本地常量,BondTrsAutoSwapScenarioTest.cs:52
ex-date / effective date

7. 日中 vs 日终处理时机

  • 日终收盘消费ex_dividend_infoController.saveDividend(:57-78) 保存后检查"该股权登记日后了结的交易"→ 提示**"请务必重新收盘"deleteDividend(:87) 检查 checkDividendInfoExecuteStatus→"该条除权信息已被执行,不允许删除"——证实除权信息是日终/收盘时应用**的。
  • TRS 侧:SwapPositionCompose 日终编排,DealDividendsPosiDividendSum != 0 时触发自动互换。
  • 价格侧:EodPriceProvider 取价时按 ExDividendDate == valueDate 调当日。
  • ⚠️ ex-date 价格调整时点(关键):行情源的除权价是开盘前(ex-date 开盘即生效)重置的,ex-date 全天的成交/取价都已在新尺度;系统侧只取市场已除权价、不手动改价。后续若接入份额类(送股/拆股),其持仓 ratio 调整(数量×ratio、成本÷ratio)必须作为 ex-date 当天 EOD 的第一步(盘前) 执行,绝不能在收盘后补——否则当天 intraday 成交已被按新尺度记入、再对整仓 ratio 会二次缩放、盈亏跳变。
  • 日中实时处理公司行为的证据为零:未发现盘中监听公司行为事件并即时调整持仓/损益的逻辑。期权侧 DividendService.Execute 在 trade 创建/调整时调比率,但那是定价调整,非持仓实时复权。

8. TRS 侧公司行为(EodSwapPosition.TdChangedQty

Framework/YLErp.Core/DBModels/EodSwapPosition.cs:295-300

/// 当日公司行为数量,因当日公司行为事件导致的数量变化
[DisplayName("当日公司行为数量")]
public decimal TdChangedQty { get; set; }
  • 全仓仅 2 处引用:定义处 + SwapEodPositionService.cs:1650newEodPayPosition.TdChangedQty = 0;(恒为 0)。
  • TRS 从不做任何持仓数量的公司行为调整。任何改变持仓数量/价格基准的行为(送股、拆股、配股)在 TRS 中完全未实现。

9. 设计模式评估(vs 业界最佳实践)

9.1 现状

  • 无领域抽象:无 CorporateAction 模型/枚举/策略/工厂/事件总线(全局搜索 0 命中)。
  • 行为类型靠魔法字符串EventReason = "系统操作-分红"SwapEodPositionService.cs:712),并被当作过滤键使用(测试 :445/:482)。
  • 双轨并行、互不相通:债券走 bond_payment_infoswap 模块);股票走 ex_dividend_infoYLErpDAL/Model,服务期权/结算)——swap 模块对后者零耦合
  • 硬编码复合公式DividendService.cs:730 含魔数 10;存在"勿改"防御注释(SwapDealService.cs:358/:987)。
  • 唯一亮点:SwapPositionCompose 单一日终编排入口 + SaveAutoSwapDeal 收敛了资金/事件/明细三类写操作(seam 化已落地,可单测)。

9.2 业界最佳实践对照

业界成熟做法通常是:

  1. 统一 CorporateAction 实体actionType + announceDate + exDate + recordDate + paymentDate + effectiveDate + factor/splitRatio/dividendAmount + status + source
  2. 类型化 handlerICorporateActionHandler.Process(position, action),按 actionType 分发(付息/分红/拆股实现同一契约)。
  3. 事件驱动重算:公司行为触发持仓数量、成本价、名义本金、计息基准的自动重算 + 复权因子重算。
  4. 累计复权因子持久化,前/后复权可切换。
  5. 税务组件化、可配置(按账户/税收居民地/券种)。
  6. record-date 持仓快照显式化,用于分红比例计算。

9.3 差距

本系统等价于"每种行为一张表 + 一段 if"

业界 zszq-trs
统一 CorpAction 表 + 类型枚举 两张独立表,字段语义不一致
typed handler 按类型分发 魔法字符串 + PosiDividendSum != 0 隐式判定
事件驱动重算持仓/损益 日终顺序硬编码 + 复用平仓事件结构
累计复权因子 每次现算、单向前复权
税务组件 两套内联公式

10. 新增 corp action 的成本与建议

10.1 新增「拆股 / 送股」的成本:中高(约 24 人周)

难点不在写入 TdChangedQty,而在于数量变化会横穿盯市盈亏、名义本金、成本均价、计息基准、dv01 五个口径,而现有代码对这些量的推导深度耦合于"数量只会因平仓而减少"这一隐含假设(如 SwapEodPositionService.cs:1835 if (curretEod.PosiQuantity > 0) 的递推分支)。

需改动:

  1. 事件枚举扩位(SwapEventTypeEnum / SwapFlowEventTypeEnum)或定义 CorpActionType,摆脱 EventReason 字符串判定;
  2. 数据源接入:让 SwapModule 读取 ex_dividend_info(或新建 corp_action 表补 ex/effective date),当前 TRS 与该表零耦合;
  3. SwapEodPositionService 三条 EOD 分支 SaveCurrentEodInitalPosi / CopyEodPosition(:1700-1745) / UpdateEodPosition(:1787-1860) 均需插入数量与成本价折算,并真正写 TdChangedQty
  4. 同步调整 swap_position 实际持仓、PosiGrossPrice/PosiNetPricePosiNotionalValue、利息腿 TdInterestPrincipal
  5. 前端流水/估值列与 Golden 文件全量重跑。

10.2 建议演进方向(优先级排序)

  1. 抽象统一 CorporateAction 实体:补 announce/ex/record/payment/effective 五类日期 + factor/splitRatio/dividendAmount + status;先让债券付息与股票除权汇入同一张表(迁移 bond_payment_info / ex_dividend_info)。
  2. 引入按 actionType 分发的 handler 接口,付息/分红/拆股实现同一契约;将 EventReason 魔法字符串升级为枚举。
  3. 复权改造:持久化累计复权因子、实现后复权、填 HisPriceDividendAdjustEnum 引用、加除零/空值防护、抽掉魔数 10。
  4. 税务组件化:抽出独立税务计算组件,支持按账户/税收居民地/券种配置;替掉两处内联公式。
  5. record-date 持仓快照显式化:用真实快照算分红比例,替代"按付息日持仓线性缩放"的近似。
  6. TRS 接份额类行为:真正写 TdChangedQty,解除"数量只因平仓减少"的隐含假设。

附录:关键文件索引

内容 文件:行
股票除权除息模型 YLErpDAL/Model/ExDividendInfo.cs:8-61
除权信息入库/删除(重收盘提示/已执行状态) YLErpWeb/Controllers/ex_dividend_infoController.cs:57-104
复权价格调整 YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs:235-261
复权因子公式 YLErpDAL/BLL/.../DividendService.cs:710-732
前/后复权枚举(死代码) Framework/YLErp.Core/Enums/HisPriceDividendAdjustEnum.cs:6-19
债券付息表 Framework/YLErp.Core/DBModels/BondPayment.cs:15
债券付息计算 YLErpDAL/Modules/EodModule/BondPaymentService.cs:122-140
自动互换入口 YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs:358,428,690,771
TRS 当日公司行为数量(死字段) Framework/YLErp.Core/DBModels/EodSwapPosition.cs:295-300:1650
分红税 / 增值税 Valuedate.cs:341-343SwapEodPositionService.cs:1715,1720,1809,2010
事件类型枚举 Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs:9-24

本报告为静态代码分析,未运行系统、未连接数据库;所有结论均基于源码与既有测试,必要时建议对关键链路补集成测试验证。