From ae8156020ecedf62418008cd2661219fc48c1c77 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com>
Date: Tue, 25 Aug 2026 09:57:23 +0800
Subject: [PATCH 1/3] =?UTF-8?q?refactor(swap):=20=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=E5=80=BA=E5=88=B8=E4=BB=B7=E6=A0=BC=E5=A4=84=E7=90=86=E9=80=BB?=
=?UTF-8?q?=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将债券价格处理逻辑从依赖 StructureType 改为基于 IsBond 判断
- 移除 SetPosiPrice 方法中的 useBondPriceScale 参数
- 更新收入互换交易中的 multiplier 计算方式
- 调整平仓交易中 isBondTRS 的判断条件
- 同步修改相关注释说明
---
YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs | 11 ++++++-----
.../wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js | 4 +++-
.../wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js | 8 +++++---
3 files changed, 14 insertions(+), 9 deletions(-)
diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
index 9650d66d..eceb433e 100644
--- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
@@ -3605,9 +3605,10 @@ namespace YLErp.Modules.SwapModule
{
item.FloatRateAbs = item.position.PosiNotionalValue == 0 ? 0 : item.InterestAmount / item.position.PosiNotionalValue;
}
- // 交易录入的债券类收益互换价格以小数保存,展示时转为百分比价格;
- // 普通收益互换录入的是数量/原始数值,不做乘 100 转换。
- SetPosiPrice(item.position, item.StructureType == "普通债券类收益互换");
+ // 是否 ×100 由标的资产类型决定(债券价格以小数保存,展示时转为百分比价格),
+ // 与存储层 GetStorageDeliveryPriceRound / GetSwapValuationPrice 的 IsBond 口径一致,
+ // 不依赖簿记结构类型 StructureType。
+ SetPosiPrice(item.position);
}
return retListResult;
}
@@ -3652,10 +3653,10 @@ namespace YLErp.Modules.SwapModule
position.SwapPositionValue = -position.SwapPositionValue;
position.PosiDividendSum = -position.PosiDividendSum;
}
- private void SetPosiPrice(eod_swap_position position, bool? useBondPriceScale = null)
+ private void SetPosiPrice(eod_swap_position position)
{
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(position.UnderlyingCode);
- if (useBondPriceScale ?? (um != null && um.IsBond()))
+ if (um != null && um.IsBond())
{
position.PosiNetPrice *= 100;
position.UnderlyingPrice *= 100;
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
index d160d50e..f5e767a6 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
@@ -46,7 +46,9 @@ const vue = new Vue({
}
},
created() {
- this.multiplier = this.deal.StructureType == '普通债券类收益互换' ? 100 : 1;
+ // 是否 ×100 由浮动腿标的资产类型决定(债券价格以小数保存,展示时转为百分比),
+ // 与存储层 SetPosiPrice/GetStorageDeliveryPriceRound 的 IsBond 口径一致,不依赖簿记结构类型。
+ this.multiplier = this.IsBond(swapInstrumentType) ? 100 : 1;
this.initDeal();
this.setValueDate();
},
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
index 346174c4..72ff7698 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
@@ -64,7 +64,7 @@ const vue = new Vue({
marginList: [],
initPosiNetPrice: 0,
multiplier: 1,
- // EQD-6953 簿记模板=普通债券类收益互换 时启用 期末交割全价↔结算收益率(ExitYtm) 互算
+ // EQD-6953 浮动腿标的为债券时启用 期末交割全价↔结算收益率(ExitYtm) 互算
isBondTRS: false,
// 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。
// oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。
@@ -77,13 +77,15 @@ const vue = new Vue({
minStartDate() {
return this.deal.StartDate;
},
- // EQD-6953:簿记模板为普通债券类收益互换 且 浮动腿标的为债券 时,才展示 源/AUTO/REV 标识并允许互算
+ // EQD-6953:浮动腿标的为债券时,才展示 源/AUTO/REV 标识并允许互算
isBondUnwindLeg() {
return this.isBondTRS && !!this.floatPosition && this.IsBond(this.floatPosition.UnderlyingInstrumentType);
}
},
created() {
- this.isBondTRS = this.deal.StructureType == '普通债券类收益互换';
+ // 是否债券收益互换由浮动腿标的资产类型决定(×100 展示口径与存储层 SetPosiPrice 的 IsBond 一致),
+ // 不依赖簿记结构类型 StructureType。
+ this.isBondTRS = this.IsBond(swapInstrumentType);
this.multiplier = this.isBondTRS ? 100 : 1;
this.initDeal();
this.setValueDate(this.deal.ValueDate);
From 695d78810fffc9f67381aa9912e1f7a6259846f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com>
Date: Tue, 25 Aug 2026 10:15:51 +0800
Subject: [PATCH 2/3] =?UTF-8?q?refactor(dividend):=20=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=E5=88=86=E7=BA=A2=E4=B8=9A=E5=8A=A1=E9=80=BB=E8=BE=91=E5=92=8C?=
=?UTF-8?q?=E6=B3=A8=E9=87=8A=E8=AF=B4=E6=98=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 更新除权参考价计算公式中的收盘价描述为登记日收盘价
- 优化Swap模块中公司行为处理的日志注释
- 区分除权日和登记日的信息处理流程
- 优化持仓基线重置和公司行为事件记录的注释说明
- 完善现金分红和配股处理的相关注释
- 优化实时持仓切换和事件匹配的逻辑注释
---
.../SwapModule/SwapEodPositionService.cs | 73 ++++++++++++-------
.../TradeModule/DealModule/DividendService.cs | 10 +--
2 files changed, 52 insertions(+), 31 deletions(-)
diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
index eceb433e..3119c917 100644
--- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
@@ -439,7 +439,7 @@ namespace YLErp.Modules.SwapModule
///
/// 获取公司行为公式使用的收盘价。
/// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日
- /// ExDividendDate;不能在 8 月 17 日 EOD 误取 8 月 17 日收盘价重算 8 月 14 日
+ /// ExDividendDate;不能在 除权日 EOD 误取 除权日收盘价重算 登记日
/// 登记日形成的系数。测试实现可以返回快照中的回退值,生产实现从登记日行情读取。
///
protected virtual decimal GetFundCorporateActionClosePrice(
@@ -516,18 +516,22 @@ namespace YLErp.Modules.SwapModule
// 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘,
// 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。
var corporateActionInfos = FindCorporateActionInfos(settleDate) ?? new List();
+ // 除权日信息
var exDividendInfos = corporateActionInfos
.Where(x => x != null
&& x.ValidStatus
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value.Date == settleDate.Date)
.ToList();
+ // 登记日信息
var registrationInfos = corporateActionInfos
.Where(x => x != null
&& x.ValidStatus
&& x.ExDividendDate.HasValue
&& x.ExDividendDate.Value.Date == settleDate.Date)
.ToList();
+
+ // 公司行为去重 - 除权日
var duplicateDividend = exDividendInfos
.GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(x => x.Count() > 1);
@@ -535,7 +539,8 @@ namespace YLErp.Modules.SwapModule
{
throw new InvalidOperationException($"标的【{duplicateDividend.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效除权记录");
}
- // 公司行为去重 - 拦截
+
+ // 公司行为去重 - 登记日
var duplicateRegistration = registrationInfos
.GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(x => x.Count() > 1);
@@ -545,6 +550,8 @@ namespace YLErp.Modules.SwapModule
// 有多条有效记录时,系统无法证明应采用哪一条派现金额,必须中止收盘。
throw new InvalidOperationException($"标的【{duplicateRegistration.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效登记日记录");
}
+
+ // 根据标的代码 创建map
var exDividendByCode = exDividendInfos.ToDictionary(
x => x.UnderlyingCode,
x => x,
@@ -588,21 +595,21 @@ namespace YLErp.Modules.SwapModule
var flowEvents = FindFlowEvents(td.id, settleDate);
var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期
List autoInterests = new List();//自动互换利息腿信息
+
// 处理浮动腿前先准备当日开盘基线:登记日 EOD 仍保存
// 1000 份/100 元,除权日收盘时先把上一 EOD 的基线转换为
// 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。
- // 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到
- // 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。
-
- // 重置基线
+ // 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,
+ // 否则会错误得到 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。
+ // 重置基线 - 除权日
var openingEodPositions = PrepareFundOpeningEodPositions(
- eodPositions,
+ eodPositions, // 上一日终持仓
exDividendByCode,
settleDate);
// 构建公司行为前eod持仓
var corporateActionBeforePositions = BuildCorporateActionBeforePositions(
- eodPositions,
+ eodPositions, // 上一日终持仓
posiList);
// 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线,应用生效日公司行为。
@@ -613,18 +620,19 @@ namespace YLErp.Modules.SwapModule
// 处理浮动腿归档
var curEodPosis = DealFloatPositions(
- floatPositionsForCompose,
- realPosiList,
- openingEodPositions,
- todyEodPositions,
- settleDate,
- td,
- preSettleDate,
- flowEvents);
+ floatPositionsForCompose, // 初始腿
+ realPosiList, // 实时腿
+ openingEodPositions, // 开盘基线
+ todyEodPositions, // 当日终持仓
+ settleDate, // 收盘日期
+ td, // 交易
+ preSettleDate, // 上一交易日
+ flowEvents); // 流水事件
// 现金分红不在登记日直接累加;Copy/Update EOD 通过 CalcBondPayment
// 读取 EffectiveDate 命中的 ex_dividend_info,并生成 TdPosiDividend。
// 这样登记日快照不提前变化,且公司行为分红与债券付息共用同一待实现余额。
+ // 公司行为事件
RecordCorporateActionEvents(
td,
curEodPosis,
@@ -632,8 +640,9 @@ namespace YLErp.Modules.SwapModule
registrationInfos,
exDividendInfos,
settleDate);
- // 登记日 EOD 仍保存除权前快照,但下一交易日开盘读取的实时浮动腿需要
- // 先切换到生效后的 Q/P。该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大。
+ // 登记日 EOD 仍保存除权前快照,
+ // 但下一交易日开盘读取的实时浮动腿需要先切换到生效后的 Q/P。
+ // 该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大。
UpdateRealtimeCorporateActionPositions(td, curEodPosis, registrationInfos, exDividendInfos, settleDate);
var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue);
var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue);
@@ -668,8 +677,8 @@ namespace YLErp.Modules.SwapModule
///
/// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Stock/Fund 公司行为。
/// 原始上一 EOD 只读保留在数据库中,确保登记日 EOD 报表仍展示除权前 Q/P。
- /// 例如 1000 份/100 元、10 送 10 的记录在 8 月 14 日 EOD 仍是 1000/100;
- /// 8 月 17 日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。
+ /// 例如 1000 份/100 元、10 送 10 的记录在 登记日 EOD 仍是 1000/100;
+ /// 除权日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。
///
protected List PrepareFundOpeningEodPositions(
IReadOnlyCollection previousEodPositions,
@@ -729,6 +738,7 @@ namespace YLErp.Modules.SwapModule
var dividendTaxRate = 0m;
foreach (var position in positions)
{
+ // 不是浮动腿 或者 不是 Fund Stock类型的标的 或者 没有除权信息 或者 除权日不是结算日 - 跳过
if (position.PosiDirection <= 0
|| !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType)
|| string.IsNullOrWhiteSpace(position.UnderlyingCode)
@@ -739,7 +749,7 @@ namespace YLErp.Modules.SwapModule
continue;
}
- // 获取除权参考价
+ // 获取除权参考价 - 登记日收盘价
var corporateActionClosePrice = GetFundCorporateActionClosePrice(
dividendInfo,
position.UnderlyingPrice);
@@ -773,8 +783,11 @@ namespace YLErp.Modules.SwapModule
position.PosiNetFeePrice = adjusted.NetFeePrice;
position.PosiNetNoFeePrice = adjusted.NetNoFeePrice;
+ // 多空方向
var shortRatio = DirectionRatio.LongShort(position.PositionType);
+ // 收付方向
var directionRatio = DirectionRatio.ReceivePay(position.PosiDirection);
+ // 处理价格的正负号(收支方向)
position.PosiNotionalValue = Math.Round(
position.PosiGrossPrice * position.PosiQuantity * position.ContractSize,
ConsGlobal.MoneyRound,
@@ -900,8 +913,9 @@ namespace YLErp.Modules.SwapModule
return;
}
- // 登记日收盘后即切换实时 BOD。EffectiveDate 只用于确认这条记录仍是未来生效的
- // 公司行为;无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。
+ // 登记日收盘后即切换实时 BOD。
+ // EffectiveDate 只用于确认这条记录仍是未来生效的公司行为;
+ // 无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。
var pendingInfos = (registrationInfos ?? Array.Empty())
.Where(x => x.EffectiveDate.HasValue && x.EffectiveDate.Value.Date > settleDate.Date)
.ToList();
@@ -912,6 +926,7 @@ namespace YLErp.Modules.SwapModule
&& IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType)
&& !string.IsNullOrWhiteSpace(x.UnderlyingCode)))
{
+ // 实时腿
var realtime = DbContext.swap_position.FirstOrDefault(x => x.SwapTradeId == td.id
&& !x.Invalid
&& !x.IsInitial
@@ -920,7 +935,8 @@ namespace YLErp.Modules.SwapModule
{
continue;
}
-
+
+ // 对每条当日 EOD 浮动腿,按标的代码在 pendingInfos 中找匹配的公司行为。
var pending = pendingInfos.FirstOrDefault(x => string.Equals(
x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase));
if (pending != null)
@@ -966,6 +982,7 @@ namespace YLErp.Modules.SwapModule
return;
}
+ // 登记日信息合并除权日信息
var infos = (registrationInfos ?? Array.Empty())
.Concat(effectiveInfos ?? Array.Empty())
.Where(x => x != null && x.ValidStatus && !string.IsNullOrWhiteSpace(x.UnderlyingCode))
@@ -983,6 +1000,7 @@ namespace YLErp.Modules.SwapModule
return;
}
+ // 跟据交易id查当前交易关联事件
var existingEvents = FindCorporateActionEvents(td.id);
foreach (var current in currentPositions.Where(x => x != null && x.PosiDirection > 0
&& IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType)))
@@ -1004,16 +1022,18 @@ namespace YLErp.Modules.SwapModule
&& x.Data.ExDividendInfoId == info.id
&& x.Data.PositionId == current.PositionId)
.ToList();
+ // 寻找applied = false的(登记日记录的)
var eventData = matchingEvents.FirstOrDefault(x => !x.Data.Applied)
?? matchingEvents.FirstOrDefault();
- var previous = previousPositions?.FirstOrDefault(x => x != null && x.PositionId == current.PositionId);
+ var previous = previousPositions?.FirstOrDefault(x => x != null
+ && x.PositionId == current.PositionId);
// 登记日 false 除权日 true
var isEffective = info.EffectiveDate.HasValue
&& info.EffectiveDate.Value.Date <= settleDate.Date
&& effectiveInfos != null
&& effectiveInfos.Any(x => x.id == info.id);
- // 如果没有匹配到事件或事件未生效,则创建新事件。
+ // 如果没有匹配到事件或今天不是除权日 但找到的事件的applied=true(异常事件/重收盘),则创建新事件。
if (eventData == null || (!isEffective && eventData.Data.Applied))
{
// 创建新事件
@@ -1184,6 +1204,7 @@ namespace YLErp.Modules.SwapModule
decimal dividendTaxRate,
int grossPriceRound)
{
+ // 计算除权系数 - adjustCashDividendPrice = false (现金分红模式)
var factors = DividendService.CalculateCorporateActionFactors(
dividendInfo,
closePrice,
diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs
index 8b026f5d..fedf0c00 100644
--- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs
+++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs
@@ -773,9 +773,9 @@ namespace YLErp.Modules.TradeModule.DealModule
bool adjustCashDividendPrice = true)
{
// 价格调整模式除权参考价 =
- // 收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价
- // - -----------------------------------------------------
- // (10 + 送股数 + 配股数) * 拆股倍数
+ // 登记日收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价
+ // ---------------------------------------------------------------
+ // (10 + 送股数 + 配股数) * 拆股倍数
// 场内链路默认继续把现金派息计入除权参考价;
// TRS Stock/Fund 现金模式显式关闭该项 :“【】” 号内数据。
var cashPriceAdjustment = adjustCashDividendPrice
@@ -784,8 +784,8 @@ namespace YLErp.Modules.TradeModule.DealModule
// 拆股倍数
var splitFactor = GetSplitFactor(info);
// 除权参考价(TRS) :
- // 收盘价 * 10 + 配股数 * 配股价
- // ------------------------------
+ // 登记日收盘价 * 10 + 配股数 * 配股价
+ // -------------------------------------
// (10 + 送股数 + 配股数) * 拆股倍数
var exDividendPrice = ((closePrice * 10m - cashPriceAdjustment
+ info.RationedSharesAmount * info.RationedSharesPrice)
From f7766fb72360e1805678042f6e1112fc8de9ca96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com>
Date: Tue, 25 Aug 2026 15:39:01 +0800
Subject: [PATCH 3/3] =?UTF-8?q?fix(dividend):=20=E5=85=AC=E5=8F=B8?=
=?UTF-8?q?=E8=A1=8C=E4=B8=BA=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 债券付息查询/BondPayment/BondPaymentList这个界面 不再查询公司行为除权表
- 公司行为事件的说明 格式化一下变为:调整前:名义本金:XXXX 期初标的价格:XXXX 持仓数量:XXXX
调整后:名义本金:XXXX 期初标的价格:XXXX 持仓数量:XXXX
- 报错信息修改:当某个标的的除权除息数据仍有交易引用时
则修改标的公司行为数据时则提示“不可修改,有交易【交易编号】使用了该条除权除息数据”。
---
.../BondPaymentListQueryBoundaryTest.cs | 47 +++++++++++++++
.../DividendInfoEditGuardMessageTest.cs | 59 +++++++++++++++++++
.../CorporateActionEventLifecycleTest.cs | 4 +-
.../Modules/EodModule/BondPaymentService.cs | 38 +-----------
.../Modules/SwapModule/SwapEventService.cs | 38 ++----------
.../TradeModule/DealModule/DividendService.cs | 55 ++++++++++-------
.../Controllers/ex_dividend_infoController.cs | 6 +-
7 files changed, 154 insertions(+), 93 deletions(-)
create mode 100644 UnitTestProject/Modules/EodModule/BondPaymentListQueryBoundaryTest.cs
create mode 100644 UnitTestProject/Modules/EodModule/DividendInfoEditGuardMessageTest.cs
diff --git a/UnitTestProject/Modules/EodModule/BondPaymentListQueryBoundaryTest.cs b/UnitTestProject/Modules/EodModule/BondPaymentListQueryBoundaryTest.cs
new file mode 100644
index 00000000..9d09352f
--- /dev/null
+++ b/UnitTestProject/Modules/EodModule/BondPaymentListQueryBoundaryTest.cs
@@ -0,0 +1,47 @@
+using System;
+using System.IO;
+
+namespace YLErp.Modules.EodModule
+{
+ [TestClass]
+ public class BondPaymentListQueryBoundaryTest
+ {
+ [TestMethod]
+ public void SearchList_OnlyQueriesBondPaymentTable()
+ {
+ var source = ReadBondPaymentServiceSource();
+ var searchList = ExtractMethod(source, "public SearchListResult SearchList", "public BondPayment SaveBondPayment");
+
+ Assert.IsFalse(
+ searchList.Contains("ex_dividend_info", StringComparison.Ordinal),
+ "债券付息列表只能查询 bond_payment_info,不能把公司行为除权表拼入展示结果。");
+ StringAssert.Contains(searchList, "DbContext.bondPayment");
+ }
+
+ private static string ExtractMethod(string source, string startMarker, string endMarker)
+ {
+ var start = source.IndexOf(startMarker, StringComparison.Ordinal);
+ Assert.IsTrue(start >= 0, $"Could not find method: {startMarker}");
+ var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
+ Assert.IsTrue(end >= 0, $"Could not find method end: {endMarker}");
+ return source.Substring(start, end - start);
+ }
+
+ private static string ReadBondPaymentServiceSource()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var path = Path.Combine(directory.FullName, "YLErpDAL", "Modules", "EodModule", "BondPaymentService.cs");
+ if (File.Exists(path))
+ {
+ return File.ReadAllText(path);
+ }
+ directory = directory.Parent;
+ }
+
+ Assert.Fail("Could not locate BondPaymentService.cs from the test output directory.");
+ return string.Empty;
+ }
+ }
+}
diff --git a/UnitTestProject/Modules/EodModule/DividendInfoEditGuardMessageTest.cs b/UnitTestProject/Modules/EodModule/DividendInfoEditGuardMessageTest.cs
new file mode 100644
index 00000000..d0a6e001
--- /dev/null
+++ b/UnitTestProject/Modules/EodModule/DividendInfoEditGuardMessageTest.cs
@@ -0,0 +1,59 @@
+using System;
+using System.IO;
+
+namespace YLErp.Modules.EodModule
+{
+ [TestClass]
+ public class DividendInfoEditGuardMessageTest
+ {
+ [TestMethod]
+ public void AddDividendInfos_ReportsTheReferencingTradeNumberWhenAnExecutedActionIsEdited()
+ {
+ var source = ReadDividendServiceSource();
+ var addDividendInfos = ExtractMethod(source, "public bool AddDividendInfos", "public bool checkDividendInfoExecuteStatus");
+ var controllerSource = ReadExDividendInfoControllerSource();
+ var deleteDividend = ExtractMethod(controllerSource, "public JsonResult deleteDividend", "public JsonResult ImportDividendInfo");
+
+ Assert.IsTrue(addDividendInfos.Contains("不可修改,有交易【", StringComparison.Ordinal));
+ Assert.IsTrue(addDividendInfos.Contains("使用了该条除权除息数据", StringComparison.Ordinal));
+ Assert.IsTrue(deleteDividend.Contains("不可修改,有交易【", StringComparison.Ordinal));
+ Assert.IsTrue(deleteDividend.Contains("使用了该条除权除息数据", StringComparison.Ordinal));
+ }
+
+ private static string ExtractMethod(string source, string startMarker, string endMarker)
+ {
+ var start = source.IndexOf(startMarker, StringComparison.Ordinal);
+ Assert.IsTrue(start >= 0, $"Could not find method: {startMarker}");
+ var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
+ Assert.IsTrue(end >= 0, $"Could not find method end: {endMarker}");
+ return source.Substring(start, end - start);
+ }
+
+ private static string ReadDividendServiceSource()
+ {
+ return ReadSource("YLErpDAL", "Modules", "TradeModule", "DealModule", "DividendService.cs");
+ }
+
+ private static string ReadExDividendInfoControllerSource()
+ {
+ return ReadSource("YLErpWeb", "Controllers", "ex_dividend_infoController.cs");
+ }
+
+ private static string ReadSource(params string[] relativePath)
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var path = Path.Combine(new[] { directory.FullName }.Concat(relativePath).ToArray());
+ if (File.Exists(path))
+ {
+ return File.ReadAllText(path);
+ }
+ directory = directory.Parent;
+ }
+
+ Assert.Fail("Could not locate DividendService.cs from the test output directory.");
+ return string.Empty;
+ }
+ }
+}
diff --git a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs
index 7e202a38..5df141d4 100644
--- a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs
+++ b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs
@@ -37,8 +37,8 @@ namespace YLErp.Modules.SwapModule
Assert.IsFalse(snapshot.Applied);
var reason = SwapEventService.BuildCorporateActionEventReason(snapshot);
- StringAssert.Contains(reason, "BeforeQuantity=1000");
- StringAssert.Contains(reason, "AfterQuantity=0");
+ StringAssert.Contains(reason, "调整前:名义本金:100000 期初标的价格:100 持仓数量:1000");
+ StringAssert.Contains(reason, "调整后:名义本金:0 期初标的价格:0 持仓数量:0");
}
[TestMethod]
diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs
index 19366db5..5b6f03ce 100644
--- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs
+++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs
@@ -59,43 +59,7 @@ namespace YLErp.Modules.EodModule
create_time = source.create_time,
update_time = source.update_time
};
- // 不再依赖 bond-sync 镜像:Stock/Fund 公司行为直接作为展示行返回。
- // 展示金额按“每 10 份派现金额”换算为 GiveCashAmount / 10;EOD 计算仍使用
- // GetBondPayments 的内部单位口径,不受此处展示换算影响。
- var corporateQuery = from un in queryUn
- join dividend in DbContext.ex_dividend_info.AsNoTracking()
- on un.UnderlyingCode equals dividend.UnderlyingCode
- where dividend.ValidStatus
- && dividend.EffectiveDate.HasValue
- && dividend.EffectiveDate.Value >= valueDtStart
- && dividend.EffectiveDate.Value < valueDtEnd
- && dividend.GiveCashAmount != 0
- && (string.IsNullOrEmpty(req.UnderlyingCode)
- || dividend.UnderlyingCode.Contains(req.UnderlyingCode))
- && (un.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Stock
- || un.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund)
- && (string.IsNullOrEmpty(req.DataSource)
- || "公司行为除权表".Contains(req.DataSource))
- select new BondPaymentDto
- {
- id = -dividend.id,
- channel_source = "公司行为除权表",
- MarketName = un.MarketName,
- security_id = un.UnderlyingCode,
- symbol = un.UnderlyingName,
- coupon_rate = null,
- payment_date = dividend.EffectiveDate,
- payment_interest = dividend.GiveCashAmount / 10m,
- payment_parvalue = null,
- paying_price = dividend.GiveCashAmount / 10m,
- create_time = dividend.OptDate,
- update_time = dividend.OptDate
- };
- // EF Core 无法翻译两个对 BondPaymentDto 继承属性赋值集合不完全一致的投影
- // 直接 Concat;分别执行后在内存合并,不改变两组查询的筛选口径。
- var rows = query.ToList();
- rows.AddRange(corporateQuery.ToList());
- var result = rows.AsQueryable().ToSearchList(req);
+ var result = query.ToSearchList(req);
return result;
}
diff --git a/YLErpDAL/Modules/SwapModule/SwapEventService.cs b/YLErpDAL/Modules/SwapModule/SwapEventService.cs
index 711bc06f..935bda97 100644
--- a/YLErpDAL/Modules/SwapModule/SwapEventService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapEventService.cs
@@ -247,8 +247,8 @@ namespace YLErp.Modules.SwapModule
}
///
- /// 公司行为说明使用稳定的键值格式,完整保留调整前后名义本金、价格、数量、
- /// 待实现分红和现金流变化,操作历史无需重新计算即可核对。
+ /// 公司行为说明仅展示调整前后的名义本金、期初标的价格和持仓数量,
+ /// 便于操作历史直接比对持仓基线。
///
public static string BuildCorporateActionEventReason(CorporateActionEventData data)
{
@@ -257,37 +257,11 @@ namespace YLErp.Modules.SwapModule
return "公司行为快照为空";
}
- // 使用 InvariantCulture 固定小数与日期格式,说明文本不随服务器区域设置变化。
+ // 使用 InvariantCulture 固定小数格式,说明文本不随服务器区域设置变化。
string D(decimal value) => value.ToString(CultureInfo.InvariantCulture);
- string Date(DateTime? value) => value.HasValue
- ? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
- : "";
-
- return string.Join("; ", new[]
- {
- $"公司行为[{data.UnderlyingCode}]",
- $"ExDividendDate={Date(data.ExDividendDate)}",
- $"EffectiveDate={Date(data.EffectiveDate)}",
- $"ExDividendInfoId={data.ExDividendInfoId}",
- $"PositionId={data.PositionId}",
- $"GiveCashAmount={D(data.GiveCashAmount)}",
- $"GiveShareAmount={D(data.GiveShareAmount)}",
- $"Split={(data.Split.HasValue ? D(data.Split.Value) : "")}",
- $"RationedSharesAmount={D(data.RationedSharesAmount)}",
- $"RationedSharesPrice={D(data.RationedSharesPrice)}",
- "调整前",
- $"BeforeNotional={D(data.BeforeNotional)}",
- $"BeforePrice={D(data.BeforePrice)}",
- $"BeforeQuantity={D(data.BeforeQuantity)}",
- $"BeforePendingDividend={D(data.BeforePendingDividend)}",
- "调整后",
- $"AfterNotional={D(data.AfterNotional)}",
- $"AfterPrice={D(data.AfterPrice)}",
- $"AfterQuantity={D(data.AfterQuantity)}",
- $"AfterPendingDividend={D(data.AfterPendingDividend)}",
- $"CashFlowChange={D(data.CashFlowChange)}",
- $"Applied={data.Applied}"
- });
+ return $"调整前:名义本金:{D(data.BeforeNotional)} 期初标的价格:{D(data.BeforePrice)} 持仓数量:{D(data.BeforeQuantity)}"
+ + Environment.NewLine
+ + $"调整后:名义本金:{D(data.AfterNotional)} 期初标的价格:{D(data.AfterPrice)} 持仓数量:{D(data.AfterQuantity)}";
}
public void DeleteEvent(int tradeId)
diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs
index fedf0c00..1deac603 100644
--- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs
+++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs
@@ -1159,9 +1159,10 @@ namespace YLErp.Modules.TradeModule.DealModule
}
else
{
- if (checkDividendInfoExecuteStatus(dividend))
+ var executingTradeNumber = GetDividendInfoExecutingTradeNumber(dividend);
+ if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
- errMsg = $"{dividend.UnderlyingCode} {dividend.ExDividendDate?.ToString("yyyy-MM-dd")}除权信息保存失败,该信息已被执行,不允许修改!";
+ errMsg = $"不可修改,有交易【{executingTradeNumber}】使用了该条除权除息数据";
return false;
}
var conflictingDividend = FindExDividendByBusinessKey(underlying.id, itemDate, dividend.id);
@@ -1223,6 +1224,14 @@ namespace YLErp.Modules.TradeModule.DealModule
///
///
public bool checkDividendInfoExecuteStatus(ex_dividend_info info)
+ {
+ return !string.IsNullOrWhiteSpace(GetDividendInfoExecutingTradeNumber(info));
+ }
+
+ ///
+ /// 返回仍在引用已执行公司行为的交易编号;无引用时返回空字符串。
+ ///
+ public string GetDividendInfoExecutingTradeNumber(ex_dividend_info info)
{
// TRS 公司行为以 EffectiveDate 为真正生效边界。登记日创建待生效事件不应锁定
// 维护;只有交易已经完成 EffectiveDate(例如收盘到 7 月 30 日,而真实除权日为
@@ -1230,34 +1239,37 @@ namespace YLErp.Modules.TradeModule.DealModule
if (info?.EffectiveDate.HasValue == true)
{
var effectiveDate = info.EffectiveDate.Value.Date;
- var trsTradeIds = DbContext.trade
+ var trsTrades = DbContext.trade
.Where(x => x.ValidState != ConsGlobal.InValid
&& x.TradeType == "收益互换"
&& x.UnderlyingCode == info.UnderlyingCode
&& x.TradeDate <= effectiveDate
&& x.ExerciseDate >= effectiveDate)
- .Select(x => x.id)
+ .Select(x => new { x.id, x.TradeNumber })
.ToList();
- if (trsTradeIds.Count > 0)
+ if (trsTrades.Count > 0)
{
// 是否仍被交易引用以当前有效 EOD 为准。公司行为事件本身是不可篡改
// 历史,交易回退后仍会保留;若仅凭 Applied 事件锁定,回退到登记日前
// 也无法纠错。生效日及以后还有有效 EOD 才表示当前仍已执行。
- var hasAppliedEod = DbContext.eod_swap_position.Any(x =>
+ var trsTradeIds = trsTrades.Select(x => x.id).ToList();
+ var appliedTradeId = DbContext.eod_swap_position.Where(x =>
trsTradeIds.Contains(x.SwapTradeId)
&& !x.Invalid
&& x.UnderlyingCode == info.UnderlyingCode
- && x.ValueDate >= effectiveDate);
- if (hasAppliedEod)
+ && x.ValueDate >= effectiveDate)
+ .Select(x => x.SwapTradeId)
+ .FirstOrDefault();
+ if (appliedTradeId > 0)
{
- return true;
+ return trsTrades.First(x => x.id == appliedTradeId).TradeNumber;
}
// EffectiveDate 已存在时,当前有效 EOD 是唯一执行状态来源。
// 回退会清理生效日及之后的 EOD,但不会删除 eodStatus 或不可篡改的
// 公司行为审计事件;此处不能继续落入旧的登记日 eodStatus 判断,
// 否则交易已回退仍会被错误判定为“已执行”而无法修改。
- return false;
+ return string.Empty;
}
}
@@ -1268,20 +1280,22 @@ namespace YLErp.Modules.TradeModule.DealModule
var tradeQuery = from t in DbContext.trade.Where(O => O.UnderlyingCode == info.UnderlyingCode && O.TradeDate <= info.ExDividendDate && O.ExerciseDate >= info.ExDividendDate && O.DividendDate >= O.TradeDate)
join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus))
on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate }
- select et.id;
- if (tradeQuery.Any())
+ select t.TradeNumber;
+ var executingTradeNumber = tradeQuery.FirstOrDefault();
+ if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
- return true;
+ return executingTradeNumber;
}
//查询篮子标的对应交易是否执行过收盘操作;
var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.CommodityCode == "篮子标的" && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray();
tradeQuery = from t in DbContext.trade.Where(O => umList.Contains(O.UnderlyingCode) && O.TradeDate <= info.ExDividendDate && O.ExerciseDate >= info.ExDividendDate && O.DividendDate >= O.TradeDate)
join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus))
on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate }
- select et.id;
- if (tradeQuery.Any())
+ select t.TradeNumber;
+ executingTradeNumber = tradeQuery.FirstOrDefault();
+ if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
- return true;
+ return executingTradeNumber;
}
//查询多标的对应交易是否执行过收盘操作;
tradeQuery = from ts in DbContext.trade_swap_detail.Where(O => O.UnderlyingCode == info.UnderlyingCode)
@@ -1289,13 +1303,14 @@ namespace YLErp.Modules.TradeModule.DealModule
on ts.TradeId equals t.id
join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus))
on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate }
- select et.id;
- if (tradeQuery.Any())
+ select t.TradeNumber;
+ executingTradeNumber = tradeQuery.FirstOrDefault();
+ if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
- return true;
+ return executingTradeNumber;
}
}
- return false;
+ return string.Empty;
}
public List QueryDividendTrade(DividendTradeReq req)
diff --git a/YLErpWeb/Controllers/ex_dividend_infoController.cs b/YLErpWeb/Controllers/ex_dividend_infoController.cs
index e8db3bb8..c731fc17 100644
--- a/YLErpWeb/Controllers/ex_dividend_infoController.cs
+++ b/YLErpWeb/Controllers/ex_dividend_infoController.cs
@@ -105,9 +105,11 @@ namespace YLErp.Web.Controllers
{
return JsonError("未找到有效的除权除息信息");
}
- if (new DividendService(CurUser).checkDividendInfoExecuteStatus(r))
+ var dividendService = new DividendService(CurUser);
+ var executingTradeNumber = dividendService.GetDividendInfoExecutingTradeNumber(r);
+ if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
- return JsonError("该条除权信息已被执行,不允许删除!");
+ return JsonError($"不可修改,有交易【{executingTradeNumber}】使用了该条除权除息数据");
}
else
{