Files
zszq-trs/corp-action-refactor-proposal.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

150 lines
12 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.
# 债券 TRS 公司行为(Corporate Action)改造方案
> 配套分析:`corp-action-analysis.md`(现状审计)。本文档给出**可落地的改造路线**:
> - 改造一:**债券付息「登记日快照」修复**(当前唯一真 bug,独立低风险)。
> - 改造二:**份额类公司行为(送股/配股/拆股)接入口盘点**(当前完全缺失,改造前须先摸清落点)。
>
> 所有结论均基于源码实测,附 `文件:行号`。
---
## 0. 现状一句话总结
| 维度 | 现状 | 性质 |
|---|---|---|
| 统一 CorporateAction 模型 | 无(全局搜 `CorporateAction`/`CorporateActionType`/`ICorpActionHandler` = 0 命中) | 架构债 |
| 债券付息归属 | **唯一真 bug 是缺登记日快照**(用支付日/期初持仓算票息) | **当前要修的 bug** |
| 股票除权·份额类 | `ex_dividend_info``SwapModule` **零引用**,送股/配股永不生效 | 缺失 |
| 复权 adjust factor | 仅单向 `P/ratio`、无累计因子、不持久化;`HisPriceDividendAdjustEnum` 死代码 | 残缺 |
| 税务 | 两套割裂内联公式,无通用模型 | 不健全 |
**结论**:「债券付息归属」这条链路上——**ex-date 价格自动剔息(行情驱动)正确、pmt-date 现金划付正确、登记日前平仓不享息正确;唯一错在金额锚定的持仓快照日期(用了支付日/期初持仓,应为登记日收盘快照)**。但**整个公司行为处理体系并不都正确**,份额类/复权/税务是独立的、尚未处理的缺口。
---
## 1. 改造一:债券付息「登记日快照」修复(最高优先级 · 低风险)
### 1.1 根因(已实证)
- **模型缺字段**`BondPayment.cs``bond_payment_info`)只有 `payment_date_pl` / `payment_date`**无 `record_date`(债权登记日)/ `ex_date`(除息日)**。
- **归属日期错**`BondPaymentService.GetBondPayments`:101)用 `payment_date` 过滤事件;`SwapEodPositionService.cs:1718 & 1821``CalcBondPayment(..., eod.ValueDate, valueDate, curretEod.PosiQuantity)` —— **`PosiQuantity` 是支付日当天持仓**,登记日→支付日间若有开平仓,归属算错。
- **正确口径(三日期映射)**
- **登记日(record)**:归属截止——按**登记日收盘持仓快照**确认 dividendin / accruedCash,金额在此钉死;
- **除息日(ex)**:价格自动剔息(现金票息仅价格变动、行情源给;份额类才需价格÷因子+数量×因子),**不加 dividendin、无现金**
- **付息日(pmt)**:现金划付(`swap_flow_event.PayDate`),纯结算、**不再重算归属**。
- 注意:**dividendin 不在 ex-date 加**ex-date 与 pmt-date 通常也非同一天);无论收益腿确认日在登记日还是 ex-date,**金额永远按登记日持仓**这一定律不变。
### 1.2 数据模型改动
```csharp
// BondPayment.cs (bond_payment_info)
public DateTime? record_date { get; set; } // 债权登记日(归属截止)
public DateTime? ex_date { get; set; } // 除息日(价格剔息)
// payment_date / payment_date_pl 保留(现金划付日)
```
- 导入侧:`BondPaymentController.cs` 仅 CRUD,存什么由模型决定;上游聚源付息日历**本就含**这三个日期(表带 `jsid`/`channel_source`),加列 + 导入映射即可,低风险。
### 1.3 计算逻辑改动
| 落点 | 改动 |
|---|---|
| `SwapEodPositionService.cs:721` `DealDividends` | `PositionQty` 改用 `eod_swap_position``ValueDate == record_date` 的**登记日快照**(而非支付日 EOD) |
| `SwapEodPositionService.cs:1718 / :1821` `CalcBondPayment` 调用 | 传入的 qty 改为登记日快照数量 |
| `BondPaymentService.GetBondPayments`:101 | **保留**按 `payment_date` 筛事件(决定哪天触发现金);金额一律取登记日快照 |
| 收益腿确认日 | 本系统现状是**登记日**确认 dividendinGLMS 测试 3/2 当日 EventType=4),保持不变;无论确认日在登记日还是 ex-date,**金额恒按登记日持仓** |
### 1.4 验证(回归测试护住)
- 已有 `GLMS20260105PartialCloseDividendBugTest`:验「2/28 平 40% → 3/2 登记日不拿息、剩余 30M 拿 -54,240」。
- 新增「登记日快照」测试:构造**登记日→支付日间有开/平仓**的交易(例如登记日后才买入、或登记日前已平),断言票息只给登记日收盘持有人,验证快照而非支付日持仓。
- 复用既有 `YLErp_UNIT_TEST_SKIP_INITIALIZATION=1` 环境变量跑测(沙箱无 MySQL 亦可)。
### 1.5 影响面
仅修正债券票息**归属日期**(支付日/期初持仓 → 登记日收盘快照);**以下本就正确、一律不动**:ex-date 价格自动剔息(行情源开盘前重置、系统只取已除权价、不手动改价)、pmt-date 现金划付时机、登记日前平仓不享息、税务。
---
## 2. 改造二:份额类公司行为接入口盘点(送股/配股/拆股)
> 当前 `SwapModule` 对 `ex_dividend_info` **零引用**,股票除权的送股/配股字段在 TRS 里永不生效。以下是「若要接入」必须触碰的最小落点。
### 2.1 最大风险:数量只因平仓减少的硬假设
`SwapEodPositionService.cs:1897` 的 EOD 递推式被**写死**
```csharp
var qty = eod.PosiQuantity + openQty - unwindQty; // 仅开仓/平仓两项
curretEod.PosiQuantity = qty < 0 ? 0 : Math.Abs(qty);
// :1919 注释「平仓数量一定<持仓数量」把「数量单调不增」写进分支前提
```
送股使数量**增加**时,EOD 次日用旧 `eod.PosiQuantity` 直接结转 → **份额凭空丢失**
**这是头号风险**:数量入口不打通,`TdChangedQty` 永远为 0,后续成本均价/计息基准根本拿不到触发信号。
### 2.1.1 份额类 ex-date 时序规则(设计约束,避免二次缩放)
接入送股/配股/拆股时,必须遵循以下时序,否则盯市跳变:
1. **因子只作用于"穿越 ex-date 的存量持仓"**(上期收盘持有且未在 ex 前平仓);**ex-date 当日新成交已在新尺度、不吃因子**;ex 前已平仓者不存在。
2. **ratio 在 ex-date 一次性写死**(数量×ratio、成本÷ratio),之后该持仓永久带新数量/新成本,后续每天因子等价=1、无每日重算。
3. **价格调整开盘前生效**(交易所参考价在 ex-date 开盘前重置为除权价,全天成交已在新尺度)。因此持仓 ratio 调整必须作为 **ex-date 当天 EOD 的第一步(盘前)** 执行,**绝不可收盘后补**——否则当天 intraday 成交已被按新尺度记入、再对整仓 ratio 会二次缩放导致盈亏跳变。
4. **系统只调持仓记录(数量/成本),价格取自市场已除权价**,不手动改价(与现金票息的 ex-date 处理一致)。
5. 复权价格序列的 CAF 另算:`CAF(t)=1`(t≥ex-date) / `=ratio`(t<ex-date),活在历史查询;与实时持仓的一次性 ratio 是两件事。
### 2.2 接入口清单(按口径分组)
| 口径 | 落点 | 送股/配股影响 | 改造最小文件 |
|---|---|---|---|
| **数量递推** | `SwapEodPositionService.cs:1897`qty 递推)、`:1723`/`:1905`(无事件日结转)、`:1631`(首次归档) | 需新增「公司行为数量」第三来源项 | `SwapEodPositionService.cs` |
| **`TdChangedQty`** | 定义 `EodSwapPosition.cs:300`DisplayName "当日公司行为数量");唯一赋值 `SwapEodPositionService.cs:1650`(恒=0) | 挂进 :1897 递推式(与 `TdCloseQty`:1942 对称),否则与 `PosiQuantity` 永久不自洽 | `SwapEodPositionService.cs` |
| **成本均价** | `SwapEodPositionService.cs:1926-1936`(加权重算,TRS 无 `CostPrice` 字段,等价字段 `PosiGrossPrice`/`PosiNetPrice`) | 送股无成交金额(分子+0、分母增)→ 走 `:1912 else if` 分支价不摊薄,污染盯市;配股有现金需加 `RationedSharesAmount×Price` | `SwapEodPositionService.cs:1912-1937` |
| **计息基准** | `SwapDealService.cs:893 CalcNotionalByMode`(五种模式分流)、`:1372` `dynomicPrincipal``:2384 InterestPrincipalFix` 仅平仓递减 | `posiLong/Short``PosiNotionalValue` 可自动跟随;**`InterestPrincipalFix` 是独立存量,与数量解耦**,配股缴款需新写入点 | `SwapDealService.cs` |
| **盯市盈亏** | `SwapEodPositionService.cs:1657/1730/1815/2019`4 份同构副本 `PosiMtmPnL`)、`:1713 GetSwapValuationPrice`(取除权后价) | 数量突变日若 `PosiQuantity`/`PosiGrossPrice` 未同步除权 → 虚假巨亏;**4 处副本必须一致改** | `SwapEodPositionService.cs` |
| **数据源** | `DividendService.cs:752 GetPositionAmount`(现成 `amount*(1+GiveShareAmount/10)` 送股调整)、`:730 GetRatio`(现成除权价公式) | SwapModule 未复用,需建调用边 | 新增 SwapModule→DividendService 调用 |
### 2.3 最小改动集(新增"送股"一种行为)
**4 文件 / 5 函数 + 1 条复用边**
1. `SwapEodPositionService.cs``SetPriceInfoByFlowEvent`:1897 qty 递推 + :1926 加权价 + :1650 `TdChangedQty`+ 债券结转 :1723
2. `SwapDealService.cs``UpdateInitalPosition`:2346+ `CalcNotionalByMode`:893
3. `SwapTradeBaseService.cs` — EOD→实时回写(:66/82/90
4. 复用 `DividendService.GetPositionAmount`:752/ `GetRatio`(:730)建数据边,**无需重写除权公式**
### 2.4 现有可复用资产(避免重写)
- `DividendService.GetPositionAmount`:752):`return amount * (1 + info.GiveShareAmount / 10.0);` —— 现成送股份额调整。
- `DividendService.GetRatio`(:730):完整除权价公式,已含送股+配股。
- `EodSettlementService.cs:47/112`:场外期权 EOD 已用 `GetPositionAmount` 调持仓数量(**唯一真正消费份额字段的模块**,可作参考范式)。
---
## 3. 推荐演进路线(分阶段,增量可控)
| 阶段 | 内容 | 风险 | 依赖 |
|---|---|---|---|
| **0** | 改造一:债券付息登记日快照修复(加字段 + 改 `DealDividends` 数量来源 + 回归测试) | 低 | 无 |
| **1** | 抽象统一 `CorporateAction` 实体(`actionType` + 四类日期 record/ex/effective/payment + `factor/splitRatio`),引入按 `actionType` 分发的 typed handler,替 `EventReason="系统操作-分红"` 魔法字符串 | 中 | 阶段0 |
| **2** | 份额类真正接入:打通数量入口(:1897)+ `TdChangedQty` + 成本均价(:1926+ 计息基准同步(:893/:2384)+ 4 处盯市副本一致改(:1657 等) | 中高 | 阶段1 |
| **3** | 复权 adjust factor 持久化(累计因子表)+ 后复权实现(目前 `HisPriceDividendAdjustEnum` 死代码);`GetRatio` 去递归反查、补除零防护、去魔数 10 | 中 | 阶段1 |
| **4** | 税务组件化:统一 `TaxComponent`(按税收居民地/品种差异),替两套割裂内联公式 | 中 | 阶段1 |
> **阶段 0 可立即独立提交**,不阻塞阶段 1–4;阶段 2 的最大难点是「数量只因平仓减少」假设,须优先打通数量入口。
---
## 4. 风险与注意
1. **数量入口是总开关**:不通 `:1897` 递推式,后续成本/计息全拿不到信号 → 阶段 2 先动数量。
2. **`PosiMtmPnL` 四处副本**:1657/1730/1815/2019)必须一致改动,否则不同归档路径口径分裂、盯市跳变。
3. **`InterestPrincipalFix` 是独立存量**(:2384 仅平仓递减),配股缴款若计入本金须新增写入点,不可假设随数量自动跟随。
4. **改造须增量 + 回归测试护住**:参考已建 `SwapCalcTrace` 常驻日志(关键 forensic 落盘)与 golden-record 测试(`GLMS20260105PartialCloseDividendBugTest`)范式,避免「测试覆盖不足时重构引入新 bug」。
---
## 5. 待确认 / 下一步
- [ ] 阶段 0 是否现在落地(加字段 + 改 `DealDividends` + 补「登记日快照」回归测试)?
- [ ] 阶段 2 是否借债券 ETF 设计契机一并启动(ETF 分红同需 record/ex/pmt 三日期)?
- [ ] 税务(阶段 4)当前是否已有业务痛点,决定优先级?