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
@@ -0,0 +1,51 @@
using YLErp.DBModels;
namespace YLErp.Modules.EodModule
{
[TestClass]
public class BondPaymentServiceCalculationTest
{
private static BondPaymentService CreateService()
{
return new BondPaymentService(
new OptUserInfo(0, nameof(BondPaymentServiceCalculationTest), OptUserFrom.UnitTest));
}
[TestMethod]
public void CalcPayment_BondCoupon_KeepsPerHundredScale()
{
var payments = new List<BondPayment>
{
new BondPayment { payment_interest = 1m }
};
// 债券票息 1 表示每 100 元面值付 1 元:1 * 1000 / 100 = 10。
var actual = CreateService().CalcPayment(
payments,
qty: 1000m,
longRatio: 1m,
payDirection: 1m);
Assert.AreEqual(10m, actual);
}
[TestMethod]
public void CalcPayment_StockOrFundDividend_DoesNotApplyBondScale()
{
var payments = new List<BondPayment>
{
new BondPayment { payment_interest = 10m }
};
// GiveCashAmount=10(每 10 份派 10)时,payment_interest 直接存 10
// 持仓 1000 份的现金分红 = 10 * 1000 / 10 = 1000,不能再套债券报价的 /100 换算。
var actual = CreateService().CalcPayment(
payments,
qty: 1000m,
longRatio: 1m,
payDirection: 1m,
useBondPriceScale: false);
Assert.AreEqual(1000m, actual);
}
}
}
@@ -113,7 +113,7 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
public void OperationHistory_FiltersPendingCorporateActionOnly()
public void OperationHistory_PreservesPendingCorporateActionForAudit()
{
var info = CreateAction(80, ConsGlobal.InstrumentType.Stock);
var pendingData = SwapEodPositionService.BuildCorporateActionEventData(
@@ -133,12 +133,12 @@ namespace YLErp.Modules.SwapModule
new swap_event { id = 3, EventType = (int)SwapEventTypeEnum., EventData = "{}" }
};
var visible = SwapEventService.FilterOperationHistory(events);
Assert.AreEqual(2, visible.Count);
CollectionAssert.DoesNotContain(visible.Select(x => x.id).ToList(), 1L);
CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 2L);
CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 3L);
// 操作历史不再隐藏登记日待生效事件;Applied=false 是事件状态,不是展示过滤条件。
Assert.AreEqual(3, events.Count);
Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[0], out var pendingSnapshot));
Assert.IsFalse(pendingSnapshot.Applied);
Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[1], out var appliedSnapshot));
Assert.IsTrue(appliedSnapshot.Applied);
}
[TestMethod]
@@ -158,7 +158,7 @@ namespace YLErp.Modules.SwapModule
var info = CreateAction(81, ConsGlobal.InstrumentType.Stock);
info.GiveShareAmount = 10m;
var applied = SwapEodPositionService.ApplyFundCorporateActionToPosition(
var applied = SwapEodPositionService.ApplyCorporateActionToPosition(
position,
info,
100m,
@@ -77,6 +77,38 @@ namespace YLErp.Modules.SwapModule
"未恢复基线时不得擅自改写前端请求,沿用既有当日实时流程");
}
[TestMethod]
public void FCA_UW_008_股票TRS平仓恢复有效Eod基线()
{
var realtime = CreateRealtimeFundPosition();
realtime.UnderlyingCode = "STOCK.TEST";
realtime.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock;
realtime.PosiQuantity = 1000m;
realtime.PosiGrossPrice = 100m;
realtime.PosiNetPrice = 100m;
realtime.PosiNetFeePrice = 100m;
realtime.PosiNetNoFeePrice = 100m;
realtime.PosiNotionalValue = 100000m;
var eod = CreateEod(ExDate, 2000m, 50m);
eod.UnderlyingCode = "STOCK.TEST";
eod.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock;
var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false);
var unwindData = CreateFullCloseUnwindData();
unwindData.ValueDate = ExDate;
unwindData.UnwindDate = ExDate.AddDays(1);
Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate));
Assert.AreEqual(2000m, realtime.PosiQuantity,
"Stock TRS 生效日盘中平仓应使用有效 EOD 数量,不能继续使用除权前实时数量");
Assert.AreEqual(50m, realtime.PosiGrossPrice,
"Stock TRS 生效日盘中平仓应使用有效 EOD 价格");
Assert.AreEqual(2000m, unwindData.PositionQty);
Assert.AreEqual(2000m, unwindData.CloseQty);
Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice);
}
[TestMethod]
public void FCA_UW_005_生效日盘中恢复前一Eod后再套除权()
{
@@ -14,7 +14,7 @@ namespace YLErp.Modules.SwapModule
var position = CreateFundPosition();
var info = CreateCorporateAction(split: 10m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(1000m, position.PosiQuantity);
@@ -27,7 +27,7 @@ namespace YLErp.Modules.SwapModule
var position = CreateFundPosition();
var info = CreateCorporateAction(split: 0.1m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(10m, position.PosiQuantity);
@@ -40,7 +40,7 @@ namespace YLErp.Modules.SwapModule
var position = CreateFundPosition();
var info = CreateCorporateAction(giveShare: 10m, split: null);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(200m, position.PosiQuantity);
@@ -54,7 +54,7 @@ namespace YLErp.Modules.SwapModule
var info = CreateCorporateAction(giveShare: 5m, split: 2m);
// (1 + 5 / 10) * 2 = 3100 份/100 元变为 300 份/约 33.333333333 元。
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(300m, position.PosiQuantity);
@@ -67,7 +67,7 @@ namespace YLErp.Modules.SwapModule
var position = CreateFundPosition();
var info = CreateCorporateAction(cash: 10m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(100m, position.PosiQuantity);
@@ -82,7 +82,7 @@ namespace YLErp.Modules.SwapModule
rationedSharesAmount: 1m,
rationedSharesPrice: 50m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
// Excel L-NL=(100*10+1*50)/(10+1)=95.4545...M=100/L
@@ -97,7 +97,7 @@ namespace YLErp.Modules.SwapModule
var info = CreateCorporateAction(split: 0m);
Assert.ThrowsException<ArgumentOutOfRangeException>(() =>
SwapEodPositionService.ApplyFundCorporateActionToPosition(
SwapEodPositionService.ApplyCorporateActionToPosition(
CreateFundPosition(), info, 100m, 0m));
}
@@ -107,7 +107,7 @@ namespace YLErp.Modules.SwapModule
var info = CreateCorporateAction(split: -1m);
Assert.ThrowsException<ArgumentOutOfRangeException>(() =>
SwapEodPositionService.ApplyFundCorporateActionToPosition(
SwapEodPositionService.ApplyCorporateActionToPosition(
CreateFundPosition(), info, 100m, 0m));
}
@@ -97,19 +97,10 @@ namespace YLErp.Modules.SwapModule
public void ExecuteFundCorporateActions(
IReadOnlyCollection<eod_swap_position> positions,
IReadOnlyCollection<eod_swap_position> previousEodPositions,
IReadOnlyCollection<swap_flow_event> flowEvents,
IReadOnlyCollection<ex_dividend_info> dividendInfos)
{
ApplyFundCorporateActions(
ApplyCorporateActions(
positions,
previousEodPositions,
flowEvents,
dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase),
SettleDate);
ApplyFundCashDividends(
positions,
previousEodPositions,
dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase),
SettleDate);
}
@@ -315,8 +306,6 @@ namespace YLErp.Modules.SwapModule
service.ExecuteFundCorporateActions(
new[] { actual },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
Assert.AreEqual(2000m, actual.PosiQuantity);
@@ -381,25 +370,24 @@ namespace YLErp.Modules.SwapModule
new List<swap_flow_event>(),
price: 100m);
service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m));
var todayEod = previousEod.Clone();
todayEod.ValueDate = SettleDate;
todayEod.UnderlyingPrice = 100m;
// 生产重收盘每次都会从上一日 EOD clone 出新的当日基线,再应用一次公司行为;
// 底层 ApplyCorporateActions 只负责处理调用方提供的未调整基线,不再承担恢复旧基线的测试兼容职责。
var firstRunEod = previousEod.Clone();
firstRunEod.ValueDate = SettleDate;
firstRunEod.UnderlyingPrice = 100m;
service.ExecuteFundCorporateActions(new[] { firstRunEod }, service.ExDividendInfos);
service.ExecuteFundCorporateActions(
new[] { todayEod },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
service.ExecuteFundCorporateActions(
new[] { todayEod },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
var rerunEod = previousEod.Clone();
rerunEod.ValueDate = SettleDate;
rerunEod.UnderlyingPrice = 100m;
service.ExecuteFundCorporateActions(new[] { rerunEod }, service.ExDividendInfos);
Assert.AreEqual(2000m, todayEod.PosiQuantity);
Assert.AreEqual(1000m, todayEod.TdChangedQty);
Assert.AreEqual(50m, todayEod.PosiGrossPrice);
Assert.AreEqual(100000m, todayEod.PosiNotionalValue);
Assert.AreEqual(2000m, firstRunEod.PosiQuantity);
Assert.AreEqual(1000m, firstRunEod.TdChangedQty);
Assert.AreEqual(50m, firstRunEod.PosiGrossPrice);
Assert.AreEqual(100000m, firstRunEod.PosiNotionalValue);
Assert.AreEqual(firstRunEod.PosiQuantity, rerunEod.PosiQuantity);
Assert.AreEqual(firstRunEod.PosiGrossPrice, rerunEod.PosiGrossPrice);
}
[TestMethod]
@@ -427,8 +415,6 @@ namespace YLErp.Modules.SwapModule
service.ExecuteFundCorporateActions(
new[] { actual },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
Assert.AreEqual(1000m, actual.PosiQuantity);
@@ -559,8 +545,6 @@ namespace YLErp.Modules.SwapModule
service.ExecuteFundCorporateActions(
new[] { actual },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
Assert.AreEqual(10m, actual.PosiQuantity,
@@ -65,8 +65,6 @@ namespace YLErp.Modules.SwapModule
decimal fallbackPrice)
=> fallbackPrice;
protected override decimal GetFundDividendTaxRate() => 0m;
public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate)
=> TryRestoreEffectiveFundPosition(unwindData, valueDate);
@@ -84,15 +84,6 @@ namespace YLErp.Modules.SwapModule
return 1.0; // 本币,汇率=1
}
protected override List<ex_dividend_info> FindExDividendInfos(DateTime settleDate)
{
return ExDividendInfos
.Where(x => x.ValidStatus
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value.Date == settleDate.Date)
.ToList();
}
protected override List<ex_dividend_info> FindCorporateActionInfos(DateTime settleDate)
{
return ExDividendInfos
@@ -102,15 +93,6 @@ namespace YLErp.Modules.SwapModule
.ToList();
}
protected override List<ex_dividend_info> FindRegistrationExDividendInfos(DateTime settleDate)
{
return ExDividendInfos
.Where(x => x.ValidStatus
&& x.ExDividendDate.HasValue
&& x.ExDividendDate.Value.Date == settleDate.Date)
.ToList();
}
protected override List<swap_event> FindCorporateActionEvents(int swapTradeId)
{
return CorporateActionEvents
@@ -124,11 +106,6 @@ namespace YLErp.Modules.SwapModule
decimal fallbackPrice)
=> fallbackPrice;
protected override decimal GetDividendTaxRate()
{
return 0m;
}
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
{
ClientCashCalls.Add((amount, action));