Files
zszq-trs/corp-action-analysis.md
T
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

254 lines
17 KiB
Markdown
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.
# zszq-trs 公司行为(Corporate Action)处理能力分析报告
> 分析范围:`/Users/hjhan/code/zszq-trs`C# / 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`
```csharp
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);
}
```
- `GetExDividends``DividendService.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 == 0``closePrice == 0` 时得 `Infinity/NaN`
4. **魔数 `10` 硬编码**(每 10 股基准),公式可读性差、不可配置。
5. `Math.Round(...,4)` 反复截断,多次调整累积舍入误差。
> **「因子」的两种含义(易混,务必分清)**:
> ① **实时持仓的 ratio**:在 ex-date **一次性**把 `PosiQuantity ×ratio`、`PosiGrossPrice ÷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` 也有对应数量调整逻辑(`GetPositionAmount``DividendService.cs:754``amount * (1 + GiveShareAmount/10)`)。
### 3.2 实际接线(关键发现)
- `ex_dividend_info` / `DividendService` 的**主要消费方**是 EOD 价格复权(`EodPriceProvider.cs:251`)、期权 Fixing`FixingService.cs:180`)、期权结算时 `DividendService.Execute` 对 trade 的 Strike/Barrier/SpotPrice 做同比率调整。
- **`YLErpDAL/Modules/SwapModule``ex_dividend_info` / `DividendService` 零引用** —— 即 TRS 模块完全没有接入股票的送股/配股/分红份额调整。
> 这意味着:即便股票除权表"看起来"支持送股配股,TRS 持仓也**永远不会**因送股而增加股数、因配股而改变成本价。
---
## 4. 债券付息处理
### 4.1 数据流
`SwapPositionCompose``SwapEodPositionService.cs:358`)日终编排:`DealInterests``DealAutoInterests``DealDividends`(`:428/:690`)。
`DealDividends` 触发条件 `PosiDividendSum != 0``:693`),复用"分红"通道,最终汇入 `SaveAutoSwapDeal`(`:771`)
```csharp
EventType = (int)SwapEventTypeEnum.自动互换,
EventReason = "系统操作-分红",
PayDate = dividendPayDate,
DividendIn = eodPosi.PosiDividendSum,
```
`AddClientCash(...)` 写资金流(`:782/:789/:799`)。
金额来源:`BondPaymentService.CalcPayment``BondPaymentService.cs:122-140`)按 `payment_date ∈ (startDate, endDate]` 取票息 × 持仓数量 ÷ 100,再扣增值税 `payment/(1+tax)*(1-tax)`。与测试 oracle 一致(`BondTrsAutoSwapScenarioTest.cs:284`5000万×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/2010``SwapDealService.cs:1750`
- **分红税**`Valuedate.DividendRate``Valuedate.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.DividendPayDate`0=到期结算日/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` 日终编排,`DealDividends``PosiDividendSum != 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`
```csharp
/// 当日公司行为数量,因当日公司行为事件导致的数量变化
[DisplayName("当日公司行为数量")]
public decimal TdChangedQty { get; set; }
```
- 全仓仅 **2 处引用**:定义处 + `SwapEodPositionService.cs:1650``newEodPayPosition.TdChangedQty = 0;`(恒为 0)。
- **TRS 从不做任何持仓数量的公司行为调整**。任何改变持仓数量/价格基准的行为(送股、拆股、配股)在 TRS 中完全未实现。
---
## 9. 设计模式评估(vs 业界最佳实践)
### 9.1 现状
- **无领域抽象**:无 `CorporateAction` 模型/枚举/策略/工厂/事件总线(全局搜索 0 命中)。
- **行为类型靠魔法字符串**`EventReason = "系统操作-分红"``SwapEodPositionService.cs:712`),并被当作过滤键使用(测试 `:445/:482`)。
- **双轨并行、互不相通**:债券走 `bond_payment_info`swap 模块);股票走 `ex_dividend_info``YLErpDAL/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. **类型化 handler**`ICorporateActionHandler.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 新增「拆股 / 送股」的成本:**中高(约 2–4 人周)**
难点不在写入 `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`/`PosiNetPrice``PosiNotionalValue`、利息腿 `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-343``SwapEodPositionService.cs:1715,1720,1809,2010` |
| 事件类型枚举 | `Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs:9-24` |
---
*本报告为静态代码分析,未运行系统、未连接数据库;所有结论均基于源码与既有测试,必要时建议对关键链路补集成测试验证。*