feat(bond): 添加公司行为现金分红标识并优化付息计算逻辑

- 在 BondPayment 模型中添加 IsCorporateActionCashDividend 属性用于区分不同数据来源
- 修改 BondPaymentService 中的 CalcPayment 方法,按每条记录来源分别处理金额单位转换
- 更新单元测试以验证混合债券付息和公司行为分红的正确计算
- 移除不再需要的 useBondPriceScale 参数简化方法接口
- 修正注释说明以反映新的按记录类型分别计算的逻辑
This commit is contained in:
张名锐
2026-08-21 16:51:55 +08:00
parent 02da9d78a3
commit 392e36a820
5 changed files with 55 additions and 46 deletions
@@ -112,5 +112,13 @@ namespace YLErp.DBModels
[DisplayName("更新时间")]
[Column("update_time")]
public DateTime? update_time { get; set; }
/// <summary>
/// 是否由 ex_dividend_info 在内存中补充的 Stock/Fund 公司行为现金分红。
/// bond_payment_info 原生记录的 paying_interest 按每 100 份存储;公司行为记录的
/// GiveCashAmount 按每 10 份存储。该标记只在运行时区分两种金额单位,不落库。
/// </summary>
[NotMapped]
public bool IsCorporateActionCashDividend { get; set; }
}
}
@@ -29,11 +29,11 @@ namespace YLErp.Modules.EodModule
}
[TestMethod]
public void CalcPayment_StockOrFundDividend_DoesNotApplyBondScale()
public void CalcPayment_CorporateActionDividend_UsesPerTenScale()
{
var payments = new List<BondPayment>
{
new BondPayment { payment_interest = 10m }
new BondPayment { payment_interest = 10m, IsCorporateActionCashDividend = true }
};
// GiveCashAmount=10(每 10 份派 10)时,payment_interest 直接存 10
@@ -42,10 +42,29 @@ namespace YLErp.Modules.EodModule
payments,
qty: 1000m,
longRatio: 1m,
payDirection: 1m,
useBondPriceScale: false);
payDirection: 1m);
Assert.AreEqual(1000m, actual);
}
[TestMethod]
public void CalcPayment_FundMixedBondPaymentAndCorporateAction_UsesEachRecordScale()
{
var payments = new List<BondPayment>
{
// bond_payment_info:每 100 份付 2200000 份应为 4000。
new BondPayment { payment_interest = 2m },
// ex_dividend_info:每 10 份派 10200000 份应为 200000。
new BondPayment { payment_interest = 10m, IsCorporateActionCashDividend = true }
};
var actual = CreateService().CalcPayment(
payments,
qty: 200000m,
longRatio: 1m,
payDirection: 1m);
Assert.AreEqual(204000m, actual);
}
}
}
@@ -144,7 +144,7 @@ namespace YLErp.Modules.EodModule
// 让 Copy/Update EOD 始终只依赖 BondPaymentService,而不必在收盘链路直接累加 ex_dividend_info。
// Stock/Fund 公司行为直接从 ex_dividend_info 读取,内部仍按“每 10 份派现金额”保存 GiveCashAmount 原值,
// 最后的 /10 由 CalcPayment 非债券分支完成
// 最后的 /10 只应用于这类内存补充记录;bond_payment_info 原生记录仍按每 100 份处理
var corporatePayments = (from dividend in DbContext.ex_dividend_info.AsNoTracking()
join underlying in DbContext.underlying_manager.AsNoTracking()
on dividend.UnderlyingCode equals underlying.UnderlyingCode
@@ -169,7 +169,8 @@ namespace YLErp.Modules.EodModule
payment_interest = paymentInterest,
paying_price = paymentInterest,
create_time = dividend.OptDate,
update_time = dividend.OptDate
update_time = dividend.OptDate,
IsCorporateActionCashDividend = true
});
}
return result;
@@ -205,43 +206,35 @@ namespace YLErp.Modules.EodModule
DateTime endDate,
decimal qty,
decimal longRatio,
decimal payDirection,
bool useBondPriceScale = true)
decimal payDirection)
{
var payments = GetBondPayments(underlyingCode, startDate, endDate);
return CalcPayment(payments, qty, longRatio, payDirection, useBondPriceScale);
return CalcPayment(payments, qty, longRatio, payDirection);
}
/// <summary>
/// 计算某标的期间现金流。债券与 Stock/Fund 公司行为共用 bond_payment_info
/// 但通过 useBondPriceScale 明确区分两种入库金额单位
/// 计算某标的期间现金流。债券期间付息与 Stock/Fund 公司行为现金分红可能同时命中
/// 必须按每条记录的来源单位分别计算,不能按标的类型对整个集合统一除以 10 或 100
/// </summary>
/// <param name="payments">期间付息集合</param>
/// <param name="qty">持仓数量</param>
/// <param name="longRatio">多空方向</param>
/// <param name="payDirection">收支方向</param>
/// <param name="useBondPriceScale">
/// 是否按债券报价的百分比口径换算。债券的 payment_interest 是每 100 元面值的票息,
/// 需要继续通过 BondPriceConverter 转成入库金额;Fund/Stock 的公司行为现金分红
/// 在 bond_payment_info 中按“每 10 份派现金额”存储,payment_interest * qty / 10 才是实际现金,
/// 不能再套债券的 /100。默认 true 是为了保持所有历史债券调用方的原有口径。
/// </param>
/// <returns></returns>
public decimal CalcPayment(
List<BondPayment> payments,
decimal qty,
decimal longRatio,
decimal payDirection,
bool useBondPriceScale = true)
decimal payDirection)
{
var interest = payments.Sum(s => s.payment_interest ?? 0);
var paymentAmount = interest * qty;
// 债券:interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额。
// Fund/Stock 公司行为:payment_interest 存的是“每 10 份派现金额”(GiveCashAmount 原值),
// interest × qty 得到“每 10 份派现额 × 持仓份数”,需再 ÷10 才是实际现金;
// 既不能套债券的 /100,也不能直接返回 paymentAmount(那样会放大 10 倍)。
var actualAmount = useBondPriceScale
? BondPriceConverter.ToStorage(paymentAmount)
: paymentAmount / 10;
var actualAmount = (payments ?? new List<BondPayment>()).Sum(payment =>
{
var paymentAmount = (payment.payment_interest ?? 0m) * qty;
// bond_payment_info 原生期间付息按每 100存储;由 ex_dividend_info 补充的
// 公司行为现金分红按每 10 份存储。Fund 标的可能同时命中两类记录,故必须逐条分流。
return payment.IsCorporateActionCashDividend
? paymentAmount / 10m
: BondPriceConverter.ToStorage(paymentAmount);
});
return actualAmount * longRatio * payDirection;
}
}
@@ -2156,18 +2156,13 @@ namespace YLErp.Modules.SwapModule
int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType);
int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode);
// 债券付息按每百元票息存储,继续走 BondPriceConverterStock/Fund 的公司行为
// 现金分红按每 10 份金额存储,实际现金 = payment_interest * qty / 10,不能 /100
// 标的资料缺失时保持旧债券口径,避免未知标的的历史平仓金额被放大。
var useBondPriceScale = um == null
|| !SwapEodPositionService.IsCorporateActionInstrument(um.UnderlyingInstrumentType);
// + 付息日>上日日终且小于等于平仓日期的分红数据
// 期间付息和公司行为现金分红可能同时命中;BondPaymentService 逐条按来源单位换算,
// 原生 bond_payment_info 记录按每 100 份,公司行为表补充记录按每 10 份
var dividendIn = servie.CalcPayment(
payments,
unwindQty,
shortRatio,
directionRatio,
useBondPriceScale);
directionRatio);
decimal tax = um?.ValueAddedTax ?? 0;
dividendIn = DividendCalc.AfterTaxRaw(dividendIn, tax);
@@ -342,25 +342,19 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// 计算期间现金流(生产: BondPaymentService;测试: 返回固定值)。
/// BondPaymentService 的默认仍是债券百分比价格口径;TRS Stock/Fund 的公司行为
/// 分红按每 10 份金额入库,因此必须显式关闭 BondPriceConverter 的 /100 换算。
/// 标的资料缺失时沿用债券口径,避免把未知历史数据放大 100 倍
/// BondPaymentService 会按每条付款记录的数据来源换算:bond_payment_info 原生期间付息
/// 按每 100 份,公司行为表补充的现金分红按每 10 份。不能只按 Fund/Stock 标的类型
/// 选择一个统一除数,否则两类记录同时命中时会错算
/// </summary>
protected virtual decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
var underlying = GetUnderlyingData(underlyingCode);
// 本期现金分红只覆盖 TRS Stock/Fund。其他非债券(期货、期权等)虽然也不属于
// 债券,但尚未接入本现金分红表,继续使用默认债券换算,避免扩大改造范围。
var useBondPriceScale = underlying == null
|| !IsCorporateActionInstrument(underlying.UnderlyingInstrumentType);
return new BondPaymentService(UserInfo).CalcPayment(
underlyingCode,
fromDate,
toDate,
qty,
shortRatio,
directionRatio,
useBondPriceScale);
directionRatio);
}
// ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)----