diff --git a/Framework/YLErp.Core/DBModels/Consts/ConsFundTag.cs b/Framework/YLErp.Core/DBModels/Consts/ConsFundTag.cs index 2c1169fb..c5060be6 100644 --- a/Framework/YLErp.Core/DBModels/Consts/ConsFundTag.cs +++ b/Framework/YLErp.Core/DBModels/Consts/ConsFundTag.cs @@ -24,5 +24,25 @@ namespace YLErp.DBModels { return fundTag == Credit ? Credit : Cash; } + + /// + /// 腿是否按授信分配(§2.3 情形1 回退口径):腿上显式选择优先(Credit=授信、Cash=现金); + /// 腿未选(默认)时取交易级资金来源(trade.margin_fund_source,必填默认现金)——授信→按授信分配 + /// (额度不足拆单),现金→现金。交易级空值按现金(存量防御,SaveTrade 已归一)。 + /// 标签定稿(ApplyMarginFundTags)与簿记资金校验(RealtimePnlCalc.TradeCanBeConfirm) + /// 共用本口径,保证校验与定稿一致。 + /// + public static bool PreferCredit(string legFundTag, string tradeFundSource) + { + if (legFundTag == Credit) + { + return true; + } + if (legFundTag == Cash) + { + return false; + } + return string.IsNullOrEmpty(legFundTag) && tradeFundSource == Credit; + } } } 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/Framework/YLErp.Core/DBModels/TradeBase.cs b/Framework/YLErp.Core/DBModels/TradeBase.cs index 7aee59a2..057999c1 100644 --- a/Framework/YLErp.Core/DBModels/TradeBase.cs +++ b/Framework/YLErp.Core/DBModels/TradeBase.cs @@ -677,6 +677,17 @@ namespace YLErp.DBModels [TradeAuditExclude] public string MarginTemplateName { get; set; } + /// + /// 保证金资金来源(收益互换,必填,默认现金):预付金腿未选资金标签(默认)时的定稿依据(§2.3 情形1)。 + /// 值与 swap_position.fund_tag 同词表:Credit=优先授信(额度不足自动拆单)、Cash=现金。 + /// 录入页必选(新交易默认 Cash),SaveTrade 对空值归一为 Cash(兜住 DMA 等绕过页面的链路); + /// 历史来源:旧版 trade.MarginTemplateName 曾以字典文本(授信保证金/现金保证金)承载该语义,模板V2迁移后由本列承接。 + /// + [DisplayName("资金来源")] + [TradeAuditExclude] + [Column("margin_fund_source")] + public string MarginFundSource { 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/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/BondPaymentServiceCalculationTest.cs b/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs index 237cf1b0..fa37af8e 100644 --- a/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs +++ b/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs @@ -66,5 +66,23 @@ namespace YLErp.Modules.EodModule Assert.AreEqual(204000m, actual); } + + [TestMethod] + public void CalcPayment_CorporateActionUsesPreCorporateActionQuantity() + { + var payments = new List + { + new BondPayment { payment_interest = 2m }, + new BondPayment { payment_interest = 10m, IsCorporateActionCashDividend = true } + }; + var method = typeof(BondPaymentService).GetMethod( + nameof(BondPaymentService.CalcPayment), + new[] { typeof(List), typeof(decimal), typeof(decimal), typeof(decimal), typeof(decimal?) }); + + Assert.IsNotNull(method, "公司行为现金分红需要支持单独传入除权前数量。"); + var actual = (decimal)method.Invoke(CreateService(), new object[] { payments, 2000m, 1m, 1m, (decimal?)1000m }); + + Assert.AreEqual(1040m, actual, "原生付息按当前 2000 份计算为 40,公司行为分红按除权前 1000 份计算为 1000。"); + } } } 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/FundTagCalcTest.cs b/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs index 319f40d1..0bc063a4 100644 --- a/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs @@ -206,5 +206,106 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(ConsFundTag.Cash, settlements[1].Tag); Assert.AreEqual(-300m, settlements[1].MarginAmount); } + + // ================================================================ + // §2.3 情形1 交易级资金来源回退(ConsFundTag.PreferCredit): + // 腿上显式选择优先 > 交易级 margin_fund_source 回退 > 默认现金。 + // 标签定稿(ApplyMarginFundTags)与资金校验(TradeCanBeConfirm)共用本口径。 + // ================================================================ + + [TestMethod] + public void FT_031_腿选授信或现金_交易级字段不覆盖腿上显式选择() + { + Assert.IsTrue(ConsFundTag.PreferCredit(ConsFundTag.Credit, null)); + Assert.IsTrue(ConsFundTag.PreferCredit(ConsFundTag.Credit, ConsFundTag.Cash)); + Assert.IsFalse(ConsFundTag.PreferCredit(ConsFundTag.Cash, ConsFundTag.Credit)); + Assert.IsFalse(ConsFundTag.PreferCredit(ConsFundTag.Cash, null)); + } + + [TestMethod] + public void FT_032_腿未选_按交易级资金来源回退() + { + Assert.IsTrue(ConsFundTag.PreferCredit(null, ConsFundTag.Credit)); + Assert.IsTrue(ConsFundTag.PreferCredit("", ConsFundTag.Credit)); + Assert.IsFalse(ConsFundTag.PreferCredit(null, ConsFundTag.Cash)); + //交易级也未设置 → 默认现金 + Assert.IsFalse(ConsFundTag.PreferCredit(null, null)); + Assert.IsFalse(ConsFundTag.PreferCredit("", "")); + } + + // ================================================================ + // §2.3 保存前授信拆单(FundTagCalc.ApplySaveTimeSplit,2026-08-26 业务确认: + // 保存检查授信→不足拦截确认→拆完再保存;原腿=可用额度标授信、新腿=现金差额) + // ================================================================ + + private static LegAmount MarginLeg(long id, decimal fix, bool preferCredit = true) + => new() + { + Leg = new swap_position + { + id = id, + InterestDirection = 1, + InterestPrincipalFix = fix, + FundTag = preferCredit ? ConsFundTag.Credit : ConsFundTag.Cash + }, + Amount = (double)fix, + PreferCredit = preferCredit + }; + + [TestMethod] + public void FT_033_保存前拆单_额度不足_原腿授信新腿现金差额守恒() + { + var legs = new List { MarginLeg(101, 1000m) }; + var plans = FundTagCalc.AllocateByLegPreference(legs, 300, ignoreMoneyCheck: false); + var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans); + + Assert.AreEqual(1, newLegs.Count); + //原腿保留可用额度部分并标授信 + Assert.AreEqual(300m, legs[0].Leg.InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Credit, legs[0].Leg.FundTag); + //新现金腿=差额,倒挤守恒 + Assert.AreEqual(700m, newLegs[0].InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Cash, newLegs[0].FundTag); + Assert.AreEqual(0, newLegs[0].id); + Assert.IsNull(newLegs[0].Obervation); + } + + [TestMethod] + public void FT_034_保存前拆单_可用授信为零_整腿定稿现金不拆() + { + var legs = new List { MarginLeg(101, 1000m) }; + var plans = FundTagCalc.AllocateByLegPreference(legs, 0, ignoreMoneyCheck: false); + var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans); + + Assert.AreEqual(0, newLegs.Count); + Assert.AreEqual(1000m, legs[0].Leg.InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Cash, legs[0].Leg.FundTag); + } + + [TestMethod] + public void FT_035_保存前拆单_额度充足_全额授信定稿不拆_现金腿不动() + { + var legs = new List { MarginLeg(101, 1000m), MarginLeg(102, 500m, preferCredit: false) }; + var plans = FundTagCalc.AllocateByLegPreference(legs, 5000, ignoreMoneyCheck: false); + var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans); + + Assert.AreEqual(0, newLegs.Count); + Assert.AreEqual(ConsFundTag.Credit, legs[0].Leg.FundTag); + Assert.AreEqual(1000m, legs[0].Leg.InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Cash, legs[1].Leg.FundTag); + } + + [TestMethod] + public void FT_036_保存前拆单_支付方向腿按方向比折算() + { + var leg = new swap_position { id = 101, InterestDirection = 2, InterestPrincipalFix = -1000m, FundTag = ConsFundTag.Credit }; + var legs = new List { new() { Leg = leg, Amount = 1000, PreferCredit = true } }; + var plans = FundTagCalc.AllocateByLegPreference(legs, 300, ignoreMoneyCheck: false); + var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans); + + //应付额 = fix × -1(dir=2),授信部分 300 → fix = -300;现金差额倒挤 = -700 + Assert.AreEqual(-300m, leg.InterestPrincipalFix); + Assert.AreEqual(-700m, newLegs[0].InterestPrincipalFix); + } } } 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/BLL/EodSettlement/RealtimePnlCalc.cs b/YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs index d11193d8..d4b6ae4f 100644 --- a/YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs +++ b/YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs @@ -2327,8 +2327,9 @@ namespace YLErp.BLL.Eod if (trade.ExerciseDate.Value.Date >= valuedateBLL.ValueDate.Date) { // R4 簿记资金校验口径(2026-08-21 业务强调"走了资金的就不能占用授信"): - // 按腿的资金走向分流——走现金的部分(未选/选现金腿 + 成交金额)只认现金结存; - // 选授信的腿认 剩余可用授信(有效授信−已使用授信,授信出入表 Σ(amount)), + // 按腿的资金走向分流——走现金的部分(选现金/未选且交易级资金来源非授信 + 成交金额)只认现金结存; + // 按授信的腿(腿选授信,或腿未选回退交易级 margin_fund_source=授信,ConsFundTag.PreferCredit) + // 认 剩余可用授信(有效授信−已使用授信,授信出入表 Σ(amount)), // 授信不够覆盖的部分回落现金,同样只认现金结存。杜绝"现金腿拿授信垫付校验→现金透支"。 var marginModes = new[] { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; var legs = trade.swap_positions?.Where(x => marginModes.Contains(x.InterestMode)).ToList(); @@ -2346,7 +2347,7 @@ namespace YLErp.BLL.Eod { continue; } - if (leg.FundTag == YLErp.DBModels.ConsFundTag.Credit) + if (YLErp.DBModels.ConsFundTag.PreferCredit(leg.FundTag, trade.MarginFundSource)) { creditPayable += payable; } 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..fe12ab75 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; } @@ -225,10 +189,26 @@ namespace YLErp.Modules.EodModule decimal qty, decimal longRatio, decimal payDirection) + { + return CalcPayment(payments, qty, longRatio, payDirection, null); + } + + /// + /// 公司行为现金分红按登记日权益数量计算;同一窗口中的原生债券付息仍按当前持仓数量计算。 + /// + public decimal CalcPayment( + List payments, + decimal qty, + decimal longRatio, + decimal payDirection, + decimal? corporateActionQty) { var actualAmount = (payments ?? new List()).Sum(payment => { - var paymentAmount = (payment.payment_interest ?? 0m) * qty; + var paymentQty = payment.IsCorporateActionCashDividend + ? corporateActionQty ?? qty + : qty; + var paymentAmount = (payment.payment_interest ?? 0m) * paymentQty; // bond_payment_info 原生期间付息按每 100 份存储;由 ex_dividend_info 补充的 // 公司行为现金分红按每 10 份存储。Fund 标的可能同时命中两类记录,故必须逐条分流。 return payment.IsCorporateActionCashDividend 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/Margin/FundTagCalc.cs b/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs index 62a206f5..61f1c6d0 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs @@ -40,6 +40,49 @@ public static class FundTagCalc return plans; } + /// + /// 保存前授信拆单(§2.3 保存前拆单,2026-08-26 业务确认):把确认成交阶段的物理拆分前移到录入保存—— + /// 对 NeedSplit 的腿:原腿保留授信部分(InterestPrincipalFix 按可用额度折算)标 Credit, + /// 克隆一条现金差额腿(倒挤守恒)标 Cash 返回(Obervation 置空,防 SaveSwapPositions 重复插观察配置); + /// 不拆的授信偏好腿同步定稿标签:全额授信→Credit、额度为0/耗尽全额现金→Cash; + /// 现金/默认腿不动(最终定稿仍由确认成交 ApplyMarginFundTags 兜底重写)。 + /// legs 与 plans 须为 AllocateByLegPreference 的同序输入输出。占用/流水仍发生在确认成交。 + /// + public static List ApplySaveTimeSplit(List legs, List plans) + { + var newLegs = new List(); + for (var i = 0; i < plans.Count; i++) + { + if (!legs[i].PreferCredit) + { + continue; + } + var plan = plans[i]; + if (plan.NeedSplit) + { + var position = plan.Leg; + //应付额 = fix × (dir==1 ? 1 : -1),反推 fix 用同一比例(±1 自反) + var payableRatio = position.InterestDirection == 1 ? 1 : -1; + var originalFix = position.InterestPrincipalFix; + position.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CreditAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + position.FundTag = ConsFundTag.Credit; + var cashLeg = position.Clone(); + cashLeg.id = 0; + cashLeg.PositionId = 0; + cashLeg.Obervation = null; + //现金腿倒挤 = 原 fix − 授信 fix(分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒) + cashLeg.InterestPrincipalFix = originalFix - position.InterestPrincipalFix; + cashLeg.FundTag = ConsFundTag.Cash; + newLegs.Add(cashLeg); + } + else + { + plan.Leg.FundTag = plan.CreditAmount > 0 ? ConsFundTag.Credit : ConsFundTag.Cash; + } + } + return newLegs; + } + /// /// 平仓/到期返还金额按被平仓腿的 FundTag 分流(§2.4): /// Credit 腿的返还本金与返息不产生资金流水(本金写授信出入表"释放",出金方向记正数),Cash/无标签(存量)走现金。 @@ -102,8 +145,8 @@ public class LegFundPlan public double CreditAmount { get; set; } /// 现金部分金额 public double CashAmount { get; set; } - /// 拆单时新拆出的授信腿(占用记录绑定到它) - public swap_position CreditLeg { get; set; } + /// 拆单时新拆出的现金腿(授信不足的差额;占用记录绑原腿、现金流水绑它) + public swap_position CashLeg { get; set; } public bool NeedSplit => CreditAmount > 0 && CashAmount > 0; } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index f9fb7204..ee537768 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -357,6 +357,19 @@ namespace YLErp.Modules.SwapModule directionRatio); } + protected virtual decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, + decimal qty, int shortRatio, int directionRatio, decimal? corporateActionQty) + { + if (!corporateActionQty.HasValue) + { + return CalcBondPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio); + } + + var service = new BondPaymentService(UserInfo); + var payments = service.GetBondPayments(underlyingCode, fromDate, toDate); + return service.CalcPayment(payments, qty, shortRatio, directionRatio, corporateActionQty); + } + // ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)---- /// 查找收盘所需的活跃互换交易(生产: DbContext.trade.Where;测试: 内存列表) @@ -439,7 +452,7 @@ namespace YLErp.Modules.SwapModule /// /// 获取公司行为公式使用的收盘价。 /// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日 - /// ExDividendDate;不能在 8 月 17 日 EOD 误取 8 月 17 日收盘价重算 8 月 14 日 + /// ExDividendDate;不能在 除权日 EOD 误取 除权日收盘价重算 登记日 /// 登记日形成的系数。测试实现可以返回快照中的回退值,生产实现从登记日行情读取。 /// protected virtual decimal GetFundCorporateActionClosePrice( @@ -516,18 +529,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 +552,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 +563,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,22 +608,27 @@ 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); + var corporateActionCashDividendBeforePositions = corporateActionBeforePositions + .Where(position => !string.IsNullOrWhiteSpace(position.UnderlyingCode) + && exDividendByCode.TryGetValue(position.UnderlyingCode, out var dividend) + && dividend.GiveCashAmount != 0m) + .ToList(); // 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线,应用生效日公司行为。 // 有上一份 EOD 时沿用 PrepareFundOpeningEodPositions,避免重复套系数。 @@ -613,18 +638,20 @@ namespace YLErp.Modules.SwapModule // 处理浮动腿归档 var curEodPosis = DealFloatPositions( - floatPositionsForCompose, - realPosiList, - openingEodPositions, - todyEodPositions, - settleDate, - td, - preSettleDate, - flowEvents); + floatPositionsForCompose, // 初始腿 + realPosiList, // 实时腿 + openingEodPositions, // 开盘基线 + todyEodPositions, // 当日终持仓 + settleDate, // 收盘日期 + td, // 交易 + preSettleDate, // 上一交易日 + flowEvents, // 流水事件 + corporateActionCashDividendBeforePositions); // 现金分红不在登记日直接累加;Copy/Update EOD 通过 CalcBondPayment // 读取 EffectiveDate 命中的 ex_dividend_info,并生成 TdPosiDividend。 // 这样登记日快照不提前变化,且公司行为分红与债券付息共用同一待实现余额。 + // 公司行为事件 RecordCorporateActionEvents( td, curEodPosis, @@ -632,8 +659,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 +696,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 +757,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 +768,7 @@ namespace YLErp.Modules.SwapModule continue; } - // 获取除权参考价 + // 获取除权参考价 - 登记日收盘价 var corporateActionClosePrice = GetFundCorporateActionClosePrice( dividendInfo, position.UnderlyingPrice); @@ -773,8 +802,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 +932,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 +945,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 +954,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 +1001,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 +1019,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 +1041,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 +1223,7 @@ namespace YLErp.Modules.SwapModule decimal dividendTaxRate, int grossPriceRound) { + // 计算除权系数 - adjustCashDividendPrice = false (现金分红模式) var factors = DividendService.CalculateCorporateActionFactors( dividendInfo, closePrice, @@ -1414,7 +1454,8 @@ namespace YLErp.Modules.SwapModule DateTime settleDate, trade td, DateTime preSettleDate, - List flowEvents) + List flowEvents, + IReadOnlyCollection corporateActionBeforePositions = null) { string settleDateStr = settleDate.ToString("yyyy-MM-dd"); string preSettleDateStr = preSettleDate.ToString("yyyy-MM-dd"); @@ -1436,18 +1477,20 @@ namespace YLErp.Modules.SwapModule var tdEodPosition = todyEodPositions.FirstOrDefault(x => x.PositionId == posi.id);//当前结算日日终持仓信息 var unwindEvents = flowEvents.Where(x => x.PositionId == posi.id).ToList();//当前日平仓信息 var realPosition = realPosiList.FirstOrDefault(s => s.PositionId == posi.id); + var corporateActionBeforeQuantity = corporateActionBeforePositions? + .FirstOrDefault(x => x.PositionId == posi.id)?.PosiQuantity; eod_swap_position eodPosi = new eod_swap_position(); if (eodPosition == null) { - eodPosi = SaveCurrentEodInitalPosi(posi, td, settleDate, preSettleDate, unwindEvents); + eodPosi = SaveCurrentEodInitalPosi(posi, td, settleDate, preSettleDate, unwindEvents, corporateActionBeforeQuantity); } else if (unwindEvents.Count() == 0) { - eodPosi = CopyEodPosition(eodPosition, tdEodPosition, td, settleDate, preSettleDate); + eodPosi = CopyEodPosition(eodPosition, tdEodPosition, td, settleDate, preSettleDate, corporateActionBeforeQuantity); } else { - eodPosi = UpdateEodPosition(posi, eodPosition, tdEodPosition, td, settleDate, preSettleDate, unwindEvents); + eodPosi = UpdateEodPosition(posi, eodPosition, tdEodPosition, td, settleDate, preSettleDate, unwindEvents, corporateActionBeforeQuantity); } Log.Info($"eodPosi为:{JsonHelper.Serialize(eodPosi, false)}"); list.Add(eodPosi); @@ -2634,7 +2677,7 @@ namespace YLErp.Modules.SwapModule /// 当日日终归档信息 /// 当日平仓/互换事件信息 /// 交易信息 - protected eod_swap_position CopyEodPosition(eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate) + protected eod_swap_position CopyEodPosition(eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate, decimal? corporateActionBeforeQuantity = null) { if (curretEod == null) { @@ -2656,7 +2699,7 @@ namespace YLErp.Modules.SwapModule decimal tax = um.ValueAddedTax ?? 0; if (valueDate > td.StartDate.Value && curretEod.PosiQuantity > 0) { - decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio); + decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio, corporateActionBeforeQuantity); curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax); } curretEod.PosiDividendSum = eod.PosiQuantity > 0 ? Math.Round(eod.PosiDividendSum + curretEod.TdPosiDividend, 2) : 0; @@ -2718,7 +2761,7 @@ namespace YLErp.Modules.SwapModule /// /// /// - protected eod_swap_position UpdateEodPosition(swap_position swapPosition, eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate, List unwindEvents) + protected eod_swap_position UpdateEodPosition(swap_position swapPosition, eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate, List unwindEvents, decimal? corporateActionBeforeQuantity = null) { if (curretEod == null) { @@ -2752,7 +2795,7 @@ namespace YLErp.Modules.SwapModule // 修改,互换事件会影响待实现的分红的,现在要算上 if (valueDate > td.StartDate.Value && (curretEod.PosiQuantity > 0)) { - decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio); + decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio, corporateActionBeforeQuantity); curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax); } curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl; @@ -2879,7 +2922,8 @@ namespace YLErp.Modules.SwapModule /// /// /// - protected eod_swap_position SaveCurrentEodInitalPosi(swap_position position, trade td, DateTime settleDate, DateTime preSettleDate, List unwindEvents) + protected eod_swap_position SaveCurrentEodInitalPosi(swap_position position, trade td, DateTime settleDate, + DateTime preSettleDate, List unwindEvents, decimal? corporateActionBeforeQuantity = null) { eod_swap_position curretEod = new eod_swap_position(); var um = GetUnderlyingData(position.UnderlyingCode); @@ -2932,7 +2976,7 @@ namespace YLErp.Modules.SwapModule if (!hasSwapEvent && settleDate > td.StartDate.Value && curretEod.PosiQuantity > 0) { decimal tax = um.ValueAddedTax ?? 0; - decimal payment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, settleDate, curretEod.PosiQuantity, shortRatio, directionRatio); + decimal payment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, settleDate, curretEod.PosiQuantity, shortRatio, directionRatio, corporateActionBeforeQuantity); payment = DividendCalc.AfterTax(payment, tax); //var consumedDividend = CalcConsumedDividend(curretEod, unwindEvents); 首日应该没有分红 curretEod.TdPosiDividend = payment; @@ -3370,6 +3414,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 +3577,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 +3742,7 @@ namespace YLErp.Modules.SwapModule else if (isEtf) { item.PeriodAmount = null; - item.DividendAmount = pendingDividend; + item.DividendAmount = -pendingDividend; // 每日估值报告是客户视角 取值与日终持仓风险相反 } else { @@ -3634,9 +3786,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 +3834,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/SwapModule/SwapFundTagService.cs b/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs index f6e188a2..f967f723 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs @@ -10,7 +10,8 @@ namespace YLErp.Modules.SwapModule /// 标签赋值与返还两个写入口集中在本服务,授信出入表(ClientCreditInoutService)的占用/释放由此统一触发。 /// 口径:授信值取 credit.Credit 合计(已审批+日期有效+含母公司,阶段一已折算),已使用授信取授信出入表; /// 授信不进资金——授信部分不产生资金流水。 - /// 资金标签是预付金腿上的单列(swap_position.fund_tag,逐腿):录入时存用户选择(授信/现金/未选默认现金),确认成交时系统在同列定稿。 + /// 资金标签是预付金腿上的单列(swap_position.fund_tag,逐腿):录入时存用户选择(授信/现金/未选回退交易级 + /// margin_fund_source,交易级也未设默认现金),确认成交时系统在同列定稿。 /// public class SwapFundTagService : YLBaseService { @@ -50,10 +51,81 @@ namespace YLErp.Modules.SwapModule return GetEffectiveCredit(clientId, valueDate) - ClientCreditInoutService.GetUsedCredit(clientId, DbContext); } + /// + /// 保存前授信拆单(§2.3 保存前拆单,2026-08-26 业务确认:保存检查授信→不足拦截→UI 确认→拆完再保存)。 + /// 按当前剩余授信对偏好授信的预付金腿(腿选授信,或腿默认回退交易级资金来源=授信)做物理拆分: + /// 原腿=可用额度 标授信、克隆现金差额腿(插回 td.swap_positions 随保存落库);额度为0/耗尽的授信腿整体定稿现金。 + /// 有授信不足且未带确认标记(allowSplit=false)时抛 TradeMarginCreditSplitException—— + /// controller 返回 AdditionalProcessing/MarginCreditSplit 由 UI 确认后带参重提。 + /// 本方法只拆腿不定簿记:授信占用/资金流水仍在确认成交 ApplyMarginFundTags。 + /// + public void PreSplitMarginLegsByCredit(trade td, bool allowSplit) + { + var marginModes = new[] { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; + var preferLegs = (td.swap_positions ?? new List()) + .Where(x => marginModes.Contains(x.InterestMode) + //不扣本金的腿不产生预付金簿记(与 SwapTradeConfirm 同口径),不参与拆分 + && (x.Obervation == null || x.Obervation.IsDeductPrincipal) + && ConsFundTag.PreferCredit(x.FundTag, td.MarginFundSource)) + .ToList(); + if (preferLegs.Count == 0) + { + return; + } + var valueDate = td.TradeDate ?? DateTime.Now; + var creditAvailable = GetAvailableCredit(td.ClientId, valueDate); + var allocateLegs = preferLegs + .Select(x => new LegAmount + { + Leg = x, + Amount = Convert.ToDouble(x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1)), + PreferCredit = true + }) + .Where(x => x.Amount > 0) + .OrderBy(x => x.Leg.HappenDate ?? DateTime.MaxValue) + .ThenBy(x => x.Leg.id) + .ToList(); + if (allocateLegs.Count == 0) + { + return; + } + var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, creditAvailable, ignoreMoneyCheck: false); + //授信不足的腿 = 偏好授信但授信没覆盖全额(含额度为0/被前腿耗尽的整体转现金) + var shortPlans = plans.Where(p => p.CreditAmount < p.Amount).ToList(); + if (shortPlans.Count == 0) + { + //额度充足:全额授信腿就法定稿授信(含"默认+交易级授信"回退解析),无拆分、无拦截 + FundTagCalc.ApplySaveTimeSplit(allocateLegs, plans); + return; + } + if (!allowSplit) + { + var detail = string.Join(";", shortPlans.Select(p => p.NeedSplit + ? $"金额 {p.Amount:#,##0.00} → 授信 {p.CreditAmount:#,##0.00} + 现金 {p.CashAmount:#,##0.00}" + : $"金额 {p.Amount:#,##0.00} → 全额现金(可用授信不足)")); + throw new TradeMarginCreditSplitException( + $"预付金授信额度不足,剩余可用授信 {Math.Max(creditAvailable, 0):#,##0.00}:{detail}。" + + "确认后将按上述拆分保存(授信部分确认成交时占用授信额度、不产生资金流水;现金部分产生应付预付金)。"); + } + var newLegs = FundTagCalc.ApplySaveTimeSplit(allocateLegs, plans); + //新现金腿插回原腿之后(列表相邻,随 SaveSwapPositions 落库并分配 PosiNumber) + var splitPlans = plans.Where(p => p.NeedSplit).ToList(); + for (var i = 0; i < newLegs.Count; i++) + { + newLegs[i].OptId = UserId; + newLegs[i].OptName = UserName; + newLegs[i].OptTime = DateTime.Now; + var original = splitPlans[i].Leg; + var index = td.swap_positions.IndexOf(original); + td.swap_positions.Insert(index < 0 ? td.swap_positions.Count : index + 1, newLegs[i]); + } + } + /// /// 簿记确认时对预付金腿定稿资金标签并产生资金记录(§2.3 四种情形,逐腿)。 /// fund_tag 单列:录入时存用户选择(Credit/Cash/NULL),本方法读取选择后在同列定稿—— - /// 特批全现金;选授信按剩余额度分配(跨界腿拆单为 授信+现金 两条),未选/现金直接现金。 + /// 特批全现金;按授信分配(腿选授信,或腿未选回退交易级 margin_fund_source=授信)的腿按剩余额度占用, + /// 跨界腿拆单为 授信+现金 两条(原腿保留授信部分、差额拆出新现金腿);现金直接现金。 /// 授信腿只写授信出入表占用(占用记正数,绑定腿 position_id,冗余 trade_id),不产生资金流水; /// 现金腿走 SaveSwapTradeClientCash 幂等 upsert 产生 应付预付金 记录。 /// marginLegs 需为已过滤(IsDeductPrincipal 等)的预付金腿(InterestMode=5/6)。 @@ -74,7 +146,9 @@ namespace YLErp.Modules.SwapModule { Leg = x, Amount = Convert.ToDouble(x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1)), - PreferCredit = x.FundTag == ConsFundTag.Credit + //优先级:腿上显式选择 > 交易级 margin_fund_source 回退(§2.3 情形1)> 默认现金, + //与 TradeCanBeConfirm 校验分流共用 ConsFundTag.PreferCredit 保证口径一致 + PreferCredit = ConsFundTag.PreferCredit(x.FundTag, td.MarginFundSource) }) .Where(x => x.Amount > 0) .OrderBy(x => x.Leg.HappenDate ?? DateTime.MaxValue) @@ -82,12 +156,12 @@ namespace YLErp.Modules.SwapModule .ToList(); var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, creditAvailable, ignoreMoneyCheck); - //先落库拆分的新腿(需要 id 才能绑定占用记录) + //先落库拆分的新现金腿(需要 id 才能绑定现金流水) foreach (var plan in plans.Where(p => p.NeedSplit)) { - plan.CreditLeg = SplitLeg(td, plan); + plan.CashLeg = SplitLeg(td, plan); } - //标签定稿(覆盖录入选择):拆单的两条腿在 SplitLeg 内已分别标 Cash/Credit; + //标签定稿(覆盖录入选择):拆单的两条腿在 SplitLeg 内已分别定稿(原腿=授信、新腿=现金); //整腿授信→Credit、整腿现金/负应付(客户净收取)腿→Cash foreach (var leg in marginLegs) { @@ -110,26 +184,27 @@ namespace YLErp.Modules.SwapModule var happenDate = leg.HappenDate ?? td.TradeDate ?? DateTime.Now; if (plan != null && plan.CreditAmount > 0) { - //整腿授信 或 拆单后的授信部分:不产生资金流水,只写占用(拆单绑新拆出的授信腿)。 + //授信部分(整腿授信 或 拆单后保留在原腿的可用额度部分):不产生资金流水,只写占用(占用绑原腿)。 //占用记正数(BUG-01 修正:已使用授信=Σ(amount) 占用上升;2026-08-20"与资金流水同号入金负"口径已废弃) - creditService.Occupy(td.ClientId, plan.CreditLeg?.id ?? leg.id, td.id, plan.CreditAmount, happenDate, + creditService.Occupy(td.ClientId, leg.id, td.id, plan.CreditAmount, happenDate, plan.NeedSplit ? "簿记拆单授信部分" : "簿记授信占用"); } - //资金记录沿用既有符号口径(客户付钱为负 = -应付额):授信部分不产生流水,现金部分按差额产生 + //资金记录沿用既有符号口径(客户付钱为负 = -应付额):授信部分不产生流水, + //现金部分按差额产生——拆单腿的流水绑新拆出的现金腿,整腿现金/负应付腿绑原腿 var recordAmount = plan != null ? -plan.CashAmount : Convert.ToDouble(leg.InterestPrincipalFix * (leg.InterestDirection == 1 ? -1 : 1)); if (recordAmount != 0) { - cashService.SaveSwapTradeClientCash(td, recordAmount, happenDate, leg.id, ClientCashInCashOut.系统操作_应付预付金); + cashService.SaveSwapTradeClientCash(td, recordAmount, happenDate, plan?.CashLeg?.id ?? leg.id, ClientCashInCashOut.系统操作_应付预付金); } } } /// - /// 拆单:把跨界腿拆为 授信+现金 两条。原腿保留现金部分并标 Cash(资金来源同步改现金,与最终标签一致), - /// 克隆一条授信腿(InterestPrincipalFix 按授信金额折算)标 Credit,返回新腿。 - /// 拆出的腿为普通初始腿,后续编辑/回退/平仓链路按既有腿处理。 + /// 拆单:把跨界腿拆为 授信+现金 两条。原腿保留授信部分(可用额度)并标 Credit(占用记录绑原腿), + /// 克隆一条现金腿(授信不足的差额,InterestPrincipalFix 按现金金额折算)标 Cash,返回新腿 + /// (现金流水绑新腿)。拆出的腿为普通初始腿,后续编辑/回退/平仓链路按既有腿处理。 /// private swap_position SplitLeg(trade td, LegFundPlan plan) { @@ -137,22 +212,22 @@ namespace YLErp.Modules.SwapModule //应付额 = fix × (dir==1 ? 1 : -1),反推 fix 用同一比例(±1 自反) var payableRatio = leg.InterestDirection == 1 ? 1 : -1; var originalFix = leg.InterestPrincipalFix; - leg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CashAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - leg.FundTag = ConsFundTag.Cash; + leg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CreditAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + leg.FundTag = ConsFundTag.Credit; - var creditLeg = leg.Clone(); - creditLeg.id = 0; - creditLeg.PositionId = 0; - //授信腿倒挤 = 原 fix − 现金 fix(BUG-20:两腿分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒) - creditLeg.InterestPrincipalFix = originalFix - leg.InterestPrincipalFix; - creditLeg.FundTag = ConsFundTag.Credit; - creditLeg.OptId = UserId; - creditLeg.OptName = UserName; - creditLeg.OptTime = DateTime.Now; - DbContext.swap_position.Add(creditLeg); + var cashLeg = leg.Clone(); + cashLeg.id = 0; + cashLeg.PositionId = 0; + //现金腿倒挤 = 原 fix − 授信 fix(两腿分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒) + cashLeg.InterestPrincipalFix = originalFix - leg.InterestPrincipalFix; + cashLeg.FundTag = ConsFundTag.Cash; + cashLeg.OptId = UserId; + cashLeg.OptName = UserName; + cashLeg.OptTime = DateTime.Now; + DbContext.swap_position.Add(cashLeg); DbContext.SaveChanges(); - creditLeg.PosiNumber = $"{td.TradeNumber}-{creditLeg.id}"; - return creditLeg; + cashLeg.PosiNumber = $"{td.TradeNumber}-{cashLeg.id}"; + return cashLeg; } /// diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 7ff6a46c..62beaf46 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -86,8 +86,19 @@ namespace YLErp.Modules.SwapModule /// /// /// - public trade SaveTrade(trade req, bool ignoreMoneyCheck = false) + public trade SaveTrade(trade req, bool ignoreMoneyCheck = false, bool allowMarginCreditSplit = false) { + //资金来源必填(现金/授信,默认现金):保存前归一,兜住 DMA 自动建仓等绕过录入页的链路 + if (req.TradeType == "收益互换" && string.IsNullOrWhiteSpace(req.MarginFundSource)) + { + req.MarginFundSource = ConsFundTag.Cash; + } + // §2.3 保存前授信拆单(2026-08-26 业务确认):保存检查授信→不足拦截(UI 确认)→拆完再保存。 + // 特批(ignoreMoneyCheck)语义为全现金不占授信,跳过拆单 + if (!ignoreMoneyCheck && req.TradeType == "收益互换") + { + new SwapFundTagService(this).PreSplitMarginLegsByCredit(req, allowMarginCreditSplit); + } var um = checkUnderlying(req); trade dbTrade = new trade(); //交易保存处理(PrepareInitialMargin 在此把 trade_Initial_Margin 折算进 req.InitialMargin, @@ -1709,7 +1720,7 @@ namespace YLErp.Modules.SwapModule } fundTagSvc.ApplyMarginFundTags(td, generateMarginLegs, cashSvc, false); //标签定稿(含可能的拆单)后重克隆实时持仓:TradeBack 的克隆先于定稿生成, - //重克隆使实时腿继承定稿标签、新拆出的授信腿也获得克隆(平仓返还分流查的是实时腿标签) + //重克隆使实时腿继承定稿标签、拆单新拆出的现金腿也获得克隆(平仓返还分流查的是实时腿标签) InitialPosition(td); // 合约维度盯市+无预付金腿:重建交易级(positionId=0)初始预付金记录(与 SwapTradeConfirm 一致,回退重补场景)。 // 有预付金腿的互换由上面按腿重建,不在此重复生成。 diff --git a/YLErpDAL/Modules/SwapModule/TradeMarginCreditSplitException.cs b/YLErpDAL/Modules/SwapModule/TradeMarginCreditSplitException.cs new file mode 100644 index 00000000..09671823 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/TradeMarginCreditSplitException.cs @@ -0,0 +1,17 @@ +using BaseOUDAL; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 保存交易时预付金授信不足的标记异常(§2.3 保存前拆单,2026-08-26 业务确认): + /// 保存检查授信→不足拦截→UI 确认(AdditionalProcessing/MarginCreditSplit)→带参重提后 + /// 按剩余授信物理拆分预付金腿(原腿=可用额度 标授信、新腿=差额 标现金)再保存。 + /// 属标准业务流程,不受"允许交易特批"开关控制(与 LackOfMoney 特批协议区分)。 + /// + public class TradeMarginCreditSplitException : ServiceException + { + public TradeMarginCreditSplitException(string message) : base(message) + { + } + } +} 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/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index efc6d2a6..b6503aa9 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -378,10 +378,11 @@ namespace YLErp.Modules.TradeModule.DealModule } var cashService = new ClientCashInCashOutService(this); cashService.SaveSwapTradeClientCash(td, td.TradePrice ?? 0, happenDate,0); - // R4 授信/现金标签:预付金腿定稿资金标签(选授信按剩余授信分配,不足跨界腿拆单), + // R4 授信/现金标签:预付金腿定稿资金标签(按授信分配的腿——腿选授信或未选回退交易级资金来源—— + // 按剩余授信分配,不足跨界腿拆单为 原腿授信+新现金腿), // 授信部分不产生资金流水(只写授信出入表占用),现金部分产生 应付预付金 记录;特批全现金。 // 必须在 InitialPosition/AddPositionEvent 之前执行:实时持仓克隆与初始事件要继承"定稿后"的标签, - // 拆单新拆出的授信腿也要被克隆、建事件(否则平仓返还分流会查到克隆腿上的旧标签/漏腿)。 + // 拆单新拆出的现金腿也要被克隆、建事件(否则平仓返还分流会查到克隆腿上的旧标签/漏腿)。 var generateMarginLegs = new List(); foreach (var marginPosition in td.swap_positions) { diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index 004baef5..261f1ef8 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -25,6 +25,11 @@ namespace YLErp.BLL /// public const string LackOfMoney = "LackOfMoney"; + /// + /// 保存交易时预付金授信不足:UI 确认后按 剩余授信+现金差额 拆分预付金腿再保存(§2.3 保存前拆单) + /// + public const string MarginCreditSplit = "MarginCreditSplit"; + public const string RiskWarningConfirm = "RiskWarningConfirm"; /// diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 7dbb0d69..ef6cfdeb 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -104,6 +104,8 @@ namespace YLErp.Web.Controllers TraderId = CurUser.UserId, TraderName = CurUser.UserName, MarginTemplateName = defaultMarginTemplateName, + //资金来源必填(现金/授信),新交易默认现金 + MarginFundSource = ConsFundTag.Cash, OpponentRole = "乙方", OriginalStockEqvNotional = 0, StructureType = "普通债券类收益互换", @@ -492,18 +494,29 @@ namespace YLErp.Web.Controllers req.id = DecryptInt(req.EncryptId); } //特批放行判定与确认/审批环节同口径(processtradelogController/ApprovalService): - //系统参数 允许交易特批(SpecialOperateForTrade) 开启 且 显式带 LackOfMoney 标记重提 - var ignoreMoneyCheck = valuedateBLL.SystemDate.SpecialOperateForTrade == 1 && additionalProcessing == tradeBLL.LackOfMoney; + //系统参数 允许交易特批(SpecialOperateForTrade) 开启 且 显式带 LackOfMoney 标记重提。 + //additionalProcessing 支持逗号分隔多标记(保存前授信拆单与资金特批可链式确认): + //MarginCreditSplit=预付金授信不足拆单确认(标准流程,不受特批开关控制) + var processings = (additionalProcessing ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries).ToHashSet(); + var ignoreMoneyCheck = valuedateBLL.SystemDate.SpecialOperateForTrade == 1 && processings.Contains(tradeBLL.LackOfMoney); + var allowMarginCreditSplit = processings.Contains(tradeBLL.MarginCreditSplit); try { bool edit = req.id != 0; - var r= swapTradeService.SaveTrade(req, ignoreMoneyCheck); + var r= swapTradeService.SaveTrade(req, ignoreMoneyCheck, allowMarginCreditSplit); Task.Run(() => { RealtimePnlCalc.RealtimeSwapPosition(new OptUserInfo(0, "互换实时持仓服务", OptUserFrom.Service)); }); return JsonSuccess("更新成功", r); } + catch (TradeMarginCreditSplitException e) + { + //保存前授信拆单(§2.3):预付金授信不足,UI 确认后带 additionalProcessing=MarginCreditSplit + //重提,按 剩余授信+现金差额 物理拆腿后保存 + LogFactory.GetLogger("交易保存").Info("保存授信不足待拆单确认:" + e.Message); + return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.MarginCreditSplit, message = e.Message }); + } catch (TradeLackOfMoneyException e) { //保存环节资金不足:开关开启时按确认/审批同一协议返回 AdditionalProcessing/LackOfMoney, @@ -965,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/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/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index 013c2e40..ce7eb5d6 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -102,6 +102,10 @@ page.Trade.StartDate = page.Trade.StartDate ? page.Trade.StartDate.substr(0, 10) : ""; page.Trade.TradeDate = page.Trade.TradeDate ? page.Trade.TradeDate.substr(0, 10) : ""; page.Trade.ExerciseDate = page.Trade.ExerciseDate ? page.Trade.ExerciseDate.substr(0, 10) : ""; + //资金来源必填(现金/授信),存量空值按默认现金归一,避免下拉空值匹配不到选项 + if (!page.Trade.MarginFundSource) { + page.Trade.MarginFundSource = "Cash"; + } @@ -289,12 +293,19 @@ 保证金模板 - 请选择 + 默认 {{ item.Text }} + + 资金来源 + + 现金 + 授信 + + @@ -332,8 +343,8 @@ - - 默认(现金) + + 默认 现金 授信 diff --git a/YLErpWeb/Views/SwapTrade2/TradeView.cshtml b/YLErpWeb/Views/SwapTrade2/TradeView.cshtml index fc33a462..b50e1912 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeView.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeView.cshtml @@ -253,7 +253,11 @@ 保证金模板 - @trade.MarginTemplateName + @(string.IsNullOrWhiteSpace(trade.MarginTemplateName) ? "默认" : trade.MarginTemplateName) + + + 资金来源 + @(trade.MarginFundSource == ConsFundTag.Credit ? "授信" : "现金") @@ -288,8 +292,8 @@ @((SwapDirectionEnum)item.InterestDirection) @((InterestModeEnum)item.InterestMode) - @*R4 资金标签(fund_tag 单列):录入时为用户选择,确认成交后为系统定稿(授信/现金)*@ - @(item.FundTag == ConsFundTag.Credit ? "授信" : "现金") + @*R4 资金标签(fund_tag 单列):录入时为用户选择(默认=取交易上的资金来源),确认成交后为系统定稿(授信/现金)*@ + @(item.FundTag == ConsFundTag.Credit ? "授信" : item.FundTag == ConsFundTag.Cash ? "现金" : "默认") @item.HappenDate.OtcFormatDate() 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/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index 70d0d69e..8efa0496 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -817,17 +817,35 @@ const vue = new Vue({ "补充协议编号": $("#SupProtocolCode").val() }; this.trade.trade_extend.ExtendJson = JSON.stringify(this.trade.trade_extend.ExtendObj); - //R4 保存环节资金校验:资金不足且系统开启"允许交易特批"时,服务端按确认/审批同一协议返回 - //AdditionalProcessing/LackOfMoney——弹"交易特批"确认,带 additionalProcessing=LackOfMoney 重提放行 + //保存环节两类拦截确认(服务端按确认/审批同一协议返回 AdditionalProcessing): + //1) MarginCreditSplit 预付金授信不足——确认后按 剩余授信+现金差额 拆腿再保存(标准流程); + //2) LackOfMoney 资金不足且系统开启"允许交易特批"——弹"交易特批",特批放行。 + //两类可链式发生(拆单后现金仍不足再走特批),确认标记累积在 query 上一并带上 var thisObj = this; + var confirmedProcessings = []; var doSave = function (additionalProcessing) { var url = "/swaptrade2/tradeEditJson"; - if (!main.isEmpty(additionalProcessing)) { - url += "?additionalProcessing=" + additionalProcessing; + if (!main.isEmpty(additionalProcessing) && confirmedProcessings.indexOf(additionalProcessing) < 0) { + confirmedProcessings.push(additionalProcessing); + } + if (confirmedProcessings.length) { + url += "?additionalProcessing=" + confirmedProcessings.join(","); } main.post(url, thisObj.trade).done(function (resp) { if (resp.obj && resp.obj.proccessType == "AdditionalProcessing") { - if (resp.obj.type == "LackOfMoney") { + if (resp.obj.type == "MarginCreditSplit") { + var splitContent = '' + resp.obj.message + ''; + main.open2("提示", + splitContent, + { + area: ["460px", "260px"], + btn: ['确认拆分', '取消'], + yes: function (index, layero) { + layer.close(index); + doSave("MarginCreditSplit"); + } + }); + } else if (resp.obj.type == "LackOfMoney") { var htmlContent = '' + resp.obj.message + ''; var lackMoneyConfirmLayer = main.open2("提示", htmlContent, 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);