feat(bond): 支持股票和基金现金分红纳入债券支付计算 - 收紧过度开发行为

- 实现股票/基金现金分红数据从 ex_dividend_info 同步到 BondPayment
- 新增公司行为去重机制,避免镜像任务完成后重复计息
- 统一现金分红存储口径为"每 10 份派现金额",保持与同步任务一致性
- 修改 CalcPayment 方法,股票/基金分红需除以 10 转换实际现金金额
- 添加单元测试验证债券票息和股票/基金分红的不同计算方式
- 更新文档注释说明"每 10 份派现金额"存储规范
- 修复公司行为生效日处理逻辑,确保正确应用除权系数
- 扩展测试覆盖股票类证券的公司行为处理场景
This commit is contained in:
张名锐
2026-08-20 13:30:36 +08:00
parent aa3548e77f
commit b2f4e16782
13 changed files with 384 additions and 368 deletions
+32 -35
View File
@@ -285,7 +285,7 @@ namespace YLErp.Modules.SwapModule
return query.FirstOrDefault();
}
/// <summary>查询 valueDate 当日已经生效的最近有效 Fund EOD。</summary>
/// <summary>查询 valueDate 当日已经生效的最近有效 Stock/Fund EOD。</summary>
protected virtual eod_swap_position FindLatestFundEodPosition(
int tradeId,
long positionId,
@@ -296,7 +296,7 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 查询 valueDate 当天真正生效的 Fund 公司行为。
/// 查询 valueDate 当天真正生效的 Stock/Fund 公司行为。
/// ExDividendDate 只是登记日,盘中基线不能按登记日提前切换;只有
/// EffectiveDate == valueDate 时才把上一 EOD 的 Q/P 转成当日 BOD 的除权后 Q/P。
/// </summary>
@@ -311,7 +311,7 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。
/// Stock/Fund 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。
/// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。
/// </summary>
protected virtual decimal GetFundCorporateActionClosePrice(
@@ -328,10 +328,6 @@ namespace YLErp.Modules.SwapModule
return Convert.ToDecimal(closePrice);
}
/// <summary>读取 Fund 现金分红税率;单元测试可固定为 0,避免依赖系统日期配置。</summary>
protected virtual decimal GetFundDividendTaxRate()
=> new DividendService(this).GetDividendTaxRateDecimal();
/// <summary>
/// 判断最新 EOD 之后是否已有同一浮动腿的完成流水。若有,说明当日实时持仓已发生部分平仓/互换,
/// 不能再把较早 EOD 的数量覆盖回来,否则会抹掉当日成交结果。
@@ -351,22 +347,22 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 恢复实时 Fund 浮动腿到截至指定日有效的 EOD 基线。
/// 恢复实时 Stock/Fund 浮动腿到截至指定日有效的 EOD 基线。
/// 这是唯一允许把 EOD 公司行为结果带入盘中平仓的入口:10 送 10 后 EOD 是 2000 份/50
/// 时,下一日直接使用 2000/50,不再把前端可能传入的 1000/100 或已除权价格重复套系数。
/// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Fund、无 EOD
/// 和固定/利息腿均返回 false,沿用原逻辑。
/// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Stock/Fund、无
/// EOD 和固定/利息腿均返回 false,沿用原逻辑。
/// </summary>
protected virtual bool TryRestoreEffectiveFundPosition(
swap_position position,
DateTime valueDate)
{
// 只对收取方向的 Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。
// 只对收取方向的 Stock/Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。
// 无历史 EOD 或最新 EOD 后已有完成流水时返回 false,由调用方保持实时持仓原值,
// 不伪造一份快照,也不把较早的 2000 份/50 覆盖掉当日已经部分平仓后的实时数量。
if (position == null
|| position.PosiDirection <= 0
|| position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund)
|| !SwapEodPositionService.IsCorporateActionInstrument(position.UnderlyingInstrumentType))
{
return false;
}
@@ -405,15 +401,16 @@ namespace YLErp.Modules.SwapModule
if (closePrice <= 0)
{
throw new ServiceException(
$"Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权");
$"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权");
}
var dividendTaxRate = GetFundDividendTaxRate();
SwapEodPositionService.ApplyFundCorporateActionToPosition(
// TODO: 现金模式不使用税率参与 Q/P 除权;价格调整模式启用后再根据需求 考虑接入该配置。
// var dividendTaxRate = GetFundDividendTaxRate();
SwapEodPositionService.ApplyCorporateActionToPosition(
position,
corporateAction,
closePrice,
dividendTaxRate);
0m);
}
return true;
@@ -445,7 +442,7 @@ namespace YLErp.Modules.SwapModule
if (requestedQty < 0m || (!fullClose && requestedQty > effectiveQty))
{
throw new ServiceException(
$"Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}");
$"Stock/Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}");
}
var closeQty = fullClose ? effectiveQty : requestedQty;
@@ -550,10 +547,10 @@ namespace YLErp.Modules.SwapModule
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault();
// Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50
// Stock/Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50
// 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。
var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 恢复失败表示非 Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值,
var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 恢复失败表示非 Stock/Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值,
// 继续原有盘中流程,避免用不完整快照制造数量/价格。
var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps);
var hasProcess = HasTradeProcess();
@@ -590,8 +587,8 @@ namespace YLErp.Modules.SwapModule
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
// 现金分红会调整 EOD 期初价但不改数量,因此持仓名义本金可能从 100000 变为 99000。
// 只有 Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。
unwindData.PosiNotionalValue = restoredFundBaseline
// 只有 Stock/Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。
unwindData.PosiNotionalValue = restoredCorporateActionBaseline
? position.PosiNotionalValue
: Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
@@ -783,8 +780,8 @@ namespace YLErp.Modules.SwapModule
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
// 收益结算与手工平仓共用 Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。
var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 收益结算与手工平仓共用 Stock/Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。
var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD
// 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。
//var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期
@@ -823,7 +820,7 @@ namespace YLErp.Modules.SwapModule
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = restoredFundBaseline
unwindData.PosiNotionalValue = restoredCorporateActionBaseline
? position.PosiNotionalValue
: Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
@@ -1829,13 +1826,13 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
// 提交时再次从有效 EOD/实时腿复核基线,不能只相信前端缓存的数量和价格。
var restoredFundBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate);
// 这是直接提交路径的最后一道复核。若返回 false(非 Fund、无快照、或 EOD 后已有完成流水),
// 提交时再次从有效 EOD/实时腿复核 Stock/Fund 基线,不能只相信前端缓存的数量和价格。
var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate);
// 这是直接提交路径的最后一道复核。若返回 false(非 Stock/Fund、无快照、或 EOD 后已有完成流水),
// 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。
if (restoredFundBaseline)
if (restoredCorporateActionBaseline)
{
// 正式提交必须让交易级余额与同一 Fund EOD 基线一致,再执行原有扣减。
// 正式提交必须让交易级余额与同一 Stock/Fund EOD 基线一致,再执行原有扣减。
// 例:派现后有效名义本金为 99000,平掉一半 49500 后应剩 49500
// 若仍从 trade 旧值 100000 扣减,会错误留下 50500。
td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue);
@@ -1902,7 +1899,7 @@ namespace YLErp.Modules.SwapModule
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
// 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Fund EOD。
// 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Stock/Fund EOD。
// 因此它不具备手工 SwapUnwind 的 EOD 复核保护,生产上需确保自动流水已在正确的 EOD 基线之后生成。
var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType)
? ConsGlobal.PriceRound
@@ -2007,7 +2004,7 @@ namespace YLErp.Modules.SwapModule
int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode);
// 债券付息按每百元票息存储,继续走 BondPriceConverterStock/Fund 的公司行为
// 现金分红按每 10 份金额存储,实际现金就是 payment_interest * qty,不能 /100。
// 现金分红按每 10 份金额存储,实际现金 = payment_interest * qty / 10,不能 /100。
// 标的资料缺失时保持旧债券口径,避免未知标的的历史平仓金额被放大。
var useBondPriceScale = um == null
|| !SwapEodPositionService.IsCorporateActionInstrument(um.UnderlyingInstrumentType);
@@ -2326,7 +2323,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
// 正常页面先由 InitIncome 读取最近有效 Fund EOD;本提交方法本身不再重读快照,
// 正常页面先由 InitIncome 读取最近有效 Stock/Fund EOD;本提交方法本身不再重读快照,
// 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权
// 不会在这里回写,属于审批链路的残余风险。
ValidateIncomeValueDate(unwindData, td);
@@ -2365,7 +2362,7 @@ namespace YLErp.Modules.SwapModule
{
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
// 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Fund EOD 重建数量和价格。
// 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Stock/Fund EOD 重建数量和价格。
// 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线;
// 直接提交路径的 EOD 复核不覆盖此审批路径。
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
@@ -2462,7 +2459,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
// 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Fund EOD 复核。
// 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Stock/Fund EOD 复核。
// 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。
if (eventType == (int)SwapEventTypeEnum.)
{