diff --git a/Framework/YLErp.Core/DBModels/EodSwap.cs b/Framework/YLErp.Core/DBModels/EodSwap.cs
index d268cd50..80f8fd30 100644
--- a/Framework/YLErp.Core/DBModels/EodSwap.cs
+++ b/Framework/YLErp.Core/DBModels/EodSwap.cs
@@ -220,4 +220,86 @@ namespace YLErp.DBModels
///
public decimal MarginInterestLoss { get; set; }
}
+
+ ///
+ /// EQD-7084 新“框架合约”Tab 的新增字段及拆分后的估值口径。
+ /// 该模型不映射数据库,仅由新查询接口计算返回。
+ ///
+ public class EodSwapRiskNewFields
+ {
+ ///
+ /// 浮动收益端标的类型,仅供前端按债券/非债券选择期初价格精度使用,
+ /// 不参与任何收益或估值计算。
+ ///
+ public string UnderlyingInstrumentType { get; set; }
+
+ /// 浮动收益端多空方向。
+ public string UnderlyingDirection { get; set; }
+
+ /// 浮动收益端标的代码。
+ public string UnderlyingCode { get; set; }
+
+ /// 期初标的价格;债券按百分价格展示。
+ public decimal? InitialPrice { get; set; }
+
+ /// 名义数量,取合约名义本金。
+ public decimal NotionalQuantity { get; set; }
+
+ /// 合约起始日。
+ public DateTime? ContractStartDate { get; set; }
+
+ /// 合约到期日。
+ public DateTime? ContractMaturityDate { get; set; }
+
+ /// 利息端基准:FR007 或固定利率。
+ public string InterestBenchmark { get; set; }
+
+ /// 普通利息腿当前交易日适用利率合计。
+ public decimal InterestRatePrice { get; set; }
+
+ ///
+ /// 开平仓费用。日终腿已按我方收益方向归一:我方支付为负、我方收取为正;
+ /// 新 Tab 单独展示该金额,但估值中仍须计入一次。
+ ///
+ public decimal OpeningClosingFee { get; set; }
+
+ ///
+ /// 不含开平仓费用的浮动端待实现收益,来源为日终浮动腿的 PosiMtmPnL;
+ /// 不可再由旧口径的 PosiProfitSum 反推,避免把费用重新混入本列。
+ ///
+ public decimal FloatingUnrealizedPnl { get; set; }
+
+ ///
+ /// 排除初始/维持保证金腿后的普通利息端待实现收益。保证金利息保留在其独立两列,
+ /// 且只通过 MarginInterestAmount 参与估值,以满足“利息端仅展示利息端盈亏”的新口径。
+ ///
+ public decimal OrdinaryInterestPnl { get; set; }
+
+ ///
+ /// 保证金利息净额,仅供两种合约估值维持旧总额;前端不直接绑定该字段,
+ /// 以防它再次落入“合约利息端待实现收益”。
+ ///
+ public decimal MarginInterestAmount { get; set; }
+
+ /// 收取对手方保证金利息。
+ public decimal MarginInterestGain { get; set; }
+
+ /// 支付对手方保证金利息。
+ public decimal MarginInterestLoss { get; set; }
+
+ /// 到期轧差口径估值;仅 DividendPayDate=0 时有值,且包含期间付息/分红。
+ public decimal? MaturityNettingValuation { get; set; }
+
+ /// 期间支付派息口径估值;仅 DividendPayDate 非 0 时有值,不重复计入期间付息/分红。
+ public decimal? PeriodPaymentValuation { get; set; }
+ }
+
+ ///
+ /// EQD-7084 新“框架合约”Tab 响应。继承旧响应以保持原有列字段完全一致,
+ /// 新接口只额外序列化新增字段。
+ ///
+ public class EodSwapRiskNewResponse : EodSwapResponse
+ {
+ public EodSwapRiskNewFields NewFields { get; set; }
+ }
}
diff --git a/Framework/YLErp.Core/DBModels/PushStatus.cs b/Framework/YLErp.Core/DBModels/PushStatus.cs
new file mode 100644
index 00000000..547fd6cf
--- /dev/null
+++ b/Framework/YLErp.Core/DBModels/PushStatus.cs
@@ -0,0 +1,50 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace YLErp.DBModels
+{
+ public enum PushStateEnum
+ {
+ 待推送 = 0,
+ 成功 = 1,
+ 失败 = 2
+ }
+
+ ///
+ /// 外发推送失败状态。只记录定位信息,不保存报文。
+ ///
+ [Table("push_status")]
+ public class PushStatus
+ {
+ [Key]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public long id { get; set; }
+
+ [Column("value_date")]
+ public DateTime ValueDate { get; set; }
+
+ [Column("push_type")]
+ public int PushType { get; set; }
+
+ [Column("record_id")]
+ public long RecordId { get; set; }
+
+ [Column("state")]
+ public PushStateEnum State { get; set; }
+
+ [Column("retry_count")]
+ public int RetryCount { get; set; }
+
+ [Column("last_error")]
+ public string LastError { get; set; }
+
+ [Column("push_time")]
+ public DateTime? PushTime { get; set; }
+
+ [Column("create_time")]
+ public DateTime CreateTime { get; set; }
+
+ [Column("update_time")]
+ public DateTime? UpdateTime { get; set; }
+ }
+}
diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx
index 030e6c1c..0db3fb36 100644
Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx differ
diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx
index f1f7c61e..5818a8a4 100644
Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx differ
diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx
index c6da85b2..d1735609 100644
Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx differ
diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【现券】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【现券】-清洁版.docx
index 19243728..3051e844 100644
Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【现券】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【现券】-清洁版.docx differ
diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx
index 1b289c00..fd0cf135 100644
Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx differ
diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs
index 9335d06f..d7d32888 100644
--- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs
+++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs
@@ -245,6 +245,12 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
}
dic["主协议编号"] = mainProtocolCode ?? "";
+ // 主协议类型:仅 NAFMII 使用中国银行间市场协议,其余类型按 SAC 展示。
+ var mainProtocolType = Context.GetClientMeta(client.id, "MainProtocolType")?.MetaValue;
+ var isNafmii = mainProtocolType == "1";
+ dic["IsSac"] = !isNafmii;
+ dic["IsNafmii"] = isNafmii;
+
// 补充协议编号:优先取 client 表字段,为空时从 client_meta 表兜底
var supProtocolCode = client.SupProtocolCode;
if (string.IsNullOrWhiteSpace(supProtocolCode))
@@ -563,7 +569,8 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
// 参考标的证券全称和参考标的名义份额(复用上方已声明的bond)
dic["参考标的证券全称"] = underlying != null
- ? (JsonHelper.Deserialize(underlying.ExJson)?.UnderlyingFullName ?? underlying.UnderlyingName)
+ // ? (JsonHelper.Deserialize(underlying.ExJson)?.UnderlyingFullName ?? underlying.UnderlyingName)
+ ? (underlying.UnderlyingName ?? "")
: "";
dic["参考标的名义份额"] = swapPosition != null
? ((double)swapPosition.PosiQuantity).ToString("0.##")
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/EodModule/TrsContractKafkaPushServiceTest.cs b/UnitTestProject/Modules/EodModule/TrsContractKafkaPushServiceTest.cs
new file mode 100644
index 00000000..88ffb133
--- /dev/null
+++ b/UnitTestProject/Modules/EodModule/TrsContractKafkaPushServiceTest.cs
@@ -0,0 +1,210 @@
+using YLErp.Abstract;
+using YLErp.Helpers;
+using YLErp.Modules.EodModule;
+
+namespace YLErp.Modules.EodModuleTests
+{
+ [TestClass]
+ public class TrsContractKafkaPushServiceTest
+ {
+ [TestMethod]
+ public void Push_空日快照_发送一条空消息并使用业务日期作为Key()
+ {
+ var valueDate = new DateTime(2026, 8, 24);
+ var producer = new RecordingKafkaProducer();
+ var service = CreateService(producer, valueDate);
+
+ service.Push(valueDate);
+
+ Assert.AreEqual(1, producer.Messages.Count);
+ Assert.AreEqual("onederiv.trs.contract.v1", producer.Messages[0].Topic);
+ Assert.AreEqual("2026-08-24", producer.Messages[0].Key);
+ var payload = JsonHelper.Deserialize(producer.Messages[0].Message);
+ Assert.AreEqual("2026-08-24", payload.ValueDate);
+ Assert.AreEqual(0, payload.ContractCount);
+ Assert.AreEqual(0, payload.Contracts.Count);
+ }
+
+ [TestMethod]
+ public void Push_区间内每天分别调用_每个日期各发送一条快照()
+ {
+ var valueDates = new[]
+ {
+ new DateTime(2026, 8, 20),
+ new DateTime(2026, 8, 21),
+ new DateTime(2026, 8, 24)
+ };
+ var producer = new RecordingKafkaProducer();
+ var service = new TestableTrsContractKafkaPushService(producer, valueDates.ToDictionary(x => x, CreateEmptySnapshot));
+
+ foreach (var valueDate in valueDates)
+ {
+ service.Push(valueDate);
+ }
+
+ CollectionAssert.AreEqual(
+ new[] { "2026-08-20", "2026-08-21", "2026-08-24" },
+ producer.Messages.Select(x => x.Key).ToArray());
+ CollectionAssert.AreEqual(
+ new[] { "2026-08-20", "2026-08-21", "2026-08-24" },
+ producer.Messages.Select(x => JsonHelper.Deserialize(x.Message).ValueDate).ToArray());
+ }
+
+ [TestMethod]
+ public void Push_首次失败后成功_停止重试且不记录最终失败()
+ {
+ var valueDate = new DateTime(2026, 8, 24);
+ var producer = new RecordingKafkaProducer { FailuresBeforeSuccess = 1 };
+ var service = CreateService(producer, valueDate);
+
+ service.Push(valueDate);
+
+ Assert.AreEqual(2, producer.AttemptCount);
+ Assert.AreEqual(1, producer.Messages.Count);
+ Assert.AreEqual(0, service.FailureRecords.Count);
+ }
+
+ [TestMethod]
+ public void Push_连续失败三次_记录最终失败和三次尝试()
+ {
+ var valueDate = new DateTime(2026, 8, 24);
+ var producer = new RecordingKafkaProducer { FailuresBeforeSuccess = int.MaxValue };
+ var service = CreateService(producer, valueDate);
+
+ service.Push(valueDate);
+
+ Assert.AreEqual(3, producer.AttemptCount);
+ Assert.AreEqual(0, producer.Messages.Count);
+ Assert.AreEqual(1, service.FailureRecords.Count);
+ Assert.AreEqual(valueDate, service.FailureRecords[0].ValueDate);
+ Assert.AreEqual(3, service.FailureRecords[0].RetryCount);
+ Assert.IsInstanceOfType(service.FailureRecords[0].Exception, typeof(InvalidOperationException));
+ }
+
+ [TestMethod]
+ public void BuildContract_字段使用日终快照和约定来源()
+ {
+ var valueDate = new DateTime(2026, 8, 24);
+ var eodSwap = new eod_swap
+ {
+ id = 10,
+ ValueDate = valueDate,
+ SwapTradeId = 7,
+ SwapTradeNo = "TRS-001",
+ BookId = 3,
+ ClientId = 8,
+ NotionalValue = 1000000m,
+ dv01 = 12.34m,
+ InitMarginGain = 100m,
+ InitMarginLoss = 0m
+ };
+ var trade = new trade
+ {
+ id = 7,
+ UnderlyingCode = "600000.SH",
+ UnderlyingAssetName = "浦发银行",
+ UnderlyingInstrumentType = "Stock",
+ StartDate = new DateTime(2026, 8, 1),
+ ExerciseDate = new DateTime(2027, 8, 1)
+ };
+ var positions = new List
+ {
+ new() { SwapTradeId = 7, PositionId = 101, UnderlyingCode = "600000.SH", PositionType = 1 },
+ new() { SwapTradeId = 7, PositionId = 102, InterestMode = (int)InterestModeEnum.固定值, InterestRateDefault = 0.0123m, InterestDirection = 2 }
+ };
+ var swapPositions = new Dictionary
+ {
+ [101] = new() { id = 101, category_tag = "互换利率" },
+ [102] = new() { id = 102, category_tag = "互换利率" }
+ };
+
+ var item = TrsContractKafkaPushService.BuildContract(
+ eodSwap,
+ new Dictionary { [7] = trade },
+ positions,
+ swapPositions);
+
+ Assert.AreEqual("2026-08-24", item.TradeDate);
+ Assert.AreEqual(3, item.BookId);
+ Assert.AreEqual("TRS-001", item.SwapTradeNo);
+ Assert.AreEqual(8, item.ClientId);
+ Assert.AreEqual("600000.SH", item.UnderlyingCode);
+ Assert.AreEqual("浦发银行", item.UnderlyingName);
+ Assert.AreEqual("Stock", item.UnderlyingInstrumentType);
+ Assert.AreEqual(1000000m, item.NotionalValue);
+ Assert.AreEqual("2026-08-01", item.StartDate);
+ Assert.AreEqual("2027-08-01", item.MaturityDate);
+ Assert.AreEqual(12.34m, item.Dv01);
+ Assert.AreEqual(0.0123m, item.FixedRate);
+ Assert.AreEqual(2, item.InterestDirection);
+ Assert.AreEqual(1, item.FloatingDirection);
+ Assert.AreEqual(100m, item.InitMarginGain);
+ Assert.AreEqual(0m, item.InitMarginLoss);
+ }
+
+ private static TestableTrsContractKafkaPushService CreateService(RecordingKafkaProducer producer, DateTime valueDate)
+ {
+ return new TestableTrsContractKafkaPushService(
+ producer,
+ new Dictionary { [valueDate] = CreateEmptySnapshot(valueDate) });
+ }
+
+ private static TrsContractSnapshot CreateEmptySnapshot(DateTime valueDate)
+ {
+ return new TrsContractSnapshot
+ {
+ SchemaVersion = "v1",
+ ValueDate = valueDate.ToString("yyyy-MM-dd"),
+ PushTime = "2026-08-24 12:00:00",
+ ContractCount = 0,
+ Contracts = new List()
+ };
+ }
+
+ private sealed class TestableTrsContractKafkaPushService : TrsContractKafkaPushService
+ {
+ private readonly IReadOnlyDictionary _snapshots;
+
+ public List<(DateTime ValueDate, int RetryCount, Exception Exception)> FailureRecords { get; } = new();
+
+ public TestableTrsContractKafkaPushService(IKafkaProduce producer, IReadOnlyDictionary snapshots)
+ : base(new YLContext(), producer, "onederiv.trs.contract.v1")
+ {
+ _snapshots = snapshots;
+ }
+
+ protected override TrsContractSnapshot BuildSnapshot(DateTime valueDate)
+ {
+ return _snapshots[valueDate];
+ }
+
+ protected override void RecordFailures(DateTime valueDate, int retryCount, Exception exception)
+ {
+ FailureRecords.Add((valueDate, retryCount, exception));
+ }
+ }
+
+ private sealed class RecordingKafkaProducer : IKafkaProduce
+ {
+ public int FailuresBeforeSuccess { get; set; }
+ public int AttemptCount { get; private set; }
+ public List<(string Topic, string Key, string Message)> Messages { get; } = new();
+
+ public void Produce(string topic, string message)
+ {
+ throw new NotSupportedException();
+ }
+
+ public void Produce(string topic, string key, string message)
+ {
+ AttemptCount++;
+ if (AttemptCount <= FailuresBeforeSuccess)
+ {
+ throw new InvalidOperationException("Kafka unavailable");
+ }
+
+ Messages.Add((topic, key, message));
+ }
+ }
+ }
+}
diff --git a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs
index 7e202a38..67570ef6 100644
--- a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs
+++ b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Reflection;
using Newtonsoft.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.DBModels;
@@ -20,6 +21,7 @@ namespace YLErp.Modules.SwapModule
public void RegistrationSnapshot_IsPending_AndKeepsBeforeFields()
{
var info = CreateAction(77, ConsGlobal.InstrumentType.Stock);
+ info.GiveShareAmount = 1m;
var before = CreateEodPosition(9, info.UnderlyingCode, 1000m, 100m);
var snapshot = SwapEodPositionService.BuildCorporateActionEventData(
@@ -37,8 +39,9 @@ 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, "股权登记日:2026-08-14 发生公司行为(送股)");
+ StringAssert.Contains(reason, "调整前:名义本金:100000 期初标的价格:100 持仓数量:1000");
+ StringAssert.Contains(reason, "调整后:名义本金:0 期初标的价格:0 持仓数量:0");
}
[TestMethod]
@@ -65,6 +68,23 @@ namespace YLErp.Modules.SwapModule
Assert.IsFalse(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.TBonds));
}
+ [TestMethod]
+ public void CorporateActionReason_ShowsOneActionTypeOrActualCashDividend()
+ {
+ AssertActionDescription(
+ new CorporateActionEventData { ExDividendDate = RecordDate, RationedSharesAmount = 1m, GiveShareAmount = 1m, Split = 2m, GiveCashAmount = 10m, CashFlowChange = 1000m },
+ "发生公司行为(配股)");
+ AssertActionDescription(
+ new CorporateActionEventData { ExDividendDate = RecordDate, GiveShareAmount = 1m, Split = 2m, GiveCashAmount = 10m, CashFlowChange = 1000m },
+ "发生公司行为(送股)");
+ AssertActionDescription(
+ new CorporateActionEventData { ExDividendDate = RecordDate, Split = 2m, GiveCashAmount = 10m, CashFlowChange = 1000m },
+ "发生公司行为(拆分)");
+ AssertActionDescription(
+ new CorporateActionEventData { ExDividendDate = RecordDate, GiveCashAmount = 11m, CashFlowChange = 220000m },
+ "发生公司行为(产生分红:220000)");
+ }
+
[TestMethod]
public void Rerun_DoesNotCreateDuplicateCorporateActionEvent()
{
@@ -113,7 +133,7 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
- public void OperationHistory_PreservesPendingCorporateActionForAudit()
+ public void OperationHistory_HidesPendingCorporateActionUntilItIsApplied()
{
var info = CreateAction(80, ConsGlobal.InstrumentType.Stock);
var pendingData = SwapEodPositionService.BuildCorporateActionEventData(
@@ -133,12 +153,17 @@ namespace YLErp.Modules.SwapModule
new swap_event { id = 3, EventType = (int)SwapEventTypeEnum.互换, EventData = "{}" }
};
- // 操作历史不再隐藏登记日待生效事件;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);
+ var filter = typeof(SwapEventService).GetMethod(
+ "FilterOperationHistory",
+ BindingFlags.NonPublic | BindingFlags.Static);
+ Assert.IsNotNull(filter, "操作历史必须过滤登记日创建的待生效公司行为事件。");
+
+ var visibleEvents = (List)filter.Invoke(null, new object[] { events });
+
+ Assert.AreEqual(2, visibleEvents.Count);
+ Assert.IsFalse(visibleEvents.Any(x => x.id == 1));
+ Assert.IsTrue(visibleEvents.Any(x => x.id == 2));
+ Assert.IsTrue(visibleEvents.Any(x => x.id == 3));
}
[TestMethod]
@@ -283,6 +308,11 @@ namespace YLErp.Modules.SwapModule
};
}
+ private static void AssertActionDescription(CorporateActionEventData data, string expected)
+ {
+ StringAssert.Contains(SwapEventService.BuildCorporateActionEventReason(data), expected);
+ }
+
private static eod_swap_position CreateEodPosition(long positionId, string code, decimal quantity, decimal price)
{
return new eod_swap_position
diff --git a/UnitTestProject/Modules/SwapModule/SwapEodPositionRiskNewTabTest.cs b/UnitTestProject/Modules/SwapModule/SwapEodPositionRiskNewTabTest.cs
new file mode 100644
index 00000000..97a1527d
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/SwapEodPositionRiskNewTabTest.cs
@@ -0,0 +1,178 @@
+using YLErp.DBModels;
+using YLErp.DBModels.Enums;
+
+namespace YLErp.Modules.SwapModule;
+
+///
+/// EQD-7084 新“框架合约”Tab 的口径测试。
+/// 纯计算测试不依赖数据库,直接锁定 EodPnlCalculator 的新口径。
+///
+[TestClass]
+public class SwapEodPositionRiskNewTabTest
+{
+ [TestMethod]
+ public void 新口径_普通利息排除保证金_浮动收益剔除费用并保留估值总额()
+ {
+ var floating = new[]
+ {
+ // EOD 归一后,费用已经按我方收益视角落库;支付费用为负数。
+ FloatingLeg("600000", 1, 100m, 0m, -12m, "普通收益互换")
+ };
+ var interests = new[]
+ {
+ InterestLeg(1, (int)InterestModeEnum.固定值, 80m, 0.02m, 0.02m),
+ InterestLeg(1, (int)InterestModeEnum.初始预付金, 20m, 0.01m, 0.01m)
+ };
+
+ var fields = InvokeCalculation(
+ floating,
+ interests,
+ structureType: "普通收益互换",
+ notionalValue: 1_000m,
+ startDate: new DateTime(2026, 1, 1),
+ maturityDate: new DateTime(2026, 12, 31),
+ periodAmount: 5m,
+ dividendPayDate: 0);
+
+ Assert.AreEqual(100m, GetDecimal(fields, "FloatingUnrealizedPnl"), 0.0001m,
+ "新浮动端待实现收益应排除 PosiFeePending:PosiProfitSum(88) - PosiFeePending(-12) = 100");
+ Assert.AreEqual(-12m, GetDecimal(fields, "OpeningClosingFee"), 0.0001m,
+ "开平仓费用直接使用 EOD 已归一的 PosiFeePending");
+ Assert.AreEqual(80m, GetDecimal(fields, "OrdinaryInterestPnl"), 0.0001m,
+ "利息端待实现收益应排除初始/维持保证金腿");
+ Assert.AreEqual(-20m, GetDecimal(fields, "MarginInterestAmount"), 0.0001m,
+ "保证金利息仍应按保证金腿方向计入估值");
+ Assert.AreEqual(153m, GetDecimal(fields, "MaturityNettingValuation"), 0.0001m,
+ "估值应保持旧口径:100 - 12 + 80 - 20 + 5 = 153;费用只计一次");
+ }
+
+ [TestMethod]
+ public void 新口径_当前利率合计使用普通利息腿TdInterestRate_并识别FR007()
+ {
+ var fr007Leg = InterestLeg(2, (int)InterestModeEnum.合约名义本金规模, 20m, 0.03m, 0.018m);
+ fr007Leg.FloatRateUnderlyingCode = "FR007";
+ fr007Leg.FloatRate = 0.015m;
+ var fields = InvokeCalculation(
+ new[] { FloatingLeg("600001", 2, 100m, 0m, 0m, "普通收益互换") },
+ new[]
+ {
+ InterestLeg(1, (int)InterestModeEnum.固定值, 10m, 0.02m, 0.0125m),
+ fr007Leg
+ },
+ structureType: "普通收益互换",
+ notionalValue: 100m,
+ startDate: new DateTime(2026, 2, 1),
+ maturityDate: new DateTime(2026, 8, 1),
+ periodAmount: 0m,
+ dividendPayDate: 1);
+
+ Assert.AreEqual(0.0305m, GetDecimal(fields, "InterestRatePrice"), 0.0000001m,
+ "利率端价格应为普通利息腿当前 TdInterestRate 合计,而非默认利差合计");
+ Assert.AreEqual("FR007", GetString(fields, "InterestBenchmark"));
+ }
+
+ [TestMethod]
+ public void 新口径_普通利息腿无FR007时基准为固定利率()
+ {
+ var fields = InvokeCalculation(
+ new[] { FloatingLeg("600002", 1, 100m, 0m, 0m, "普通收益互换") },
+ new[] { InterestLeg(1, (int)InterestModeEnum.固定值, 10m, 0.02m, 0.0125m) },
+ structureType: "普通收益互换",
+ notionalValue: 100m,
+ startDate: new DateTime(2026, 2, 1),
+ maturityDate: new DateTime(2026, 8, 1),
+ periodAmount: 0m,
+ dividendPayDate: 1);
+
+ Assert.AreEqual("固定利率", GetString(fields, "InterestBenchmark"));
+ }
+
+ [TestMethod]
+ public void 新口径_债券期初价格按风险页约定放大百分价格_并保留合同字段()
+ {
+ var fields = InvokeCalculation(
+ new[] { FloatingLeg("110000", 1, 99.12m, 0m, 0m, "普通债券类收益互换", "Bond") },
+ new[] { InterestLeg(1, (int)InterestModeEnum.固定值, 1m, 0.01m, 0.01m) },
+ structureType: "普通债券类收益互换",
+ notionalValue: 9_900m,
+ startDate: new DateTime(2026, 3, 1),
+ maturityDate: new DateTime(2027, 3, 1),
+ periodAmount: 0m,
+ dividendPayDate: 1);
+
+ Assert.AreEqual(99.12m, GetDecimal(fields, "InitialPrice"), 0.0001m,
+ "债券日终 PosiGrossPrice 已由 SetPosiPrice 按风险页口径缩放,新接口不能再次乘 100");
+ Assert.AreEqual(9_900m, GetDecimal(fields, "NotionalQuantity"), 0.0001m);
+ Assert.AreEqual("多头", GetString(fields, "UnderlyingDirection"));
+ Assert.AreEqual("110000", GetString(fields, "UnderlyingCode"));
+ Assert.AreEqual("Bond", GetString(fields, "UnderlyingInstrumentType"));
+ Assert.AreEqual(new DateTime(2026, 3, 1), GetDate(fields, "ContractStartDate"));
+ Assert.AreEqual(new DateTime(2027, 3, 1), GetDate(fields, "ContractMaturityDate"));
+ }
+
+ private static object InvokeCalculation(
+ IEnumerable floating,
+ IEnumerable interests,
+ string structureType,
+ decimal notionalValue,
+ DateTime startDate,
+ DateTime maturityDate,
+ decimal periodAmount,
+ int dividendPayDate)
+ {
+ return EodPnlCalculator.CalculateEodSwapRiskNewFields(
+ floating,
+ interests,
+ structureType,
+ notionalValue,
+ startDate,
+ maturityDate,
+ periodAmount,
+ dividendPayDate);
+ }
+
+ private static decimal GetDecimal(object fields, string name)
+ => Convert.ToDecimal(fields.GetType().GetProperty(name)!.GetValue(fields));
+
+ private static string GetString(object fields, string name)
+ => (string)fields.GetType().GetProperty(name)!.GetValue(fields)!;
+
+ private static DateTime GetDate(object fields, string name)
+ => (DateTime)fields.GetType().GetProperty(name)!.GetValue(fields)!;
+
+ private static eod_swap_position FloatingLeg(
+ string code,
+ int positionType,
+ decimal mtm,
+ decimal dividend,
+ decimal fee,
+ string structureType,
+ string instrumentType = null)
+ => new()
+ {
+ UnderlyingCode = code,
+ UnderlyingInstrumentType = instrumentType ?? structureType,
+ PositionType = positionType,
+ PosiGrossPrice = mtm,
+ PosiMtmPnL = mtm,
+ PosiDividendSum = dividend,
+ PosiFeePending = fee,
+ PosiProfitSum = mtm + dividend + fee,
+ PosiNotionalValue = 100m
+ };
+
+ private static eod_swap_position InterestLeg(
+ int direction,
+ int mode,
+ decimal profit,
+ decimal defaultRate,
+ decimal currentRate)
+ => new()
+ {
+ InterestDirection = direction,
+ InterestMode = mode,
+ InterestProfitSum = profit,
+ InterestRateDefault = defaultRate,
+ TdInterestRate = currentRate
+ };
+}
diff --git a/YLErpDAL/Abstract/IKafkaProduce.cs b/YLErpDAL/Abstract/IKafkaProduce.cs
index d95e6ddc..a0a78943 100644
--- a/YLErpDAL/Abstract/IKafkaProduce.cs
+++ b/YLErpDAL/Abstract/IKafkaProduce.cs
@@ -9,5 +9,7 @@ namespace YLErp.Abstract
public interface IKafkaProduce
{
void Produce(string topic, string message);
+
+ void Produce(string topic, string key, string message);
}
}
diff --git a/YLErpDAL/DataBase/YLContext.cs b/YLErpDAL/DataBase/YLContext.cs
index dbffc149..1c828a21 100644
--- a/YLErpDAL/DataBase/YLContext.cs
+++ b/YLErpDAL/DataBase/YLContext.cs
@@ -354,6 +354,7 @@ namespace YLErp.BLL
public DbSet eod_swap_position { get; set; }
public DbSet swap_event { get; set; }
public DbSet eod_swap { get; set; }
+ public DbSet push_status { get; set; }
public DbSet trade_obervation { get; set; }
public DbSet SystemLogs { get; set; }
diff --git a/YLErpDAL/Helpers/KafkaProduceHelper.cs b/YLErpDAL/Helpers/KafkaProduceHelper.cs
index b01955fd..4c5789a3 100644
--- a/YLErpDAL/Helpers/KafkaProduceHelper.cs
+++ b/YLErpDAL/Helpers/KafkaProduceHelper.cs
@@ -63,19 +63,29 @@ namespace YLErp.Helpers
public void Produce(string topic,string message)
{
- var kafkaMessage = new Message
- {
- Key=null,
- Value = message
- };
try
{
- _producer.ProduceAsync(topic, kafkaMessage).GetAwaiter().GetResult();
+ ProduceCore(topic, null, message);
}
catch (Exception ex)
{
_logger.Error($"Topic:{topic} send failed",ex);
}
}
+
+ public void Produce(string topic, string key, string message)
+ {
+ ProduceCore(topic, key, message);
+ }
+
+ private void ProduceCore(string topic, string key, string message)
+ {
+ var kafkaMessage = new Message
+ {
+ Key = key,
+ Value = message
+ };
+ _producer.ProduceAsync(topic, kafkaMessage).GetAwaiter().GetResult();
+ }
}
}
diff --git a/YLErpDAL/Model/KafkaConfig.cs b/YLErpDAL/Model/KafkaConfig.cs
index 341ba482..5cd9cf36 100644
--- a/YLErpDAL/Model/KafkaConfig.cs
+++ b/YLErpDAL/Model/KafkaConfig.cs
@@ -60,7 +60,7 @@ namespace YLErp.Model
///
/// TRS合约数据推送topic(对外,如onebp等)
///
- public string ContractTopic { get; set; } = "onederi.trs.onebp.contract.v1";
+ public string ContractTopic { get; set; } = "onederiv.trs.contract.v1";
}
}
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/EodModule/SettlementModule/EodTaskRunner.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodTaskRunner.cs
index eb3da751..0a9e65fe 100644
--- a/YLErpDAL/Modules/EodModule/SettlementModule/EodTaskRunner.cs
+++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodTaskRunner.cs
@@ -10,6 +10,12 @@ using YLErp.Modules.SystemModule;
using YLErp.Modules.TradeDalModule;
using YLErp.Modules.TradeModule.DealModule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using YieldChain.Commons;
+using YLErp.Abstract;
+using YLErp.Model;
+
namespace YLErp.Modules.EodModule.SettlementModule
{
///
@@ -445,12 +451,35 @@ where {nameof(t.TaskStartTime)}>'{startDateStr}' and {nameof(t.TaskState)}={(int
}
ClientBalanceUtility.saveClientRiskMonitor(eodTask.ValueDate);
new EodFileService(this.OptUser).GenerateFileAfterEod(eodTask.ValueDate);
+ PushTrsContractSnapshot(eodTask.ValueDate);
//执行下一日
eodTask.ValueDate = eodTask.ValueDate.AddDays(1);
eodTask.TaskEndTime = DateTime.Now;
}
}
+ private void PushTrsContractSnapshot(DateTime valueDate)
+ {
+ try
+ {
+ var provider = YLServiceLocator.ServiceProvider;
+ var kafkaProduce = provider?.GetService();
+ var kafkaOptions = provider?.GetService>();
+ if (kafkaProduce == null || kafkaOptions?.Value == null)
+ {
+ LogFactory.GetLogger("TRS合约日终Kafka推送").Error("Kafka service or configuration is unavailable");
+ return;
+ }
+
+ using var pushDbContext = DbContextFactory.GetYLDbContext();
+ new TrsContractKafkaPushService(pushDbContext, kafkaProduce, kafkaOptions.Value.ContractTopic).Push(valueDate);
+ }
+ catch (Exception ex)
+ {
+ LogFactory.GetLogger("TRS合约日终Kafka推送").Error($"TRS contract snapshot task failed, valueDate:{valueDate:yyyy-MM-dd}", ex);
+ }
+ }
+
///
/// 查找第一个可用的任务
///
diff --git a/YLErpDAL/Modules/EodModule/TrsContractKafkaPushService.cs b/YLErpDAL/Modules/EodModule/TrsContractKafkaPushService.cs
new file mode 100644
index 00000000..0efc85e1
--- /dev/null
+++ b/YLErpDAL/Modules/EodModule/TrsContractKafkaPushService.cs
@@ -0,0 +1,233 @@
+using YLErp.Abstract;
+using YLErp.BLL;
+using YLErp.DBModels;
+using YLErp.Helpers;
+
+namespace YLErp.Modules.EodModule
+{
+ ///
+ /// 收盘后按交易日推送 TRS 合约全量快照。
+ ///
+ public class TrsContractKafkaPushService
+ {
+ private const string DateFormat = "yyyy-MM-dd";
+ private const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss";
+ private const string InterestCategory = "互换利率";
+ private const int TrsContractPushType = 1;
+ private const int MaxAttempts = 3;
+
+ private readonly YLContext _dbContext;
+ private readonly IKafkaProduce _kafkaProduce;
+ private readonly string _topic;
+ private readonly IYcLogger _logger;
+
+ public TrsContractKafkaPushService(YLContext dbContext, IKafkaProduce kafkaProduce, string topic)
+ {
+ _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
+ _kafkaProduce = kafkaProduce ?? throw new ArgumentNullException(nameof(kafkaProduce));
+ _topic = string.IsNullOrWhiteSpace(topic) ? throw new ArgumentException("Kafka topic is empty", nameof(topic)) : topic;
+ _logger = LogFactory.GetLogger(nameof(TrsContractKafkaPushService));
+ }
+
+ public void Push(DateTime valueDate)
+ {
+ valueDate = valueDate.Date;
+
+ TrsContractSnapshot snapshot;
+ string payload;
+ try
+ {
+ snapshot = BuildSnapshot(valueDate);
+ payload = JsonHelper.Serialize(snapshot, true, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"TRS contract snapshot build failed, valueDate:{valueDate:yyyy-MM-dd}", ex);
+ RecordFailures(valueDate, 0, ex);
+ return;
+ }
+
+ var key = valueDate.ToString(DateFormat);
+ Exception lastException = null;
+ for (var attempt = 1; attempt <= MaxAttempts; attempt++)
+ {
+ try
+ {
+ _kafkaProduce.Produce(_topic, key, payload);
+ _logger.Info($"TRS contract snapshot sent, valueDate:{key}, topic:{_topic}, count:{snapshot.ContractCount}, attempt:{attempt}");
+ return;
+ }
+ catch (Exception ex)
+ {
+ lastException = ex;
+ _logger.Error($"TRS contract snapshot send failed, valueDate:{key}, topic:{_topic}, attempt:{attempt}", ex);
+ }
+ }
+
+ _logger.Error($"TRS contract snapshot send exhausted retries, valueDate:{key}, topic:{_topic}, attempts:{MaxAttempts}");
+ RecordFailures(valueDate, MaxAttempts, lastException);
+ }
+
+ protected virtual void RecordFailures(DateTime valueDate, int retryCount, Exception exception)
+ {
+ try
+ {
+ var recordIds = _dbContext.eod_swap
+ .Where(x => x.ValueDate == valueDate)
+ .Select(x => x.id)
+ .ToList();
+ if (recordIds.Count == 0)
+ {
+ recordIds.Add(0);
+ }
+
+ var now = DateTime.Now;
+ var statuses = _dbContext.push_status
+ .Where(x => x.ValueDate == valueDate
+ && x.PushType == TrsContractPushType
+ && recordIds.Contains(x.RecordId))
+ .ToList();
+ var error = exception?.ToString();
+ if (error?.Length > 2000)
+ {
+ error = error.Substring(0, 2000);
+ }
+
+ foreach (var recordId in recordIds)
+ {
+ var status = statuses.FirstOrDefault(x => x.RecordId == recordId);
+ if (status == null)
+ {
+ status = new PushStatus
+ {
+ ValueDate = valueDate,
+ PushType = TrsContractPushType,
+ RecordId = recordId,
+ CreateTime = now
+ };
+ _dbContext.push_status.Add(status);
+ }
+
+ status.State = PushStateEnum.失败;
+ status.RetryCount = retryCount;
+ status.LastError = error;
+ status.PushTime = now;
+ status.UpdateTime = now;
+ }
+
+ _dbContext.SaveChanges();
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"TRS contract push failure status save failed, valueDate:{valueDate:yyyy-MM-dd}", ex);
+ }
+ }
+
+ protected virtual TrsContractSnapshot BuildSnapshot(DateTime valueDate)
+ {
+ var eodSwaps = _dbContext.eod_swap
+ .Where(x => x.ValueDate == valueDate)
+ .AsNoTracking()
+ .ToList();
+ var tradeIds = eodSwaps.Select(x => x.SwapTradeId).Distinct().ToList();
+ var trades = _dbContext.trade
+ .Where(x => tradeIds.Contains(x.id))
+ .AsNoTracking()
+ .ToDictionary(x => x.id);
+ var eodPositions = _dbContext.eod_swap_position
+ .Where(x => x.ValueDate == valueDate && tradeIds.Contains(x.SwapTradeId) && !x.Invalid)
+ .AsNoTracking()
+ .ToList();
+ var positionIds = eodPositions.Select(x => x.PositionId).Distinct().ToList();
+ var swapPositions = _dbContext.swap_position
+ .Where(x => positionIds.Contains(x.id) && !x.Invalid && x.category_tag == InterestCategory)
+ .AsNoTracking()
+ .ToDictionary(x => x.id);
+
+ var contracts = eodSwaps.Select(eodSwap => BuildContract(eodSwap, trades, eodPositions, swapPositions)).ToList();
+ return new TrsContractSnapshot
+ {
+ SchemaVersion = "v1",
+ ValueDate = valueDate.ToString(DateFormat),
+ PushTime = DateTime.Now.ToString(DateTimeFormat),
+ ContractCount = contracts.Count,
+ Contracts = contracts
+ };
+ }
+
+ internal static TrsContractSnapshotItem BuildContract(
+ eod_swap eodSwap,
+ IReadOnlyDictionary trades,
+ IReadOnlyCollection eodPositions,
+ IReadOnlyDictionary swapPositions)
+ {
+ if (!trades.TryGetValue(eodSwap.SwapTradeId, out var trade))
+ {
+ throw new InvalidOperationException($"TRS trade not found, swapTradeId:{eodSwap.SwapTradeId}");
+ }
+
+ var positions = eodPositions.Where(x => x.SwapTradeId == eodSwap.SwapTradeId).ToList();
+ var floating = positions.Where(x => !string.IsNullOrWhiteSpace(x.UnderlyingCode) && swapPositions.ContainsKey(x.PositionId)).ToList();
+ var interest = positions.Where(x => string.IsNullOrWhiteSpace(x.UnderlyingCode)
+ && ConsTrade.InterestModels.Contains(x.InterestMode)
+ && swapPositions.TryGetValue(x.PositionId, out var swapPosition)
+ && swapPosition.category_tag == InterestCategory).ToList();
+
+ if (floating.Count != 1 || interest.Count != 1)
+ {
+ throw new InvalidOperationException($"TRS legs invalid, swapTradeId:{eodSwap.SwapTradeId}, floating:{floating.Count}, interest:{interest.Count}");
+ }
+
+ var interestLeg = interest[0];
+ var floatingLeg = floating[0];
+ return new TrsContractSnapshotItem
+ {
+ TradeDate = eodSwap.ValueDate.ToString(DateFormat),
+ BookId = eodSwap.BookId,
+ SwapTradeNo = eodSwap.SwapTradeNo,
+ ClientId = eodSwap.ClientId,
+ UnderlyingCode = trade.UnderlyingCode,
+ UnderlyingName = trade.UnderlyingAssetName,
+ UnderlyingInstrumentType = trade.UnderlyingInstrumentType,
+ NotionalValue = eodSwap.NotionalValue,
+ Dv01 = eodSwap.dv01 ?? 0,
+ StartDate = trade.StartDate?.ToString(DateFormat),
+ MaturityDate = trade.ExerciseDate?.ToString(DateFormat),
+ FixedRate = interestLeg.InterestRateDefault,
+ InterestDirection = interestLeg.InterestDirection,
+ FloatingDirection = floatingLeg.PositionType,
+ InitMarginGain = eodSwap.InitMarginGain,
+ InitMarginLoss = eodSwap.InitMarginLoss
+ };
+ }
+ }
+
+ public class TrsContractSnapshot
+ {
+ public string SchemaVersion { get; set; }
+ public string ValueDate { get; set; }
+ public string PushTime { get; set; }
+ public int ContractCount { get; set; }
+ public List Contracts { get; set; }
+ }
+
+ public class TrsContractSnapshotItem
+ {
+ public string TradeDate { get; set; }
+ public int BookId { get; set; }
+ public string SwapTradeNo { get; set; }
+ public int ClientId { get; set; }
+ public string UnderlyingCode { get; set; }
+ public string UnderlyingName { get; set; }
+ public string UnderlyingInstrumentType { get; set; }
+ public decimal NotionalValue { get; set; }
+ public decimal Dv01 { get; set; }
+ public string StartDate { get; set; }
+ public string MaturityDate { get; set; }
+ public decimal FixedRate { get; set; }
+ public int InterestDirection { get; set; }
+ public int FloatingDirection { get; set; }
+ public decimal InitMarginGain { get; set; }
+ public decimal InitMarginLoss { get; set; }
+ }
+}
diff --git a/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs
index 62a831af..2caee13f 100644
--- a/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs
+++ b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
+using YLErp.Modules.SwapModule.Margin;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace YLErp.Modules.SwapModule
@@ -180,5 +182,101 @@ namespace YLErp.Modules.SwapModule
{
position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee;
}
+
+ ///
+ /// 计算 EQD-7084 新“框架合约”Tab 的纯展示口径。
+ /// 浮动腿盯市收益、开平仓费用和普通利息分别计算;保证金腿的利息
+ /// 仅作为估值组成项保留一次,不混入新 Tab 的普通利息列。
+ ///
+ public static EodSwapRiskNewFields CalculateEodSwapRiskNewFields(
+ IEnumerable floatingLegs,
+ IEnumerable interestLegs,
+ string structureType,
+ decimal notionalValue,
+ DateTime? startDate,
+ DateTime? maturityDate,
+ decimal periodAmount,
+ int dividendPayDate)
+ {
+ // 日终明细以 UnderlyingCode 是否存在区分浮动腿和利息腿;调用方即使传入混合集合,
+ // 这里也会重新过滤,避免保证金/利息数据被带入浮动端新口径。
+ var floating = (floatingLegs ?? Enumerable.Empty())
+ .Where(x => x != null && !string.IsNullOrEmpty(x.UnderlyingCode))
+ .ToList();
+ var interests = (interestLegs ?? Enumerable.Empty())
+ .Where(x => x != null && string.IsNullOrEmpty(x.UnderlyingCode))
+ .ToList();
+ // MarginModes 覆盖初始/维持保证金相关腿。它们的利息不属于需求中的“利息端待实现收益”,
+ // 但必须单独保留,以使两个合约估值与旧口径总额保持一致。
+ var ordinaryInterests = interests.Where(x => !MarginModes.Contains(x.InterestMode)).ToList();
+ var marginInterests = interests.Where(x => MarginModes.Contains(x.InterestMode)).ToList();
+ var firstFloating = floating.FirstOrDefault();
+
+ // PosiGrossPrice 已是 EOD 归档口径的期初全价;债券价格不可在报表接口再次乘 100。
+ var initialPrice = firstFloating?.PosiGrossPrice;
+ // PosiFeePending 是日终归一后的我方损益方向:支付费用为负、收取费用为正。
+ // 本列独立展示它,下面的 valuation 再加回一次,不能因展示拆列而改变合约估值。
+ var openingClosingFee = floating.Sum(x => x.PosiFeePending);
+ // PosiMtmPnL 已排除分红和费用,避免从 PosiProfitSum 重复拆分历史费用。
+ var floatingUnrealizedPnl = floating.Sum(x => x.PosiMtmPnL);
+ var ordinaryInterestPnl = ordinaryInterests.Sum(x =>
+ x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode));
+ var marginInterestAmount = marginInterests.Sum(x =>
+ x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode));
+
+ // 新口径估值 = 去费用浮动收益 + 开平仓费用 + 普通利息 + 保证金利息。
+ // “浮动端待实现收益”列不包含费用,而合约估值仍沿用旧总额,故费用只能在此加一次。
+ var valuation = floatingUnrealizedPnl
+ + openingClosingFee
+ + ordinaryInterestPnl
+ + marginInterestAmount;
+ var result = new EodSwapRiskNewFields
+ {
+ UnderlyingInstrumentType = firstFloating?.UnderlyingInstrumentType,
+ UnderlyingDirection = string.Join(",", floating
+ .Select(x => x.PositionType == (int)PositionTypeFlag.Long ? "多头"
+ : x.PositionType == (int)PositionTypeFlag.Short ? "空头" : "")
+ .Where(x => !string.IsNullOrEmpty(x))
+ .Distinct()),
+ UnderlyingCode = string.Join(",", floating
+ .Select(x => x.UnderlyingCode)
+ .Where(x => !string.IsNullOrEmpty(x))
+ .Distinct()),
+ InitialPrice = initialPrice,
+ NotionalQuantity = notionalValue,
+ ContractStartDate = startDate,
+ ContractMaturityDate = maturityDate,
+ // 只要普通利息腿存在 FR007,即按需求显示 FR007;保证金腿不影响该展示基准。
+ InterestBenchmark = ordinaryInterests.Any(x =>
+ !string.IsNullOrWhiteSpace(x.FloatRateUnderlyingCode)
+ && x.FloatRateUnderlyingCode.IndexOf("FR007", StringComparison.OrdinalIgnoreCase) >= 0)
+ ? "FR007" : "固定利率",
+ // 使用日终当日实际适用的 TdInterestRate 合计,而非合同初始利率或利差字段。
+ InterestRatePrice = ordinaryInterests.Sum(x => x.TdInterestRate),
+ OpeningClosingFee = openingClosingFee,
+ FloatingUnrealizedPnl = floatingUnrealizedPnl,
+ OrdinaryInterestPnl = ordinaryInterestPnl,
+ MarginInterestAmount = marginInterestAmount,
+ MarginInterestGain = marginInterests
+ .Where(x => x.InterestDirection == (int)SwapDirectionEnum.支付)
+ .Sum(x => Math.Abs(x.InterestIncomeSum)),
+ MarginInterestLoss = marginInterests
+ .Where(x => x.InterestDirection == (int)SwapDirectionEnum.收取)
+ .Sum(x => -Math.Abs(x.InterestIncomeSum))
+ };
+
+ // DividendPayDate=0 表示到期才与本金轧差,期间付息/分红需要加进该口径;
+ // 其余支付方式则由现金支付承担期间金额,估值字段不再包含 periodAmount。
+ if (dividendPayDate == 0)
+ {
+ result.MaturityNettingValuation = valuation + periodAmount;
+ }
+ else
+ {
+ result.PeriodPaymentValuation = valuation;
+ }
+
+ return result;
+ }
}
}
diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
index f9fb7204..85b17685 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,
@@ -3370,6 +3391,91 @@ namespace YLErp.Modules.SwapModule
return retListResult;
}
+
+ ///
+ /// 查询 EQD-7084 新“框架合约”字段。
+ /// 旧查询负责筛选、排序、分页及旧字段计算;新字段只基于当前页对应的日终腿补充计算,
+ /// 避免改变旧接口的返回口径。
+ ///
+ public SearchListResult SearchEodSwapNewList(EodSwapQueryRequest req)
+ {
+ // 新 Tab 与旧 Tab 共享同一套权限、筛选、排序和分页边界;先复用旧查询,
+ // 再只替换需求明确调整的展示字段,避免新接口悄然改变旧口径或查询范围。
+ var oldResult = SearchEodSwapList(req);
+ var oldRows = oldResult.rows?.ToList() ?? new List();
+ var tradeIds = oldRows.Select(x => x.position.SwapTradeId).Distinct().ToList();
+ var valueDates = oldRows.Select(x => x.position.ValueDate).Distinct().ToList();
+
+ if (tradeIds.Count == 0)
+ {
+ return new SearchListResult(oldResult,
+ Enumerable.Empty());
+ }
+
+ // 当前页的交易、日终明细和扩展信息各批量读取一次,随后在内存按“交易 + 日终日”配对。
+ // 不在 rows.Select 内查询数据库,避免分页结果产生 N+1 查询。
+ var trades = DbContext.trade
+ .Where(x => tradeIds.Contains(x.id))
+ .Select(x => new { x.id, x.StartDate, x.ExerciseDate })
+ .ToDictionary(x => x.id);
+ var eodPositionDetails = DbContext.eod_swap_position
+ .Where(x => tradeIds.Contains(x.SwapTradeId)
+ && valueDates.Contains(x.ValueDate)
+ && !x.Invalid)
+ .ToList();
+ var tradeExtends = DbContext.trade_extend
+ .Where(x => tradeIds.Contains(x.TradeId))
+ .ToList();
+
+ var rows = oldRows.Select(item =>
+ {
+ // 同一交易可出现在多个日终日;必须同时匹配 ValueDate,不能把其他日期的腿混入本行。
+ var details = eodPositionDetails
+ .Where(x => x.SwapTradeId == item.position.SwapTradeId
+ && x.ValueDate == item.position.ValueDate)
+ .ToList();
+ var floatingLegs = details.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();
+ var interestLegs = details.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();
+ var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId);
+ // 缺少扩展信息时按“期间支付”处理,和旧接口的默认值保持一致。
+ var dividendPayDate = tradeExtend?.ExtendObj?.DividendPayDate ?? 1;
+ trades.TryGetValue(item.position.SwapTradeId, out var tradeInfo);
+
+ return new EodSwapRiskNewResponse
+ {
+ position = item.position,
+ TradeDate = item.TradeDate,
+ SwapTradeNo = item.SwapTradeNo,
+ ClientName = item.ClientName,
+ StructureType = item.StructureType,
+ AssetBookName = item.AssetBookName,
+ ClientId = item.ClientId,
+ SwapTradeTypeStr = item.SwapTradeTypeStr,
+ UnderlyingType = item.UnderlyingType,
+ PeriodAmount = item.PeriodAmount,
+ FloatingUnrealizedPnl = item.FloatingUnrealizedPnl,
+ InterestPaymentMethod = item.InterestPaymentMethod,
+ MaturityNettingValuation = item.MaturityNettingValuation,
+ PeriodPaymentValuation = item.PeriodPaymentValuation,
+ MarginInterestGain = item.MarginInterestGain,
+ MarginInterestLoss = item.MarginInterestLoss,
+ // 所有 EQD-7084 差异集中在 NewFields;上方复制的旧字段用于保留原报表的
+ // 基本信息、DV、期间金额及已实现收益,前端再将六个差异列绑定到 NewFields。
+ NewFields = CalculateEodSwapRiskNewFields(
+ floatingLegs,
+ interestLegs,
+ item.StructureType,
+ item.position.NotionalValue,
+ tradeInfo?.StartDate,
+ tradeInfo?.ExerciseDate,
+ item.PeriodAmount,
+ dividendPayDate)
+ };
+ }).ToList();
+
+ return new SearchListResult(oldResult, rows);
+ }
+
///
/// 获取互换交易日终持仓数据
///
@@ -3448,6 +3554,29 @@ namespace YLErp.Modules.SwapModule
return retListResult;
}
+ ///
+ /// 计算 EQD-7084 新“框架合约”Tab 的字段口径。
+ /// 纯函数只依赖日终浮动腿、利息腿和交易级展示参数,供查询接口及无库单测共用。
+ ///
+ public static EodSwapRiskNewFields CalculateEodSwapRiskNewFields(
+ IEnumerable floatingLegs,
+ IEnumerable interestLegs,
+ string structureType,
+ decimal notionalValue,
+ DateTime? startDate,
+ DateTime? ExerciseDate,
+ decimal periodAmount,
+ int dividendPayDate)
+ => EodPnlCalculator.CalculateEodSwapRiskNewFields(
+ floatingLegs,
+ interestLegs,
+ structureType,
+ notionalValue,
+ startDate,
+ ExerciseDate,
+ periodAmount,
+ dividendPayDate);
+
///
/// 互换持仓明细查询
///
@@ -3590,7 +3719,7 @@ namespace YLErp.Modules.SwapModule
else if (isEtf)
{
item.PeriodAmount = null;
- item.DividendAmount = pendingDividend;
+ item.DividendAmount = -pendingDividend; // 每日估值报告是客户视角 取值与日终持仓风险相反
}
else
{
@@ -3634,9 +3763,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;
}
@@ -3681,10 +3811,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/YLErpDAL/Modules/SwapModule/SwapEventService.cs b/YLErpDAL/Modules/SwapModule/SwapEventService.cs
index 711bc06f..6ecc5433 100644
--- a/YLErpDAL/Modules/SwapModule/SwapEventService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapEventService.cs
@@ -207,8 +207,8 @@ namespace YLErp.Modules.SwapModule
return events;
}
///
- /// 获取交易操作历史。登记日创建但尚未到 EffectiveDate 的公司行为事件也保留,
- /// 由 EventData.Applied=false 表示“待生效”,保证审计日志完整可追溯。
+ /// 获取交易操作历史。登记日创建的待生效公司行为仍保留在审计数据中,
+ /// 但在 EffectiveDate 将其更新为 Applied=true 前不对操作历史展示。
///
/// 交易id
///
@@ -218,7 +218,30 @@ namespace YLErp.Modules.SwapModule
.Where(x => x.SwapTradeId == tradeId)
.OrderByDescending(o => o.id)
.ToList();
- return list;
+ return FilterOperationHistory(list);
+ }
+
+ ///
+ /// 过滤尚未生效的公司行为事件。非公司行为、已生效事件和无法识别的历史事件均保留,
+ /// 避免过滤条件误伤既有操作记录。
+ ///
+ private static List FilterOperationHistory(IEnumerable events)
+ {
+ if (events == null)
+ {
+ return new List();
+ }
+
+ return events
+ .Where(x => !IsPendingCorporateActionEvent(x))
+ .ToList();
+ }
+
+ private static bool IsPendingCorporateActionEvent(swap_event swapEvent)
+ {
+ return swapEvent?.EventType == (int)SwapEventTypeEnum.公司行为
+ && TryDeserializeCorporateActionEventData(swapEvent, out var data)
+ && !data.Applied;
}
///
@@ -247,8 +270,8 @@ namespace YLErp.Modules.SwapModule
}
///
- /// 公司行为说明使用稳定的键值格式,完整保留调整前后名义本金、价格、数量、
- /// 待实现分红和现金流变化,操作历史无需重新计算即可核对。
+ /// 公司行为说明仅展示调整前后的名义本金、期初标的价格和持仓数量,
+ /// 便于操作历史直接比对持仓基线。
///
public static string BuildCorporateActionEventReason(CorporateActionEventData data)
{
@@ -257,37 +280,38 @@ 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[]
+ string ActionDescription()
{
- $"公司行为[{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}"
- });
+ if (data.RationedSharesAmount != 0m)
+ {
+ return "配股";
+ }
+ if (data.GiveShareAmount != 0m)
+ {
+ return "送股";
+ }
+ if (data.Split.HasValue && data.Split.Value != 1m)
+ {
+ return "拆分";
+ }
+ if (data.GiveCashAmount != 0m)
+ {
+ // return $"产生分红:{D(data.CashFlowChange)}";
+ return $"产生分红";
+ }
+ return "公司行为";
+ }
+
+ return $"股权登记日:{Date(data.ExDividendDate)} 发生公司行为({ActionDescription()})"
+ + Environment.NewLine
+ + $"调整前:名义本金:{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 8b026f5d..1deac603 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)
@@ -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/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs
index 2f49e8e9..45a8497e 100644
--- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs
+++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs
@@ -70,8 +70,7 @@ public static class SwapSettlementBillRowBuilder
?? throw new ServiceException("结算单缺少支付日");
// 将同一事件的普通利息与预付金利息分开;预付金本金仅统计结算日前已生效的腿。
- var floatingPosition = positions.FirstOrDefault(x => x.PositionType > 0)
- ?? positions.FirstOrDefault(x => !ConsTrade.InterestMarginModels.Contains(x.InterestMode));
+ var settlementPosition = positions.FirstOrDefault(x => x.id == input.CloseFlow.PositionId);
var interestEvents = eventFlows
.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode))
.ToList();
@@ -91,10 +90,12 @@ public static class SwapSettlementBillRowBuilder
var periodAmount = -input.CloseFlow.DividendIn;
var initialMargin = SumMargin(effectiveMargins, InterestModeEnum.初始预付金);
var additionalMargin = SumMargin(effectiveMargins, InterestModeEnum.追加预付金);
+ var marginBackAmount = marginEvents.Sum(x => x.InterestPrincipal);
- // 净额结算按实际轧差项求和;到期结算在净额基础上返还或收取期初、追加预付金。
- var netSettlementAmount = interestAmount + floatingAmount + fee + marginInterest
- + (input.IncludePeriodPaymentInNetting ? periodAmount : 0m);
+ // 沿用原结算单口径:全部事件利息、浮动盈亏和预付金返还本金参与净额结算。
+ var netSettlementAmount = -eventFlows.Sum(x => x.InterestClosePnL)
+ - input.CloseFlow.FloatPnlSum
+ + marginBackAmount;
var maturitySettlementAmount = netSettlementAmount + initialMargin + additionalMargin;
var floatRateAbs = input.CloseNotionalValue == 0m
? 0m
@@ -119,17 +120,17 @@ public static class SwapSettlementBillRowBuilder
InterestRate = interestEvents.Sum(x => x.InterestRate).ToString("0.00%"),
PosiNotionalValue = input.CloseNotionalValue.ToString("0.00"),
Quantity = input.CloseFlow.Quantity.ToString("0.00"),
- DividendIn = isCashBond ? periodAmount.ToString("0.00") : string.Empty,
+ DividendIn = periodAmount.ToString("0.00"),
PeriodDividend = isEtf ? periodAmount.ToString("0.00") : string.Empty,
- PosiNetPrice = ((floatingPosition?.PosiGrossPrice ?? 0m) * 100m).ToString("0.00000000"),
+ PosiNetPrice = ((settlementPosition?.PosiGrossPrice ?? 0m) * 100m).ToString("0.00000000"),
InitYtm = isCashBond && input.Trade.InitYtm.HasValue
? input.Trade.InitYtm.Value.ToString("0.####%")
: string.Empty,
ClosePrice = (input.CloseFlow.TradingAmountAvg * 100m).ToString("0.00000000"),
- ExitYtm = isCashBond && input.ExitYtm.HasValue
+ ExitYtm = input.ExitYtm.HasValue
? input.ExitYtm.Value.ToString("0.0000")
: string.Empty,
- RateDays = Math.Max(0, (eventDate - startDate).Days + 1).ToString(),
+ RateDays = Math.Max(0, (eventDate - startDate).Days).ToString(),
FloatRateAbs = floatRateAbs.ToString("0.0000%"),
FloatRate = floatRateAbs.ToString("0.0000%"),
InterestAmount = interestAmount.ToString("0.00"),
diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs
index cabfaec8..ef6cfdeb 100644
--- a/YLErpWeb/Controllers/SwapTrade2Controller.cs
+++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs
@@ -978,6 +978,23 @@ namespace YLErp.Web.Controllers
var retListResult = service.SearchEodSwapList(req);
return Json(retListResult);
}
+
+ ///
+ /// 日终持仓-互换新框架合约查询。
+ /// 先与旧框架合约接口执行相同的账簿、资产单元和客户权限收敛,
+ /// 再返回 EQD-7084 拆分后的展示字段;不能直接绕过这些条件调用服务层。
+ ///
+ ///
+ ///
+ public JsonResult EodSwapRiskNewQuery(EodSwapQueryRequest req)
+ {
+ req.BookIds = AssetUnitModel.IntersectAssetUnits(req.AssetIdGroupList, req.BookIds).ToList();
+ req.UserAssets = CurUser.GetAssetUnitIds();
+ req.UserClients = CurUser.GetClientIdsByCurUser();
+ var service = new SwapEodPositionService(CurUser);
+ var retListResult = service.SearchEodSwapNewList(req);
+ return Json(retListResult);
+ }
#endregion
#region 结算报告
///
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
{
diff --git a/YLErpWeb/Controllers/underlying_managerController.cs b/YLErpWeb/Controllers/underlying_managerController.cs
index 60dda498..69ba8b66 100644
--- a/YLErpWeb/Controllers/underlying_managerController.cs
+++ b/YLErpWeb/Controllers/underlying_managerController.cs
@@ -473,7 +473,14 @@ namespace YLErp.Web.Controllers
{
return JsonError("资产类型 必须填写");
}
-
+
+ // 重点功能:基金及基金专户的基金管理人必须填写,后端校验避免绕过页面校验。
+ model.InvestAdvisorName = model.InvestAdvisorName?.Trim();
+ if (model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund && string.IsNullOrEmpty(model.InvestAdvisorName))
+ {
+ return JsonError("基金管理人 必须填写");
+ }
+
model.EtfSubType = model.EtfSubType?.Trim();
if (model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund && string.IsNullOrEmpty(model.EtfSubType))
{
diff --git a/YLErpWeb/Views/SwapTrade2/EodPositionRisks.cshtml b/YLErpWeb/Views/SwapTrade2/EodPositionRisks.cshtml
index aded5bf8..3a8a7e9f 100644
--- a/YLErpWeb/Views/SwapTrade2/EodPositionRisks.cshtml
+++ b/YLErpWeb/Views/SwapTrade2/EodPositionRisks.cshtml
@@ -46,7 +46,12 @@
日终持仓
- 框架合约
+ @* index=2 固定保留历史报表与导出配置,供新旧口径并行核对。 *@
+ 框架合约(旧口径)
+
+
+ @* index=3 才使用 EQD-7084 新查询与拆分字段,不能复用旧 Tab 的列配置。 *@
+ 框架合约
diff --git a/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml b/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml
index 67e3501d..44bce52a 100644
--- a/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml
+++ b/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml
@@ -327,10 +327,10 @@
- @* 仅基金及基金专户维护基金管理人 *@
+ @* 重点功能:仅基金及基金专户维护基金管理人,且该字段必填 *@
-
+ *
@* 重点功能:ETF 子类由系统字典维护,使用 Fund 类控制显隐,并固定放在表单最后 *@
diff --git a/YLErpWeb/appsettings.dev.json b/YLErpWeb/appsettings.dev.json
index ecceba98..3e268d15 100644
--- a/YLErpWeb/appsettings.dev.json
+++ b/YLErpWeb/appsettings.dev.json
@@ -34,6 +34,7 @@
"CompressionType": 0, // 消息压缩方式,可以是 None(0)、Gzip(1)、Snappy(2)、Lz4(3)、Zstd(4) 中的一种,
"MessageTimeoutMs": 3000, // 控制生产者等待消息确认的时间,单位是毫秒
"ClientRateTopic": "ylClientRateTopic", //客户互换费率生产topic
+ "ContractTopic": "onederiv.trs.contract.v1", //TRS合约日终推送topic
"HedgingAccountTopic": "ylHedgingAccountTopic", //对冲账户生产topic
"ReqAccountCapitalTopic": "ReqAccountCapital", //账户资金请求topic
"OnRspAccountCapitalTopic": "OnRspAccountCapital", //账户资金请求返回topic
diff --git a/YLErpWeb/appsettings.local.json b/YLErpWeb/appsettings.local.json
index a42f229b..e2956912 100644
--- a/YLErpWeb/appsettings.local.json
+++ b/YLErpWeb/appsettings.local.json
@@ -34,6 +34,7 @@
"CompressionType": 0, // 消息压缩方式,可以是 None(0)、Gzip(1)、Snappy(2)、Lz4(3)、Zstd(4) 中的一种,
"MessageTimeoutMs": 3000, // 控制生产者等待消息确认的时间,单位是毫秒
"ClientRateTopic": "ylClientRateTopic", //客户互换费率生产topic
+ "ContractTopic": "onederiv.trs.contract.v1", //TRS合约日终推送topic
"HedgingAccountTopic": "ylHedgingAccountTopic", //对冲账户生产topic
"ReqAccountCapitalTopic": "ReqAccountCapital", //账户资金请求topic
"OnRspAccountCapitalTopic": "OnRspAccountCapital", //账户资金请求返回topic
diff --git a/YLErpWeb/appsettings.prod.json b/YLErpWeb/appsettings.prod.json
index 3df07b7a..a7264a53 100644
--- a/YLErpWeb/appsettings.prod.json
+++ b/YLErpWeb/appsettings.prod.json
@@ -36,6 +36,7 @@
"CompressionType": 0, // 消息压缩方式,可以是 None(0)、Gzip(1)、Snappy(2)、Lz4(3)、Zstd(4) 中的一种,
"MessageTimeoutMs": 3000, // 控制生产者等待消息确认的时间,单位是毫秒
"ClientRateTopic": "ylClientRateTopic", //客户互换费率生产topic
+ "ContractTopic": "onederiv.trs.contract.v1", //TRS合约日终推送topic
"HedgingAccountTopic": "ylHedgingAccountTopic", //对冲账户生产topic
"AccountCapitalTopicGroupId": "YiLian_OnRspAccountCapitalConsumer", //账户资金消费组
"ReqAccountCapitalTopic": "ReqAccountCapital", //账户资金请求topic
diff --git a/YLErpWeb/appsettings.uat.json b/YLErpWeb/appsettings.uat.json
index 87232cdd..fb6d933c 100644
--- a/YLErpWeb/appsettings.uat.json
+++ b/YLErpWeb/appsettings.uat.json
@@ -36,6 +36,7 @@
"CompressionType": 0, // 消息压缩方式,可以是 None(0)、Gzip(1)、Snappy(2)、Lz4(3)、Zstd(4) 中的一种,
"MessageTimeoutMs": 3000, // 控制生产者等待消息确认的时间,单位是毫秒
"ClientRateTopic": "ylClientRateTopic", //客户互换费率生产topic
+ "ContractTopic": "onederiv.trs.contract.v1", //TRS合约日终推送topic
"HedgingAccountTopic": "ylHedgingAccountTopic", //对冲账户生产topic
"AccountCapitalTopicGroupId": "YiLian_OnRspAccountCapitalConsumer", //账户资金消费组
"ReqAccountCapitalTopic": "ReqAccountCapital", //账户资金请求topic
diff --git a/YLErpWeb/fe-tests/eodPositionRisks.test.js b/YLErpWeb/fe-tests/eodPositionRisks.test.js
index 908528da..c26ee1b9 100644
--- a/YLErpWeb/fe-tests/eodPositionRisks.test.js
+++ b/YLErpWeb/fe-tests/eodPositionRisks.test.js
@@ -4,7 +4,8 @@ const vm = require('vm');
function loadEodPositionRiskHelpers() {
const filePath = path.join(__dirname, '../wwwroot/Scripts/app/swaptrade/EodPositionRisks.js');
- const code = fs.readFileSync(filePath, 'utf8') + '\nmodule.exports = { TradeDirectionFormat };';
+ const source = fs.readFileSync(filePath, 'utf8');
+ const code = source + '\nmodule.exports = { TradeDirectionFormat, colModelGridEodSwap, colModelGridEodSwapNew, eodSwapGroupConfig, eodSwapRiskNewGroupConfig };';
const sandbox = {
module: { exports: {} },
exports: {},
@@ -13,14 +14,25 @@ function loadEodPositionRiskHelpers() {
numberFormat() {
return function () { };
}
+ },
+ otcformat: {
+ trading: {
+ notional() { return ''; },
+ StockEqvNotional() { return ''; },
+ tradePrice() { return ''; }
+ }
+ },
+ swapPricePrecision: {
+ format() { return ''; }
}
};
vm.runInNewContext(code, sandbox, { filename: filePath });
- return sandbox.module.exports;
+ return { helpers: sandbox.module.exports, source };
}
-const { TradeDirectionFormat } = loadEodPositionRiskHelpers();
+const loaded = loadEodPositionRiskHelpers();
+const { TradeDirectionFormat, colModelGridEodSwap, colModelGridEodSwapNew, eodSwapGroupConfig, eodSwapRiskNewGroupConfig } = loaded.helpers;
describe('互换日终持仓交易方向', () => {
test.each([
@@ -37,3 +49,54 @@ describe('互换日终持仓交易方向', () => {
expect(TradeDirectionFormat(1, {}, { eodPosition: { PosiDirection: 0, PositionType: 1 } })).toBe('');
});
});
+
+describe('EQD-7084 新框架合约前端接线', () => {
+ test('新列模型保留旧列并追加九个字段,六个展示列绑定 NewFields', () => {
+ const oldColumns = colModelGridEodSwap();
+ const newColumns = colModelGridEodSwapNew();
+ const oldNames = oldColumns.map(column => column.name);
+ const newNames = newColumns.map(column => column.name);
+ const replacements = {
+ FloatingUnrealizedPnl: 'NewFields.FloatingUnrealizedPnl',
+ 'position.InterestPnL': 'NewFields.OrdinaryInterestPnl',
+ MarginInterestGain: 'NewFields.MarginInterestGain',
+ MarginInterestLoss: 'NewFields.MarginInterestLoss',
+ MaturityNettingValuation: 'NewFields.MaturityNettingValuation',
+ PeriodPaymentValuation: 'NewFields.PeriodPaymentValuation'
+ };
+ const newFields = [
+ 'NewFields.UnderlyingDirection',
+ 'NewFields.UnderlyingCode',
+ 'NewFields.InitialPrice',
+ 'NewFields.NotionalQuantity',
+ 'NewFields.ContractStartDate',
+ 'NewFields.ContractMaturityDate',
+ 'NewFields.InterestBenchmark',
+ 'NewFields.InterestRatePrice',
+ 'NewFields.OpeningClosingFee'
+ ];
+
+ expect(newColumns).toHaveLength(oldColumns.length + 9);
+ Object.entries(replacements).forEach(([oldName, newName]) => {
+ expect(newNames).toContain(newName);
+ expect(newNames).not.toContain(oldName);
+ expect(newColumns.find(column => column.name === newName).label)
+ .toBe(oldColumns.find(column => column.name === oldName).label);
+ });
+ oldNames
+ .filter(oldName => !Object.prototype.hasOwnProperty.call(replacements, oldName))
+ .forEach(oldName => expect(newNames).toContain(oldName));
+ newFields.forEach(field => expect(newNames).toContain(field));
+ });
+
+ test('index=2 保留旧 endpoint/config,index=3 使用独立 endpoint/config 且界面不启用分组', () => {
+ expect(loaded.source).toContain("queryurl = '/swaptrade2/EodSwapRiskQuery';");
+ expect(loaded.source).toContain("cloumnTargetName = \"eodSwapList\";");
+ expect(loaded.source).toContain("queryurl = '/swaptrade2/EodSwapRiskNewQuery';");
+ expect(loaded.source).toContain("cloumnTargetName = \"eodSwapRiskNewList\";");
+ expect(loaded.source).toContain('eodSwapRiskNewExportColumnNames');
+ expect(loaded.source).not.toMatch(/main\.initCollapsibleGroupHeaders\s*\(/);
+ expect(eodSwapGroupConfig).not.toBe(eodSwapRiskNewGroupConfig);
+ expect(eodSwapRiskNewGroupConfig.some(group => group.columns.includes('NewFields.InitialPrice'))).toBe(true);
+ });
+});
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js
index 8d799ab1..3afcd0aa 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js
@@ -1,6 +1,7 @@
var queryurl = '/swaptrade2/EodPositionRiskQuery';
var cloumnTargetName = "eodSwapPositionList";
var eodSwapExportColumnNames = [];
+var eodSwapRiskNewExportColumnNames = [];
$(function () {
var PostData = {};
$("#DateValueDate").datepicker({
@@ -24,6 +25,18 @@ $(function () {
}).map(function (col) {
return col.name;
});
+ } else if (page.tabIndex == 3) {
+ // 新旧口径并行:独立 endpoint、列设置 key 与标准导出列,避免用户在新 Tab 调列后影响旧报表。
+ queryurl = '/swaptrade2/EodSwapRiskNewQuery';
+ $("#myTab li:first").removeClass("active");
+ $("#myTab li:eq(2)").addClass("active");
+ cloumnTargetName = "eodSwapRiskNewList";
+ colModelGrid = colModelGridEodSwapNew();
+ eodSwapRiskNewExportColumnNames = colModelGrid.filter(function (col) {
+ return !col.optionHide;
+ }).map(function (col) {
+ return col.name;
+ });
}
PostData.ValueDate = $("#DateValueDate").val();
var grid = jQuery('#listGrid').jqGrid({
@@ -44,7 +57,8 @@ $(function () {
pagerpos: 'left',
rowNum: 25,
rowList: [25, 50, 100, 200, 10000],
- footerrow: page.tabIndex == 2,
+ // 两个框架合约 Tab 都需要承载后端返回的 DV 汇总;普通日终持仓维持原行为。
+ footerrow: page.tabIndex == 2 || page.tabIndex == 3,
loadComplete: gridComplete,
onPaging: onJqgridPaging,
grouping: true
@@ -687,6 +701,58 @@ function colModelGridEodSwap() {
return colModelGrid;
}
+// EQD-7084 新框架合约:复用旧列定义,只替换新口径字段并追加新增列。
+function colModelGridEodSwapNew() {
+ var colModelGrid = colModelGridEodSwap().map(function (col) {
+ return Object.assign({}, col);
+ });
+
+ // 替换后仍保留旧字段 index:后端沿用旧查询处理排序,NewFields 只是显示用的计算字段。
+ function replaceColumn(oldName, newName) {
+ var column = colModelGrid.find(function (col) { return col.name === oldName; });
+ if (column) {
+ column.name = newName;
+ // 新字段在服务端计算,沿用旧列的数据库排序字段,保持分页/排序请求有效。
+ column.index = oldName;
+ }
+ }
+
+ function newColumn(name, label, formatter, index) {
+ return {
+ name: name,
+ label: label,
+ index: index || name,
+ width: 150,
+ align: 'center',
+ formatter: formatter,
+ sortable: false
+ };
+ }
+
+ // 插入点必须在原“名义本金”前,使新需求字段与旧字段的业务阅读顺序、标准导出顺序一致。
+ var contractInfoIndex = colModelGrid.findIndex(function (col) {
+ return col.name === 'position.NotionalValue';
+ });
+ colModelGrid.splice(contractInfoIndex, 0,
+ newColumn('NewFields.UnderlyingDirection', '标的多空(浮动端)'),
+ newColumn('NewFields.UnderlyingCode', '标的代码'),
+ newColumn('NewFields.InitialPrice', '期初价格', InitialPriceFormat),
+ newColumn('NewFields.NotionalQuantity', '名义数量', otcformat.trading.notional),
+ newColumn('NewFields.ContractStartDate', '合约起始日', 'date'),
+ newColumn('NewFields.ContractMaturityDate', '合约到期日', 'date'),
+ newColumn('NewFields.InterestBenchmark', '利息端基准'),
+ newColumn('NewFields.InterestRatePrice', '利率端价格', PercentFormat),
+ newColumn('NewFields.OpeningClosingFee', '开平仓费用', StockEqvNotionalFormat));
+
+ replaceColumn('FloatingUnrealizedPnl', 'NewFields.FloatingUnrealizedPnl');
+ replaceColumn('position.InterestPnL', 'NewFields.OrdinaryInterestPnl');
+ replaceColumn('MarginInterestGain', 'NewFields.MarginInterestGain');
+ replaceColumn('MarginInterestLoss', 'NewFields.MarginInterestLoss');
+ replaceColumn('MaturityNettingValuation', 'NewFields.MaturityNettingValuation');
+ replaceColumn('PeriodPaymentValuation', 'NewFields.PeriodPaymentValuation');
+ return colModelGrid;
+}
+
//框架合约分组配置(对应需求《估值模块V1》2.2 字段定义)
//columns 使用 colModel.name;组内列在 colModel 中必须连续
var eodSwapGroupConfig = [
@@ -699,14 +765,27 @@ var eodSwapGroupConfig = [
{ title: '估值与实现收益', columns: ['position.dv01', 'InterestPaymentMethod', 'MaturityNettingValuation', 'PeriodPaymentValuation', 'position.RealizedPnL'] }
];
+// 新 Tab 页面不渲染可折叠分组表头(产品已要求取消界面分组);
+// 此配置只服务“导出标准格式”,因此必须与旧 Tab 分开维护而不能删除。
+var eodSwapRiskNewGroupConfig = [
+ { title: '基本信息', columns: ['position.ValueDate', 'AssetBookName', 'ClientName', 'SwapTradeNo', 'StructureType', 'SwapTradeTypeStr', 'UnderlyingType'] },
+ { title: '新增字段', columns: ['NewFields.UnderlyingDirection', 'NewFields.UnderlyingCode', 'NewFields.InitialPrice', 'NewFields.NotionalQuantity', 'NewFields.ContractStartDate', 'NewFields.ContractMaturityDate', 'NewFields.InterestBenchmark', 'NewFields.InterestRatePrice', 'NewFields.OpeningClosingFee'] },
+ { title: '名义本金', columns: ['position.NotionalValue', 'position.NotionalValueLong', 'position.NotionalValueShort'] },
+ { title: '标的市值', columns: ['position.MarketValueLong', 'position.MarketValueShort'] },
+ { title: '浮动端', columns: ['NewFields.FloatingUnrealizedPnl', 'PeriodAmount'] },
+ { title: '利息端', columns: ['NewFields.OrdinaryInterestPnl'] },
+ { title: '保证金', columns: ['position.InitMarginGain', 'position.PostionMarginGain', 'position.InitMarginLoss', 'position.PostionMarginLoss', 'NewFields.MarginInterestGain', 'NewFields.MarginInterestLoss'] },
+ { title: '估值与实现收益', columns: ['position.dv01', 'InterestPaymentMethod', 'NewFields.MaturityNettingValuation', 'NewFields.PeriodPaymentValuation', 'position.RealizedPnL'] }
+];
+
function gridComplete() {
var jgrid = $(this);
if (arguments[0].Sum) {
jgrid.footerData("set", { 'position.dv01': arguments[0].Sum["DV"] });
}
- //框架合约Tab:列设置应用完成后补充期间付息提示。
- if (page.tabIndex == 2) {
+ // 两个框架合约 Tab 均保留 DV footer 与列设置;界面使用普通单层表头。
+ if (page.tabIndex == 2 || page.tabIndex == 3) {
var defer = main.setcolumnChooser(jgrid, cloumnTargetName);
$.when(defer).done(function () {
jgrid.jqGrid('setLabel', 'PeriodAmount', null, null, {
@@ -734,13 +813,21 @@ function starttradeView(id) {
function exportVisibleColumns() {
var jgrid = jQuery('#listGrid');
var dateStr = $("#DateValueDate").val() || '';
- var tabName = page.tabIndex == 2 ? '框架合约' : '日终持仓';
+ var tabName = page.tabIndex == 2
+ ? '框架合约(旧口径)'
+ : page.tabIndex == 3 ? '框架合约' : '日终持仓';
var fileName = '日终持仓风险_互换_' + tabName + (dateStr ? '_' + dateStr : '');
- if (page.tabIndex != 2) {
+ if (page.tabIndex != 2 && page.tabIndex != 3) {
main.exportVisibleColumnsToExcel(jgrid, fileName, null);
return;
}
+ // 虽然新 Tab 不展示分组表头,标准格式导出仍按需求输出分组标题和固定列顺序。
+ var groupConfig = page.tabIndex == 3 ? eodSwapRiskNewGroupConfig : eodSwapGroupConfig;
+ var standardColumnNames = page.tabIndex == 3
+ ? eodSwapRiskNewExportColumnNames
+ : eodSwapExportColumnNames;
+
layer.open({
type: 1,
title: '选择导出方式',
@@ -753,7 +840,7 @@ function exportVisibleColumns() {
'',
success: function (layero, index) {
layero.find('.js-export-eod-swap-standard').on('click', function () {
- exportEodSwapRows(jgrid, fileName, eodSwapGroupConfig, eodSwapExportColumnNames);
+ exportEodSwapRows(jgrid, fileName, groupConfig, standardColumnNames);
layer.close(index);
});
layero.find('.js-export-eod-swap-visible').on('click', function () {
@@ -810,6 +897,13 @@ function RealizedPnlFormat(cellValue, options, rowObject) {
function StockEqvNotionalFormat(cellValue, options, rowObject) {
return otcformat.trading.StockEqvNotional(cellValue);
}
+function InitialPriceFormat(cellValue, options, rowObject) {
+ // 类型来自 NewFields(不再是旧 eodPosition 嵌套对象),以便债券按全价精度、非债券按普通价格精度展示。
+ var instrumentType = rowObject
+ && rowObject.NewFields
+ && rowObject.NewFields.UnderlyingInstrumentType;
+ return swapPricePrecision.format(cellValue, instrumentType, 'grossPrice');
+}
function NullableStockEqvNotionalFormat(cellValue, options, rowObject) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '';
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
index aeb53d83..fdfc7b01 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);
diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js
index a5a2724d..dd80db28 100644
--- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js
+++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js
@@ -135,6 +135,12 @@ function saveData() {
return main.alert("资产品种类型 必须填写!");
}
+ // 重点功能:基金及基金专户必须填写基金管理人,空格内容也视为未填写。
+ if (data.UnderlyingInstrumentType === "Fund" &&
+ (!data.InvestAdvisorName || !data.InvestAdvisorName.trim())) {
+ return main.alert("基金管理人 必须填写!");
+ }
+
// 重点功能:ETF 子类只对基金及基金专户显示并必填,先在前端阻止无效提交。
if (data.UnderlyingInstrumentType === "Fund" && !data.EtfSubType) {
return main.alert("ETF 子类 必须填写!");