搬迁(算法体逐字未动,仅换命名空间与归属): - SwapInterest.Round/AccrualDays/FundingLegPrecision + AccrualBoundary/InterestResult → YLErpDAL/Modules/SwapModule/Accrual/InterestMath.cs - AccrualTrace → Accrual/AccrualTrace.cs(被迫同迁:其 MarkStart 引用 AccrualBoundary, Core 不能反向依赖 DAL) - 引用切换:Simple/CompoundInterestAccrual、AccrualPolicy、SwapCalcTrace、SwapDealService (保留 using YLErp.Derivatives.Interest——IIndexFixer/IndexFixerBase 留 Core) 删除(零生产引用,孤儿清零): - Core:SwapInterest.cs 算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/ AccrueUnrealized/ToInterestRate,未接线且与 DAL 生产实现舍入/rollover 口径已分叉)、 AccrualContext.cs、InterestRate.cs - DAL:AccrualState.cs(零引用死类) - 测试:SwapInterest_CompoundInArrears_RolloverTimingTests.cs(仅测已删原语) 验证:两解决方案 Rebuild 0 错误;磁盘 SwapInterest. 残留 0;影子/分红/场景 86/86 通过 (含 Accrual 3 影子对账、Margin 影子、divPower 新增 AutoUnwindMultiPartial)。 注:AccrualContext 默认精度 11 与生产 12 的分叉隐患随删除一并消除; 已删原语若将来重建须先补对账测试,勿凭记忆复原(ARCHITECTURE.md 已留警告)。
193 lines
16 KiB
Markdown
193 lines
16 KiB
Markdown
# 债券 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` 筛事件(决定哪天触发现金);金额一律取登记日快照 |
|
||
| 收益腿确认日 | 本系统现状是**登记日**确认 dividendin(GLMS 测试 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` |
|
||
| **计息基准** | mode 分流已重构为 `FundingLegs/FundingLegStrategyFactory`(原 `SwapDealService CalcNotionalByMode` 已删;`dynomicPrincipal` 亦随重构消失)、`InterestPrincipalFix` 仅平仓递减 | `posiLong/Short` 经 `PosiNotionalValue` 可自动跟随;**`InterestPrincipalFix` 是独立存量,与数量解耦**,配股缴款需新写入点 | `SwapDealService.cs` + `FundingLegs/` |
|
||
| **盯市盈亏** | `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」。
|
||
|
||
---
|
||
|
||
## 4.1 阶段1 落地形态建议:独立 `CorporateActions` 模块(新增建议 · **待深度验证与评审**)
|
||
|
||
> ⚠️ 以下为**架构建议草案**,尚未经过逐文件源码复核与评审。仅作方向性参考,落地前须:
|
||
> 1. 逐文件确认现有 `DividendService` / `BondPaymentService` / `ex_dividend_info` 的调用边,避免重复造轮子;
|
||
> 2. 确认 `YLErp.Core` 是否合适承载(须被 OMS / 期权 / TRS 多程序集引用,不能反向依赖业务层);
|
||
> 3. 与**利息核心(复利/部分平仓)**明确划界——见下方"边界警示"。
|
||
|
||
### 4.1.1 为什么必须新模块,而不是往现有屎山堆
|
||
|
||
- 现有 `SwapDealService` / `SwapEodPositionService` 已高度耦合(计息、平仓、EOD 递推、公司行为全搅在一起),继续往里加 if/else 只会放大"隐式不变量跨函数跨日不可见"的风险(这正是"测试绿但全错"的温床)。
|
||
- 公司行为域(除权除息 / 复权 / 付息 / 分红 / 拆股 / 送股 / 配股)在 **OMS、期权、TRS** 多个业务都要用,**必须抽到共享核心程序集**,各业务只消费、不各写一份。
|
||
|
||
### 4.1.2 推荐目录形态
|
||
|
||
```
|
||
YLErp.Core / CorporateActions/ ← 共享核心,被 OMS/期权/TRS 引用,不反向依赖业务层
|
||
CorporateAction.cs # 统一实体:actionType + record/ex/effective/payment 四日期 + factor/splitRatio + 金额
|
||
ICorporateActionSource.cs # 上游数据源适配(聚源付息日历已含全日期,仅做映射)
|
||
ActionType.cs # 枚举:CashDividend / StockDividend / Split / BondCoupon / RightsIssue / Merger ...
|
||
handlers/ # 每种行为一个 typed handler(新增行为 = 加类,不动旧代码)
|
||
CashDividendHandler.cs # 现金分红(含债券 ETF 分红)
|
||
BondCouponHandler.cs # 债券付息(record 日快照归属)
|
||
SplitHandler.cs # 拆股
|
||
StockDividendHandler.cs # 送股
|
||
RightsIssueHandler.cs # 配股
|
||
IAdjustmentFactorProvider.cs # 前复权 / 后复权 / 累计 CAF(t)=1(t≥ex)/=ratio(t<ex)
|
||
EodHooks/ # EOD 收盘链上的接入口(与现有 SwapEodPositionService 解耦的薄适配层)
|
||
IRecordDateHandler.cs # 登记日收盘:按快照确认 dividendin/accruedCash(金额钉死)
|
||
IExDateHandler.cs # 除息日:价格自动剔息 + 穿越持仓因子(开盘前第一步,绝不可收盘后补)
|
||
IPaymentDateHandler.cs # 支付日:纯现金划付,不重算归属
|
||
```
|
||
|
||
### 4.1.3 边界警示(重要,避免域混淆)
|
||
|
||
- **债券 ETF 分红 / 付息 = 公司行为域** → 走 `CorporateActions` 模块。
|
||
- **复利 / 部分平仓 / T+1 本金继承 / 重置日动态本金** = **利息计息域**,现居 `SwapDealService.cs`(`InitSwapDealInterest` / `CalcDailyCompoundInterest` / `CalcUnwindInterest`)+ `SwapEodPositionService.cs`(`SaveAutoEodWithCloseInterestPosition`)。这是**另一回事**,与"公司行为"正交:
|
||
- 付息(coupon)的**金额**由 corp action 决定(按登记日快照);
|
||
- 付息的**利息滚存/复利/部分平仓结算**由利息核心决定。
|
||
- 两者通过 `swap_flow_event`(付息流水)衔接,**不要在 corp action 模块里实现复利逻辑**,也不要在利息核心里硬编码某种公司行为的日期语义。
|
||
- 当前 `_0808` 分支的 4 个复利部分平仓修复(见分支对比分析)属于**利息核心**,与本模块无关;合并时利息核心以 `_0808` 为准,corp action 模块独立演进。
|
||
|
||
---
|
||
|
||
## 5. 待确认 / 下一步
|
||
|
||
- [ ] 阶段 0 是否现在落地(加字段 + 改 `DealDividends` + 补「登记日快照」回归测试)?
|
||
- [ ] 阶段 2 是否借债券 ETF 设计契机一并启动(ETF 分红同需 record/ex/pmt 三日期)?
|
||
- [ ] 税务(阶段 4)当前是否已有业务痛点,决定优先级?
|