feat(bond): 支持债券与股票基金公司行为现金流计算的差异化处理 - init2

- 修改 CalcPayment 方法添加 useBondPriceScale 参数区分债券和股票/基金的金额计算口径
- 债券利息按每100元面值票息通过BondPriceConverter转为入库金额,股票基金分红直接计算
- 在BondPaymentService中添加详细的参数说明文档注释
- 更新SwapDealService中分红计算逻辑,根据标的类型自动选择合适的金额转换方式
- 新增CorporateActionEventLifecycleTest单元测试验证公司行为事件生命周期管理
- 添加SplitCorporateActionTddTest测试验证拆合股功能
- 优化FundCorporateActionRollbackAndUnwindTest扩展到股票类型测试
- 更新前端OperationHistory页面表格列宽和显示格式支持更长的说明信息
This commit is contained in:
张名锐
2026-08-19 17:48:34 +08:00
parent ddf233678d
commit aa3548e77f
19 changed files with 1342 additions and 104 deletions
@@ -748,44 +748,68 @@ namespace YLErp.Modules.TradeModule.DealModule
internal readonly struct CorporateActionFactors
{
public CorporateActionFactors(decimal priceRatio, decimal shareFactor)
public CorporateActionFactors(decimal priceRatio)
{
PriceRatio = priceRatio;
ShareFactor = shareFactor;
}
public decimal PriceRatio { get; }
public decimal ShareFactor { get; }
}
/// <summary>
/// 统一计算公司行为的价格系数和数量系数。价格系数沿用原股票除权公式;
/// 数量仅受送股/拆合股影响,配股仍只进入价格公式,保持现有业务口径不变。
/// 按 Excel 公式计算公司行为的除权系数。
/// GiveShareAmount 只表示每 10 份的送股数量,Split 表示独立的拆/合股倍数;
/// Split 为空按 1 兼容历史记录。TRS Stock/Fund 使用 PriceRatio 同时调整期初价格
/// 和持仓数量,不再维护独立的旧数量系数。
/// <para>
/// 送股例子:收盘价 100、每 10 份送 10 份、无现金/配股时,除权参考价为 50,
/// PriceRatio=100/50=2ShareFactor=2。调用方据此把 1000 份/期初价 100 调整为
/// 2000 份/50;数量与价格反向变化,期初名义本金仍为 100000。
/// </para>
/// <para>
/// 现金例子:收盘价 100、每 10 份派现 10、税率 0 时,除权参考价为 99,
/// ShareFactor 仍为 1,所以数量不变,只把期初价按 100/99 的价格系数下调。
/// 现金分红不参与 TRS Stock/Fund 的期初价格公司行为系数;现金权益由既有分红流水单独处理。
/// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。
/// </para>
/// </summary>
internal static CorporateActionFactors CalculateCorporateActionFactors(
ex_dividend_info info,
decimal closePrice,
decimal dividendRate)
decimal dividendRate,
bool adjustCashDividendPrice = true)
{
// (收盘价 * 10) - 现金分红 * (1 - 税率) + (配股数量 * 配股价格)
// -------------------------------------------------------
// (10 + 送股数量 + 配股数量)
var exDividendPrice = (closePrice * 10m - info.GiveCashAmount * (1m - dividendRate)
// 价格调整模式除权参考价 =
// 收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价
// - -----------------------------------------------------
// (10 + 送股数 + 配股数) * 拆股倍数
// 场内链路默认继续把现金派息计入除权参考价;
// TRS Stock/Fund 现金模式显式关闭该项 :“【】” 号内数据。
var cashPriceAdjustment = adjustCashDividendPrice
? info.GiveCashAmount * (1m - dividendRate)
: 0m;
// 拆股倍数
var splitFactor = GetSplitFactor(info);
// 除权参考价(TRS
// 收盘价 * 10 + 配股数 * 配股价
// ------------------------------
// (10 + 送股数 + 配股数) * 拆股倍数
var exDividendPrice = ((closePrice * 10m - cashPriceAdjustment
+ info.RationedSharesAmount * info.RationedSharesPrice)
/ (10m + info.GiveShareAmount + info.RationedSharesAmount);
/ (10m + info.GiveShareAmount + info.RationedSharesAmount))
/ splitFactor;
// 除权系数 = 股权登记日收盘价 / 除权除息参考价
var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice;
var shareFactor = 1m + info.GiveShareAmount / 10m;
return new CorporateActionFactors(priceRatio, shareFactor);
return new CorporateActionFactors(priceRatio);
}
private static decimal GetSplitFactor(ex_dividend_info info)
{
if (info == null)
{
throw new ArgumentNullException(nameof(info));
}
if (info.Split.HasValue && info.Split.Value <= 0m)
{
throw new ArgumentOutOfRangeException(nameof(info.Split), "拆/合股倍数必须大于 0");
}
// Split 为空表示未提供拆合股信息,按 1 兼容历史记录;例如 Split=0.1 时,
// 1000 份/100 元调整为 100 份/1000 元。0 或负数无法表达有效份额比例,直接拒绝。
return info.Split ?? 1m;
}
/// <summary>
@@ -798,10 +822,9 @@ namespace YLErp.Modules.TradeModule.DealModule
}
/**
* 在没有现金分红和配股时:
* 拆合股:10*closePrice / 10+GiveShareAmount
* 调整后数量 = 原数量 × 除权系数
* 调整后价格 = 原价格 ÷ 除权系数
* GiveShareAmount 表示每 10 份送股数量,Split 表示独立拆/合股倍数(空值按 1);
* 调整后数量 = 原数量 × (1 + GiveShareAmount / 10) × Split
* 调整后价格 = 原价格 ÷ 上述数量系数(配股只参与非现金价格公式)。
*/
private decimal GetRatioDecimal(ex_dividend_info info)
{
@@ -833,9 +856,12 @@ namespace YLErp.Modules.TradeModule.DealModule
/// <returns></returns>
public double GetPositionAmount(double amount, ex_dividend_info info)
{
// 数量只按送股/拆合股调整,现金分红和配股不增加持仓数量;10 送 10 时
// 1000 份变为 2000 份,价格系数由 GetRatioDecimal 单独计算,不能在此重复套用。
var result = (decimal)amount * (1m + info.GiveShareAmount / 10m);
// 这是旧场内/兼容链路的数量接口;TRS Stock/Fund 不走这里,而是在
// SwapEodPositionService 中按 Excel公式 使用 PriceRatio。旧链路数量只按
// 送股和独立拆合股调整,现金分红和配股不增加持仓数量。
var result = (decimal)amount
* (1m + info.GiveShareAmount / 10m)
* GetSplitFactor(info);
return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero);
}
@@ -900,6 +926,7 @@ namespace YLErp.Modules.TradeModule.DealModule
: (DateTime?)null,
GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0,
GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0,
Split = decimal.TryParse(getColValueFromTable(dt.Rows[i], "拆/合股倍数"), out var split) ? split : (decimal?)null,
RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0,
RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0,
OptId = OptUser.UserId,
@@ -922,6 +949,10 @@ namespace YLErp.Modules.TradeModule.DealModule
{
throw new ServiceException($"第{i + 1}行真实除权日不正确");
}
if (info.Split.HasValue && info.Split.Value <= 0m)
{
throw new ServiceException($"第{i + 1}行拆/合股倍数必须大于0");
}
if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date)
{
throw new ServiceException($"第{i + 1}行真实除权日不应早于股权登记日");
@@ -985,6 +1016,11 @@ namespace YLErp.Modules.TradeModule.DealModule
{
target.RationedSharesPrice = source.RationedSharesPrice;
}
if (source.Split.HasValue)
{
// Split 为空表示本次未提供,不能按历史兼容值 1 清空或覆盖旧倍数;明确提供 1 才覆盖。
target.Split = source.Split.Value;
}
if (source.EffectiveDate.HasValue)
{
// EffectiveDate 是日期语义,导入/接口可能带时分秒;统一只保留自然日。
@@ -1026,6 +1062,11 @@ namespace YLErp.Modules.TradeModule.DealModule
errMsg = "股权登记日信息不存在";
return false;
}
if (item.Split.HasValue && item.Split.Value <= 0m)
{
errMsg = "拆/合股倍数必须大于0";
return false;
}
// 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间
// 能命中同一个自然日业务键,也与数据库的一行模型保持一致。
@@ -1049,6 +1090,12 @@ namespace YLErp.Modules.TradeModule.DealModule
item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6);
item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6);
item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6);
if (item.Split.HasValue)
{
// 拆合股比例可能为 0.01、0.001 等小数,保留 12 位避免导入时
// 被 6 位金额精度截断;日期字段则在上方统一归一化为自然日。
item.Split = OtcFormatHelper.FormatValue(item.Split.Value, 12);
}
// 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录
// 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。
@@ -1177,6 +1224,37 @@ namespace YLErp.Modules.TradeModule.DealModule
/// <returns></returns>
public bool checkDividendInfoExecuteStatus(ex_dividend_info info)
{
// TRS 公司行为以 EffectiveDate 为真正生效边界。登记日创建待生效事件不应锁定
// 维护;只有交易已经完成 EffectiveDate(例如收盘到 7 月 30 日,而真实除权日为
// 7 月 29 日)才禁止修改,避免修改后无法解释已落库的调整前后快照。
if (info?.EffectiveDate.HasValue == true)
{
var effectiveDate = info.EffectiveDate.Value.Date;
var trsTradeIds = DbContext.trade
.Where(x => x.ValidState != ConsGlobal.InValid
&& x.TradeType == "收益互换"
&& x.UnderlyingCode == info.UnderlyingCode
&& x.TradeDate <= effectiveDate
&& x.ExerciseDate >= effectiveDate)
.Select(x => x.id)
.ToList();
if (trsTradeIds.Count > 0)
{
// 是否仍被交易引用以当前有效 EOD 为准。公司行为事件本身是不可篡改
// 历史,交易回退后仍会保留;若仅凭 Applied 事件锁定,回退到登记日前
// 也无法纠错。生效日及以后还有有效 EOD 才表示当前仍已执行。
var hasAppliedEod = DbContext.eod_swap_position.Any(x =>
trsTradeIds.Contains(x.SwapTradeId)
&& !x.Invalid
&& x.UnderlyingCode == info.UnderlyingCode
&& x.ValueDate >= effectiveDate);
if (hasAppliedEod)
{
return true;
}
}
}
var eodStatus = DbContext.eodStatus.Where(O => O.ValueDate == info.ExDividendDate && O.OptDate > info.OptDate).Any();
if (eodStatus)
{