diff --git a/Framework/YLErp.Core/DBModels/Approvalprocess.cs b/Framework/YLErp.Core/DBModels/Approvalprocess.cs index aea2c478..ee1339a2 100644 --- a/Framework/YLErp.Core/DBModels/Approvalprocess.cs +++ b/Framework/YLErp.Core/DBModels/Approvalprocess.cs @@ -54,5 +54,21 @@ namespace YLErp.DBModels /// [DisplayName("审批组条件")] public int? approvalCondition { get; set; } + + /// + /// 分支网关条件(JSON)。 + /// 用于多分支流程的进入条件判断,结构见 ConditionExpressionConfig。 + /// 为空时回退到旧的 approvalGroupId/approvalCondition 二元判断(双写兼容)。 + /// + [DisplayName("分支网关条件")] + public string conditionConfig { get; set; } + + /// + /// 节点触发条件(JSON)。 + /// 仅挂在审批节点(node=0)上:推进到该节点时求值,满足才进入该节点审批;不满足则跳过该节点。 + /// 为空视为无条件(默认进入审批)。例:名义本金 A默认审核、B>100W再审核、C>=500W再审核。 + /// + [DisplayName("节点触发条件")] + public string triggerCondition { get; set; } } } diff --git a/Framework/YLErp.Core/DBModels/ChinaBondValuation.cs b/Framework/YLErp.Core/DBModels/ChinaBondValuation.cs index 01520835..710ca38f 100644 --- a/Framework/YLErp.Core/DBModels/ChinaBondValuation.cs +++ b/Framework/YLErp.Core/DBModels/ChinaBondValuation.cs @@ -154,5 +154,18 @@ namespace YLErp.DBModels /// 聚源id /// public long? JSID { get; set; } + + /// + /// 创建人(登录用户ID)。聚源同步写入时为 NULL,手工编辑时由 SaveBondPrice 写入。 + /// 对应库表 china_bond_valuation.create_user(bigint)。 + /// + public long? create_user { get; set; } + + /// + /// 更新人(登录用户ID)。聚源同步写入时为 NULL,手工编辑时由 SaveBondPrice 写入。 + /// 对应库表 china_bond_valuation.update_user(bigint)。 + /// 约定:NULL = 聚源/中债自动同步(无人手工维护);有值 = 被人手工改过、可溯源。 + /// + public long? update_user { get; set; } } } diff --git a/Framework/YLErp.Core/DBModels/EodPriceBase.cs b/Framework/YLErp.Core/DBModels/EodPriceBase.cs index 06e8f92e..b92d1672 100644 --- a/Framework/YLErp.Core/DBModels/EodPriceBase.cs +++ b/Framework/YLErp.Core/DBModels/EodPriceBase.cs @@ -9,6 +9,7 @@ namespace YLErp.DBModels public class EodPriceBase : DBModelWithOperator { public static string 人工 = "人工"; + public static string 系统 = "系统"; /// /// 合约代码 diff --git a/Framework/YLErp.Core/DBModels/EodSwap.cs b/Framework/YLErp.Core/DBModels/EodSwap.cs index 4d6294a0..d268cd50 100644 --- a/Framework/YLErp.Core/DBModels/EodSwap.cs +++ b/Framework/YLErp.Core/DBModels/EodSwap.cs @@ -52,19 +52,21 @@ namespace YLErp.DBModels [DataChange] public string StructureType { get; set; } /// - /// 合约名义本金 + /// 合约名义本金。取交易原始等价名义本金,表示合约约定规模; + /// 不等于多头与空头日终腿的代数和。 /// [DisplayName("合约名义本金")] [DataChange] public decimal NotionalValue { get; set; } /// - /// 合约多头名义本金 + /// 合约多头名义本金。框架合约展示口径中多头始终为正数。 /// [DisplayName("合约多头名义本金")] [DataChange] public decimal NotionalValueLong { get; set; } /// - /// 合约空头名义本金 + /// 合约空头名义本金。框架合约展示口径中空头始终为负数, + /// 以便与多头直接相加得到净方向。 /// [DisplayName("合约空头名义本金")] [DataChange] @@ -174,5 +176,48 @@ namespace YLErp.DBModels public int ClientId { get; set; } public string SwapTradeTypeStr { get; set; } + + public string UnderlyingType { get; set; } + + /// + /// 合约期内已实现加待实现的付息/分红金额。 + /// 该字段用于框架合约风险展示,不按每日估值报告的“期间付息/期间分红”列拆分。 + /// + public decimal PeriodAmount { get; set; } + + /// + /// 合约浮动端待实现收益,取浮动腿盯市收益及未结交易费用, + /// 不包含期间付息/分红,避免与 重复。 + /// + public decimal FloatingUnrealizedPnl { get; set; } + + /// + /// 付息/分红支付方式:到期轧差时计入到期轧差估值,派息日支付时在期间支付口径展示。 + /// + public string InterestPaymentMethod { get; set; } + + /// + /// 合约估值(到期轧差口径)= 浮动端待实现收益 + 利率端待实现收益 + 期间付息/分红。 + /// 仅当支付方式为到期轧差时赋值。 + /// + public decimal? MaturityNettingValuation { get; set; } + + /// + /// 合约估值(派息日支付口径)= 浮动端待实现收益 + 利率端待实现收益。 + /// 派息/分红在支付日独立结算,因此不计入该估值。 + /// + public decimal? PeriodPaymentValuation { get; set; } + + /// + /// 我方收取的保证金利息累计额。保证金腿原始“支付”方向表示 + /// 对手方向我方支付保证金,利息现金流方向与保证金本金方向相反。 + /// + public decimal MarginInterestGain { get; set; } + + /// + /// 我方支付的保证金利息累计额。保证金腿原始“收取”方向表示 + /// 我方收取对手方保证金,应向对手方支付利息;支付金额以负数展示。 + /// + public decimal MarginInterestLoss { get; set; } } } diff --git a/Framework/YLErp.Core/DBModels/EodSwapPositionResponse.cs b/Framework/YLErp.Core/DBModels/EodSwapPositionResponse.cs index 18533989..7364d7d3 100644 --- a/Framework/YLErp.Core/DBModels/EodSwapPositionResponse.cs +++ b/Framework/YLErp.Core/DBModels/EodSwapPositionResponse.cs @@ -20,8 +20,7 @@ namespace YLErp.DBModels /// public string StructureType { get; set; } /// - /// - /// 交易对手方名称 + /// 交易对手方名称。每日估值报告页面当前不展示该列,但发送报告与其他调用方仍可使用。 /// public string ClientName { get; set; } /// @@ -33,44 +32,65 @@ namespace YLErp.DBModels /// public string TradeNumber { get; set; } /// - /// 期间付息 + /// 期间付息。仅现券标的赋值;ETF、指数及其他标的返回 null,由前端和 Excel 显示为空白。 /// - public decimal PeriodAmount { get; set; } + public decimal? PeriodAmount { get; set; } + /// + /// 到期结算日,直接取日终浮动腿的到期日期,不叠加结算规则或节假日顺延。 + /// + public DateTime? MaturitySettlementDate { get; set; } + /// + /// 期间分红。仅 ETF 标的赋值;现券、指数及其他标的返回 null,避免同一金额在不适用列展示。 + /// + public decimal? DividendAmount { get; set; } + /// + /// 期初标的成交收益率。仅现券标的直接取交易录入的 trade.InitYtm;其他标的返回 null。 + /// public decimal? InitYtm { get; set; } /// - /// 期限 + /// 实际期限,按估值日与起始日的自然日差加一计算,包含起始日。 /// public int DayCount { get; set; } /// - /// 期初预付金-不包含追加预付金 取轧差 + /// 期初预付金本金,仅汇总初始预付金交易腿;收取为正、支付为负。 /// public decimal OpenMarginAmount { get; set; } /// - /// 期初预付金利率-不包含追加预付金 取轧差 + /// 预付金利率,初始和追加预付金腿按本金规模加权平均 /// public decimal OpenMarginRate { get; set; } /// - /// 预付金利息 取轧差 + /// 预付金利息,初始和追加预付金腿按本金规模加权平均 /// public decimal MarginInterestAmount { get; set; } /// - /// 浮动利率(绝对)利率端待实现收益/(标的名义金额/期初标的交割价格全价) + /// 追加预付金本金,仅汇总估值日前已生效的追加预付金交易腿;收取为正、支付为负。 + /// + public decimal AdditionalMarginAmount { get; set; } + /// + /// 浮动利率(绝对)= 利率收益金额 / 标的名义金额。 + /// 该字段是展示型比例,不参与净额结算金额计算。 /// public decimal FloatRateAbs { get; set; } /// - /// 利差 + /// 利差,汇总非预付金利息腿的约定利率。 /// public decimal InterestRate { get; set; } /// - /// 利率收益金额 利率端待实现收益 + /// 利率收益金额,汇总非预付金利息腿的 InterestIncomeSum,并转换为我方视角。 /// public decimal InterestAmount { get; set; } /// - /// 净额结算金额 互换持仓价值+待返还的预付金本金 + /// 净额结算金额 = 利率收益金额 + 浮动收益金额 + 开平仓交易费用 + 预付金利息 + /// + 到期轧差方式下应计入的期间付息/分红;不包含两类预付金本金。 /// public decimal NetSettmentAmount { get; set; } /// + /// TRS估值 = 净额结算金额 + 期初预付金 + 追加预付金。 + /// + public decimal TrsValue { get; set; } + /// /// 交易费用 /// public decimal TradingFee { get; set; } diff --git a/Framework/YLErp.Core/DBModels/SwapEvent.cs b/Framework/YLErp.Core/DBModels/SwapEvent.cs index ce7f1cc2..eff65bac 100644 --- a/Framework/YLErp.Core/DBModels/SwapEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapEvent.cs @@ -130,6 +130,11 @@ namespace YLErp.DBModels /// public DateTime ValueDate { get; set; } /// + /// 收益结算日期上限 + /// + [NotMapped] + public DateTime? MaxIncomeValueDate { get; set; } + /// /// 平仓/互换日期 /// public DateTime? UnwindDate { 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 cfbf0af2..616e388b 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 4a762fb1..173ac0c5 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 13be1b62..7377ab8c 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 2b2b91cb..8eb58c2e 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 81aa0587..1aeed0a5 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs @@ -222,7 +222,7 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator var bond = JsonHelper.Deserialize(underlying.ExJson) ?? new UnderlyingBond(); dic["参考标的发行人"] = bond.UnderlyingIssuer ?? ""; dic["参考标的担保人"] = ""; - dic["票面利率"] = ((double)(bond.CouponRate ?? 0)).ToString("0.00"); + dic["票面利率"] = ((double)(bond.CouponRate ?? 0) * 100).ToString("0.00"); dic["参考标的到期日"] = underlying.MaturityDate?.ToString("【yyyy】年【M】月【d】日") ?? ""; } diff --git a/UnitTestProject/Helpers/ConditionEvaluatorTests.cs b/UnitTestProject/Helpers/ConditionEvaluatorTests.cs new file mode 100644 index 00000000..086cca6a --- /dev/null +++ b/UnitTestProject/Helpers/ConditionEvaluatorTests.cs @@ -0,0 +1,366 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp.Helpers; + +namespace YLErp.Helpers.Tests +{ + [TestClass] + public class ConditionEvaluatorTests + { + private static ConditionContext CreateContext(double notional = 0, string tradeType = "", int? userId = null, int? initGroupId = null, string processCategory = "") + { + return new ConditionContext + { + Trade = new DBModels.trade + { + StockEqvNotional = notional, + TradeType = tradeType + }, + UserId = userId, + InitGroupId = initGroupId, + ProcessCategory = processCategory + }; + } + + [TestMethod] + public void Evaluate_EmptyOrNullCondition_ReturnsFalse() + { + Assert.IsFalse(ConditionEvaluator.Evaluate(null, CreateContext())); + Assert.IsFalse(ConditionEvaluator.Evaluate("", CreateContext())); + Assert.IsFalse(ConditionEvaluator.Evaluate(" ", CreateContext())); + } + + [TestMethod] + public void Evaluate_InvalidJson_ReturnsFalse() + { + Assert.IsFalse(ConditionEvaluator.Evaluate("not a json", CreateContext())); + Assert.IsFalse(ConditionEvaluator.Evaluate("{\"tokens\": [", CreateContext())); + } + + [TestMethod] + public void Evaluate_TokenSingleCondition_Works() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken + { + type = "condition", + condition = new ConditionItem { field = "notional", op = ">", value = 100 } + } + } + }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100))); + } + + [TestMethod] + public void Evaluate_TokenAnd_Works() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } }, + new ConditionToken { type = "operator", connector = "and" }, + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<=", value = 500 } } + } + }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50))); + } + + [TestMethod] + public void Evaluate_TokenOr_Works() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 100 } }, + new ConditionToken { type = "operator", connector = "or" }, + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 500 } } + } + }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50))); + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 600))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 300))); + } + + [TestMethod] + public void Evaluate_TokenWithParentheses_Works() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "lparen" }, + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } }, + new ConditionToken { type = "operator", connector = "or" }, + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 50 } }, + new ConditionToken { type = "rparen" }, + new ConditionToken { type = "operator", connector = "and" }, + new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } } + } + }); + + // (200>100 or 200<50) and tradeType=="香草" => true + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "香草"))); + // (30>100 or 30<50) and tradeType=="雪球" => false + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 30, tradeType: "雪球"))); + // (80>100 or 80<50) and tradeType=="香草" => false + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 80, tradeType: "香草"))); + } + + [TestMethod] + public void Evaluate_MixedAndOr_PriorityAndOverOr() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } }, + new ConditionToken { type = "operator", connector = "or" }, + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 50 } }, + new ConditionToken { type = "operator", connector = "and" }, + new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } } + } + }); + + // A or (B and C) — and 优先级高于 or + // 200>100 -> true,无需计算右侧 + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: ""))); + // 30>100=false, 30<50=true, 香草==香草=true -> false or (true and true) = true + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 30, tradeType: "香草"))); + // 80>100=false, 80<50=false, 雪球==香草=false -> false or (false and false) = false + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 80, tradeType: "雪球"))); + } + + [TestMethod] + public void Evaluate_InitGroup_Works() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = new ConditionItem { field = "initGroup", op = "==", value = 5 } } + } + }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { InitGroupId = 5 })); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { InitGroupId = 3 })); + } + + [TestMethod] + public void Evaluate_ProcessCategory_Works() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = new ConditionItem { field = "processCategory", op = "==", value = "CloseProcess" } } + } + }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(processCategory: "CloseProcess"))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(processCategory: "TradeProcess"))); + } + + [TestMethod] + public void Evaluate_TradeTypeStringComparison_Works() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "!=", value = "雪球" } } + } + }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(tradeType: "香草"))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(tradeType: "雪球"))); + } + + [TestMethod] + public void Evaluate_IncompleteParentheses_DoesNotThrow() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "lparen" }, + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } } + } + }); + + // Parser tolerates missing closing parenthesis and returns the value inside + var result = ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)); + Assert.IsTrue(result); + } + + [TestMethod] + public void Evaluate_NotEquals_Works() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "!=", value = 100 } } + } + }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100))); + } + + [TestMethod] + public void Evaluate_GreaterOrEqual_Works() + { + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">=", value = 100 } } + } + }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100))); + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 99))); + } + + // ===== 字母 op 标识符(前端改用 gt/lt/gte/lte/eq/neq 规避 > < 编码问题)===== + + [TestMethod] + public void Evaluate_AlphaOp_GreaterThan_Works() + { + var condition = BuildTokens(new ConditionItem { field = "notional", op = "gt", value = 100 }); + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50))); + } + + [TestMethod] + public void Evaluate_AlphaOp_LessThanOrEqual_Works() + { + var condition = BuildTokens(new ConditionItem { field = "notional", op = "lte", value = 100 }); + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100))); + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 101))); + } + + [TestMethod] + public void Evaluate_AlphaOp_EqualAndNotEqual_Works() + { + var eqCond = BuildTokens(new ConditionItem { field = "tradeType", op = "eq", value = "香草" }); + Assert.IsTrue(ConditionEvaluator.Evaluate(eqCond, CreateContext(tradeType: "香草"))); + Assert.IsFalse(ConditionEvaluator.Evaluate(eqCond, CreateContext(tradeType: "雪球"))); + + var neqCond = BuildTokens(new ConditionItem { field = "tradeType", op = "neq", value = "雪球" }); + Assert.IsTrue(ConditionEvaluator.Evaluate(neqCond, CreateContext(tradeType: "香草"))); + Assert.IsFalse(ConditionEvaluator.Evaluate(neqCond, CreateContext(tradeType: "雪球"))); + } + + [TestMethod] + public void Evaluate_AlphaOp_CaseInsensitive_Works() + { + // 大写字母 op 也应识别(归一化为小写) + var condition = BuildTokens(new ConditionItem { field = "notional", op = "GTE", value = 100 }); + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 99))); + } + + [TestMethod] + public void Evaluate_MixedAlphaAndSymbolOp_Works() + { + // 字母 op 与符号 op 混用:notional gt 100 and tradeType == 香草 + var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "gt", value = 100 } }, + new ConditionToken { type = "operator", connector = "and" }, + new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } } + } + }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "香草"))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50, tradeType: "香草"))); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "雪球"))); + } + + // ===== 需求①触发条件字段:initialNotional(期初)/ currentNotional(本次)===== + + [TestMethod] + public void Evaluate_InitialNotional_UsesOriginalStockEqvNotional() + { + // initialNotional 取 trade.OriginalStockEqvNotional(与 StockEqvNotional 是不同字段) + var condition = BuildTokens(new ConditionItem { field = "initialNotional", op = "gte", value = 1000000 }); + var ctx = new ConditionContext + { + Trade = new DBModels.trade + { + StockEqvNotional = 500, // 当前份额,不应被 initialNotional 使用 + OriginalStockEqvNotional = 2000000 // 期初名义本金 + } + }; + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, ctx)); + + var ctxBelow = new ConditionContext + { + Trade = new DBModels.trade { OriginalStockEqvNotional = 500000 } + }; + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, ctxBelow)); + } + + [TestMethod] + public void Evaluate_CurrentNotional_UsesContextValue() + { + // currentNotional 取 ConditionContext.CurrentNotional(了结场景由 trade_cash 取绝对值传入) + var condition = BuildTokens(new ConditionItem { field = "currentNotional", op = "gt", value = 1000000 }); + + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 5000000 })); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 500000 })); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = null })); + } + + [TestMethod] + public void Evaluate_CurrentNotional_AbsoluteValueSemantics() + { + // 需求:平仓500万,本次交易名义本金按绝对值判断。 + // 调用方应传 Math.Abs 后的正值(BuildTriggerContext 已处理),这里验证传入正值即可。 + var condition = BuildTokens(new ConditionItem { field = "currentNotional", op = "gte", value = 5000000 }); + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 5000000 })); + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 8000000 })); + Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 4999999 })); + } + + [TestMethod] + public void Evaluate_InitialNotional_AbsoluteValueStored() + { + // OriginalStockEqvNotional 的 setter 已做 Math.Abs,负值存入会变正 + var condition = BuildTokens(new ConditionItem { field = "initialNotional", op = "gt", value = 100 }); + var ctx = new ConditionContext + { + Trade = new DBModels.trade { OriginalStockEqvNotional = -500 } // setter 归一化为 500 + }; + Assert.IsTrue(ConditionEvaluator.Evaluate(condition, ctx)); + } + + /// 辅助:单条件 tokens 序列化为 JSON。 + private static string BuildTokens(ConditionItem item) + { + return JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = item } + } + }); + } + } +} diff --git a/UnitTestProject/Helpers/TriggerNodeSkipTests.cs b/UnitTestProject/Helpers/TriggerNodeSkipTests.cs new file mode 100644 index 00000000..30536f89 --- /dev/null +++ b/UnitTestProject/Helpers/TriggerNodeSkipTests.cs @@ -0,0 +1,162 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.Helpers; +using System.Collections.Generic; +using System.Linq; + +namespace YLErp.Helpers.Tests +{ + /// + /// 需求①:交易提交进入审批流程时的「起点触发条件跳过」测试。 + /// 场景:交易一进来,若节点1、2配置的触发条件均不满足,应直接从节点3开始审核; + /// 若全部节点都不满足,则直接审批通过(无需任何审核)。 + /// + [TestClass] + public class TriggerNodeSkipTests + { + /// 构造一个审批节点:order + 可选的触发条件JSON。 + private static approvalprocess Node(int order, string triggerCondition = null) + { + return new approvalprocess + { + order = order, + node = 0, + triggerCondition = triggerCondition + }; + } + + /// 构造"期初名义本金 > 阈值"的触发条件JSON。 + private static string InitialNotionalGt(double threshold) + { + return JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new List + { + new ConditionToken + { + type = "condition", + condition = new ConditionItem { field = "initialNotional", op = "gt", value = threshold } + } + } + }); + } + + private static ConditionContext Ctx(double initialNotional) + { + return new ConditionContext + { + Trade = new trade { OriginalStockEqvNotional = initialNotional } + }; + } + + [TestMethod] + public void StartNode_NoTrigger_ReturnsStartDirectly() + { + // 节点1无触发条件 → 直接返回节点1 + var nodes = new List { Node(1), Node(2), Node(3) }; + var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(500)); + Assert.IsNotNull(result); + Assert.AreEqual(1, result.order); + } + + [TestMethod] + public void StartNode_TriggerSatisfied_ReturnsStart() + { + // 节点1触发条件">100",交易期初200满足 → 返回节点1 + var nodes = new List + { + Node(1, InitialNotionalGt(100)), + Node(2), + Node(3) + }; + var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200)); + Assert.IsNotNull(result); + Assert.AreEqual(1, result.order); + } + + [TestMethod] + public void StartNode_TriggerNotSatisfied_SkipsToNextSatisfied() + { + // 节点1触发">100",节点2触发">500",交易期初200 + // → 节点1不满足(200>100满足? 满足)... 重新设计:节点1触发">1000",200不满足;节点2无触发 → 返回节点2 + var nodes = new List + { + Node(1, InitialNotionalGt(1000)), // 200 不满足 >1000 + Node(2), // 无触发条件 + Node(3) + }; + var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200)); + Assert.IsNotNull(result); + Assert.AreEqual(2, result.order); + } + + [TestMethod] + public void AllStartNodesNotSatisfied_ReturnsNull_DirectlyApproved() + { + // 节点1、2、3都有触发条件,交易都不满足 → 返回null(表示直接审批通过) + var nodes = new List + { + Node(1, InitialNotionalGt(1000)), // 200 不满足 + Node(2, InitialNotionalGt(5000)), // 200 不满足 + Node(3, InitialNotionalGt(10000)) // 200 不满足 + }; + var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200)); + Assert.IsNull(result); + } + + [TestMethod] + public void SkipMultipleNodes_LandsOnThird() + { + // 节点1、2都不满足,节点3无触发 → 返回节点3 + // 模拟"A默认审核、B>100W再审核、C>=500W再审核"中,小额交易跳过B、C直达... 实际应停在满足的节点 + var nodes = new List + { + Node(1, InitialNotionalGt(1000000)), // 50W 不满足 + Node(2, InitialNotionalGt(2000000)), // 50W 不满足 + Node(3) // 无触发条件(兜底审核) + }; + var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(500000)); + Assert.IsNotNull(result); + Assert.AreEqual(3, result.order); + } + + [TestMethod] + public void SatisfiedAtSecondNode_StopsThere() + { + // 节点1触发">1000"不满足,节点2触发">100"满足 → 返回节点2(不会继续到节点3) + var nodes = new List + { + Node(1, InitialNotionalGt(1000)), // 200 不满足 + Node(2, InitialNotionalGt(100)), // 200 满足 + Node(3, InitialNotionalGt(50)) // 不会走到这 + }; + var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200)); + Assert.IsNotNull(result); + Assert.AreEqual(2, result.order); + } + + [TestMethod] + public void StartFromMiddleNode_Works() + { + // 起点不是节点1(如分支调整后从节点2开始),从节点2起判断 + var nodes = new List + { + Node(1, InitialNotionalGt(1000)), + Node(2, InitialNotionalGt(1000)), // 200 不满足 + Node(3) // 无触发 + }; + var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[1], Ctx(200)); + Assert.IsNotNull(result); + Assert.AreEqual(3, result.order); + } + + [TestMethod] + public void NullStart_ReturnsNull() + { + var nodes = new List { Node(1) }; + var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, null, Ctx(500)); + Assert.IsNull(result); + } + } +} diff --git a/UnitTestProject/Modules/DataProviderModule/EodPriceQueryServiceSettlementTest.cs b/UnitTestProject/Modules/DataProviderModule/EodPriceQueryServiceSettlementTest.cs new file mode 100644 index 00000000..ec615f82 --- /dev/null +++ b/UnitTestProject/Modules/DataProviderModule/EodPriceQueryServiceSettlementTest.cs @@ -0,0 +1,72 @@ +namespace YLErp.Modules.DataProviderModule +{ + /// + /// TryGetSettlementEodPrice(债券感知统一取价)的白盒测试。 + /// 覆盖期权/交易到期结算场景:债券标的应走中债估值表取到价(修复"结算价未找到"), + /// 非债券标的行为应与原 TryGetEodPrice 完全一致(不影响期货/股票)。 + /// 注:DB 驱动,需连测试库;无数据时 Assert.Inconclusive 跳过。 + /// + [TestClass] + public class EodPriceQueryServiceSettlementTest : YLUnitTestBase + { + [TestMethod] + public void BondUnderlying_RoutesToChinaBondValuation() + { + using var db = DbContextFactory.GetYLDbContext(); + var bond = (from b in db.china_bond_valuation + join u in db.underlying_manager on b.bond_id equals u.UnderlyingCode + where b.dirty_price_close > 0 + orderby b.valuation_date descending + select new { b.bond_id, vd = b.valuation_date }).FirstOrDefault(); + if (bond == null) Assert.Inconclusive("测试库无债券估值数据,跳过"); + + var ok = EodPriceQueryService.TryGetSettlementEodPrice(bond.vd, bond.bond_id, out var ep); + Assert.IsTrue(ok, "债券标的应走中债估值表取到价(修复点)"); + Assert.IsNotNull(ep); + // 债券 ClosePrice=全价(dirty_price_close),应与 GetBondPrice().ClosePrice 一致 + var bondPrice = EodPriceQueryService.GetBondPrice(bond.vd, bond.bond_id); + Assert.IsNotNull(bondPrice); + Assert.AreEqual(bondPrice.ClosePrice, ep.ClosePrice, 1e-6); + } + + [TestMethod] + public void NonBondUnderlying_RoutesToStockOrFuturePath() + { + using var db = DbContextFactory.GetYLDbContext(); + var stock = (from s in db.eod_stock_price + join u in db.underlying_manager on s.UnderlyingCode equals u.UnderlyingCode + where s.ClosePrice > 0 && u.UnderlyingInstrumentType == "Stock" + select new { s.UnderlyingCode, s.ValueDate }).FirstOrDefault(); + if (stock == null) Assert.Inconclusive("测试库无(股票类型)价格数据,跳过"); + + var ok = EodPriceQueryService.TryGetSettlementEodPrice(stock.ValueDate, stock.UnderlyingCode, out var ep); + var okOld = EodPriceQueryService.TryGetEodPrice(stock.ValueDate, stock.UnderlyingCode, out var epOld); + Assert.AreEqual(okOld, ok, "非债券标的行为应与原 TryGetEodPrice 一致"); + if (ok) + { + Assert.IsNotNull(ep); + Assert.AreEqual(epOld.ClosePrice, ep.ClosePrice, 1e-6, "非债券标的取到的收盘价应与原路径相同"); + } + } + + [TestMethod] + public void BondOptionExpiry_Regression_OldPathFailsNewPathSucceeds() + { + using var db = DbContextFactory.GetYLDbContext(); + var bond = (from b in db.china_bond_valuation + join u in db.underlying_manager on b.bond_id equals u.UnderlyingCode + where b.dirty_price_close > 0 + orderby b.valuation_date descending + select new { b.bond_id, vd = b.valuation_date }).FirstOrDefault(); + if (bond == null) Assert.Inconclusive("测试库无债券估值数据,跳过"); + + // 旧路径:TryGetEodPrice 只 join 期货/股票两表,债券取不到价 + var oldOk = EodPriceQueryService.TryGetEodPrice(bond.vd, bond.bond_id, out _); + // 新路径:债券感知统一取价,应能取到 + var newOk = EodPriceQueryService.TryGetSettlementEodPrice(bond.vd, bond.bond_id, out var ep); + Assert.IsFalse(oldOk, "回归基线:旧路径对债券标的应取不到价(这正是期权到期报'结算价未找到'的根因)"); + Assert.IsTrue(newOk && ep != null && ep.ClosePrice > 0, + "修复验证:统一取价应能为债券标的取到结算价,期权到期不再报'结算价未找到'"); + } + } +} diff --git a/UnitTestProject/Modules/EodModule/EodPriceDtoTest.cs b/UnitTestProject/Modules/EodModule/EodPriceDtoTest.cs new file mode 100644 index 00000000..459ba7af --- /dev/null +++ b/UnitTestProject/Modules/EodModule/EodPriceDtoTest.cs @@ -0,0 +1,221 @@ +using YLErp; + +namespace YLErp.Modules.EodModule +{ + /// + /// 日终价格管理 —— 纯单元测试(不连库、秒级)。 + /// 锁定两处改动的意图: + /// 问题4:列表"标的种类"按真实类型显示,且路由键 UnderlyingInstrumentType 不变; + /// 问题3:债券(china_bond_valuation)数据来源按是否手工改过区分"人工"/"系统"。 + /// + [TestClass] + public class EodPriceDtoTest + { + #region 问题4:显示走真实类型,不再一律"商品期货";路由键保持不变 + + [TestMethod] + [Description("有真实类型时,标的种类按真实类型显示,而非硬编码'商品期货'")] + public void 有真实类型_按真实类型显示_而非商品期货() + { + // 模拟从 eod_commodity_future_price 出来、但真实是现券的一行 + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, // 路由键(旧硬编码值) + RealInstrumentType = ConsGlobal.InstrumentType.CreditBonds // 真实类型=信用债 + }; + + Assert.AreEqual("信用债", dto.UnderlyingInstrumentTypeCn, "显示应走真实类型"); + Assert.AreNotEqual("商品期货", dto.UnderlyingInstrumentTypeCn, "不应再一律显示商品期货"); + } + + [TestMethod] + [Description("贵金属现货从商品期货表出来,也应显示真实种类")] + public void 贵金属现货_显示黄金现货_而非商品期货() + { + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, + RealInstrumentType = ConsGlobal.InstrumentType.GoldSpot + }; + + Assert.AreEqual("黄金现货", dto.UnderlyingInstrumentTypeCn); + Assert.AreNotEqual("商品期货", dto.UnderlyingInstrumentTypeCn); + } + + [TestMethod] + [Description("真正的商品期货,真实类型=CommodityFutures,仍显示商品期货")] + public void 真商品期货_仍显示商品期货() + { + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, + RealInstrumentType = ConsGlobal.InstrumentType.CommodityFutures + }; + + Assert.AreEqual("商品期货", dto.UnderlyingInstrumentTypeCn); + } + + [TestMethod] + [Description("RealInstrumentType 为空时,回退到路由键,保证 null 安全不崩")] + public void 真实类型为空_回退到路由键() + { + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, + RealInstrumentType = null + }; + + Assert.AreEqual("商品期货", dto.UnderlyingInstrumentTypeCn, "?? 回退应等于路由键的中文"); + } + + [TestMethod] + [Description("路由键 UnderlyingInstrumentType 不受显示改动影响(保证'查看'不串表)")] + public void 路由键不变_保证查看不串表() + { + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, + RealInstrumentType = ConsGlobal.InstrumentType.TBonds + }; + + // 显示变了,但路由键仍是 CommodityFutures → EodPriceView 仍会去 eod_commodity_future_price 取数 + Assert.AreEqual("利率债", dto.UnderlyingInstrumentTypeCn); + Assert.AreEqual(ConsGlobal.InstrumentType.CommodityFutures, dto.UnderlyingInstrumentType); + } + + #endregion + + #region 问题3:债券数据来源按是否手工改过区分"人工"/"系统" + + [TestMethod] + [Description("债券来源:被手工改过(update_user 有值)→人工,中债自动同步(update_user 为 NULL)→系统")] + public void 债券来源_手工改过为人工_否则为系统() + { + Assert.AreEqual(EodPriceBase.人工, EodPriceService.ResolveBondDisplaySource(1024L)); + Assert.AreEqual(EodPriceBase.系统, EodPriceService.ResolveBondDisplaySource(null)); + } + + [TestMethod] + [Description("来源常量应为约定的中文'人工'/'系统'")] + public void 来源常量取值正确() + { + Assert.AreEqual("系统", EodPriceBase.系统); + Assert.AreEqual("人工", EodPriceBase.人工); + } + + #endregion + + #region 债券手工编辑:操作人写入已有列(不新增字段) + + [TestMethod] + [Description("新增债券估值:create_user 与 update_user 都写入当前登录用户ID")] + public void 新增债券估值_写入创建人与更新人() + { + var m = new ChinaBondValuation(); + EodPriceService.StampBondOperator(m, 1024, isNew: true); + + Assert.AreEqual(1024L, m.create_user, "新增时应写创建人"); + Assert.AreEqual(1024L, m.update_user, "新增时应写更新人"); + } + + [TestMethod] + [Description("更新已有债券估值:仅更新 update_user,保留原 create_user(不覆盖创建人)")] + public void 更新债券估值_仅写更新人_保留创建人() + { + var m = new ChinaBondValuation { create_user = 7 }; + EodPriceService.StampBondOperator(m, 1024, isNew: false); + + Assert.AreEqual(7L, m.create_user, "更新时不应覆盖原创建人"); + Assert.AreEqual(1024L, m.update_user, "更新人应为本次操作者"); + } + + [TestMethod] + [Description("聚源/中债自动同步(外部ETL)不调用 StampBondOperator,故 create_user/update_user 保持 NULL = 自动同步")] + public void 自动同步路径_操作人列为NULL() + { + // 注意:SettlementPriceImportService 是"手工上传"入口(会戳操作人),不是自动同步。 + // 真正的聚源/中债自动同步在外部 ETL(本仓库无代码),其写入不经 StampBondOperator。 + var m = new ChinaBondValuation(); // 模拟自动同步:仅写价格字段,不戳操作人 + Assert.IsNull(m.create_user); + Assert.IsNull(m.update_user); + } + + [TestMethod] + [Description("手工上传(SettlementPriceImportService):新增行(id==0)应写 create_user+update_user,使来源列显示上传人")] + public void 手工上传新增_写入创建人与更新人() + { + // 模拟上传债券新增分支:eodPrice.id 默认 0 → isNew=true + var m = new ChinaBondValuation(); + EodPriceService.StampBondOperator(m, 2048, isNew: m.id == 0); + + Assert.AreEqual(2048L, m.create_user, "上传新增应写创建人"); + Assert.AreEqual(2048L, m.update_user, "上传新增应写更新人"); + } + + [TestMethod] + [Description("手工上传(SettlementPriceImportService):命中已有行(id!=0)只写 update_user,保留原 create_user")] + public void 手工上传更新_仅写更新人_保留创建人() + { + // 模拟上传命中已有债券行:id!=0 → isNew=false + var m = new ChinaBondValuation { id = 55, create_user = 9 }; + EodPriceService.StampBondOperator(m, 2048, isNew: m.id == 0); + + Assert.AreEqual(9L, m.create_user, "上传更新不应覆盖原创建人"); + Assert.AreEqual(2048L, m.update_user, "上传更新应写本次上传人"); + } + + #endregion + + #region 问题3补充:债券来源人工/系统(判定逻辑已并入上方"问题3"区域) + + #endregion + + #region 日期窗口解析("页面始终5条"根因锁定,纯单测不连库) + + [TestMethod] + [Description("前端未传日期(年份<=2000) → 回退到 [今天-1年, 今天+1年)")] + public void 未传日期_回退最近一年到明年() + { + var (start, end) = EodPriceService.ResolveValueDateWindow(DateTime.MinValue, DateTime.MinValue); + Assert.AreEqual(DateTime.Today.AddYears(-1).Date, start.Date, "起始应回退到今天-1年"); + Assert.AreEqual(DateTime.Today.AddYears(1).Date, end.Date, "结束应回退到今天+1年"); + Assert.IsTrue(end > start, "窗口应正向"); + } + + [TestMethod] + [Description("列表页默认起止都填今天 → 窗口=[今天, 今天+1天),仅返回当天记录(即'5条'现象成因)")] + public void 起止都填今天_窗口仅今天() + { + var today = DateTime.Today; + var (start, end) = EodPriceService.ResolveValueDateWindow(today, today); + Assert.AreEqual(today.Date, start.Date, "起始应为今天"); + Assert.AreEqual(today.AddDays(1).Date, end.Date, "结束应为今天+1天(半开区间含今天)"); + Assert.IsTrue(today >= start && today < end, "今天的记录应落入窗口"); + Assert.IsFalse(today.AddDays(-1) >= start && today.AddDays(-1) < end, "昨天的记录不应落入仅今天窗口"); + Assert.IsFalse(today.AddDays(1) >= start && today.AddDays(1) < end, "明天的记录不应落入仅今天窗口"); + } + + [TestMethod] + [Description("显式传区间(如近30天) → 原样生效,不被回退覆盖")] + public void 显式区间_原样生效() + { + var start0 = DateTime.Today.AddDays(-30); + var end0 = DateTime.Today; + var (start, end) = EodPriceService.ResolveValueDateWindow(start0, end0); + Assert.AreEqual(start0.Date, start.Date, "起始应等于传入"); + Assert.AreEqual(end0.AddDays(1).Date, end.Date, "结束应等于传入+1天"); + } + + [TestMethod] + [Description("结束日=今天 → 半开区间上界=今天+1天,今天当天记录可命中")] + public void 结束日今天_上界为明天_当天可命中() + { + var (start, end) = EodPriceService.ResolveValueDateWindow(DateTime.Today.AddDays(-365), DateTime.Today); + var today = DateTime.Today; + Assert.IsTrue(today >= start && today < end, "今天记录应命中"); + Assert.IsFalse(today.AddDays(1) >= start && today.AddDays(1) < end, "明天记录不应命中"); + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/EodModule/EodPriceGoldenReplayTest.cs b/UnitTestProject/Modules/EodModule/EodPriceGoldenReplayTest.cs new file mode 100644 index 00000000..f6f36a6f --- /dev/null +++ b/UnitTestProject/Modules/EodModule/EodPriceGoldenReplayTest.cs @@ -0,0 +1,264 @@ +using Newtonsoft.Json; +using YLErp; + +namespace YLErp.Modules.EodModule +{ + #region Golden 数据模型 + + /// + /// 日终价格"标的种类 + 数据来源"golden 场景模型。 + /// 每个 JSON 文件存:一组原始输入行 + 每行的期望输出(种类中文/来源/路由键)。 + /// 结构与 SwapModule 的 GoldenScenarioModel 对齐(Scenario/Description/Source + Rows)。 + /// + public class EodPriceGoldenModel + { + public string Scenario { get; set; } + public string Description { get; set; } + /// synthetic(合成 Mock) / recorded(真实库录制) + public string Source { get; set; } = "synthetic"; + public DateTime? RecordedAt { get; set; } + public List Rows { get; set; } = new(); + } + + public class EodPriceGoldenRow + { + public string UnderlyingCode { get; set; } + + /// 存储表路由键 = DTO.UnderlyingInstrumentType(EodPriceView 靠它选表) + public string RouteKey { get; set; } + + /// 真实标的种类 = underlying_manager.UnderlyingInstrumentType + public string RealInstrumentType { get; set; } + + public bool IsBond { get; set; } + + /// 期望的"标的种类"列显示值 + public string ExpectedTypeCn { get; set; } + + /// 期望的"数据来源"(仅债券行断言) + public string ExpectedDataSource { get; set; } + } + + #endregion + + /// + /// 日终价格 Golden 回放测试 + /// ============================================================================ + /// 仿 SwapModule/DealInterestsGoldenReplayTest: + /// - Record_* :连真实库拉数据生成 golden JSON(标 [Ignore],手动跑) + /// - Replay_* :读 Mock/录制 JSON 重放并逐行断言(进 CI,不碰库) + /// + /// 守护点(回放时任何一行不符即失败): + /// 1. 标的种类按真实类型显示(现券→信用债、贵金属→黄金现货…),不再一律"商品期货"; + /// 2. 路由键 UnderlyingInstrumentType 保持不变(保证"查看"不串表); + /// 3. 债券数据来源固定为中债估值(聚源仅转发,无人手工维护,不随 JSID 变化)。 + /// ============================================================================ + /// + [TestClass] + public class EodPriceGoldenReplayTest + { + private static readonly string GoldenDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "EodPriceGolden"); + + #region 回放:读 golden 重放 + 逐行断言(进 CI) + + [TestMethod] + public void Replay_AllGoldenFiles() + { + if (!Directory.Exists(GoldenDir)) + { + Assert.Inconclusive($"golden 目录不存在: {GoldenDir}"); + return; + } + + var files = Directory.GetFiles(GoldenDir, "*.json").OrderBy(f => f).ToArray(); + Assert.IsTrue(files.Length > 0, "应至少有 1 个 golden 文件"); + + int rowsChecked = 0; + foreach (var file in files) + { + var golden = JsonConvert.DeserializeObject(File.ReadAllText(file)); + Console.WriteLine($"\n回放: {Path.GetFileName(file)} - {golden.Scenario} [{golden.Source}]"); + + foreach (var row in golden.Rows) + { + // 用原始输入重建 DTO(等价于 SearchUnderlyingList 的投影结果) + var dto = new EodUnderlyingPriceDto + { + UnderlyingCode = row.UnderlyingCode, + UnderlyingInstrumentType = row.RouteKey, // 路由键 + RealInstrumentType = row.RealInstrumentType, // 真实类型 + IsBond = row.IsBond + }; + // 债券来源:自动同步(中债)→系统(等价 SearchUnderlyingList 后处理赋值;synthetic 无 UpdateUser 故为系统) + if (dto.IsBond) + { + dto.DataSource = EodPriceBase.系统; + } + + // 守护点1:显示按真实类型 + Assert.AreEqual(row.ExpectedTypeCn, dto.UnderlyingInstrumentTypeCn, + $"[{row.UnderlyingCode}] 标的种类显示不符"); + + // 守护点2:路由键不变 + Assert.AreEqual(row.RouteKey, dto.UnderlyingInstrumentType, + $"[{row.UnderlyingCode}] 路由键被改动,会导致查看串表"); + + // 守护点3:债券来源 + if (row.IsBond) + { + Assert.AreEqual(row.ExpectedDataSource, dto.DataSource, + $"[{row.UnderlyingCode}] 债券数据来源判定不符"); + } + + rowsChecked++; + Console.WriteLine($" ✅ {row.UnderlyingCode}: {dto.UnderlyingInstrumentTypeCn}" + + (row.IsBond ? $" / {dto.DataSource}" : "")); + } + } + + Console.WriteLine($"\n回放完成,共校验 {rowsChecked} 行"); + Assert.IsTrue(rowsChecked > 0, "至少应校验 1 行"); + } + + #endregion + + #region 录制:连真实库拉数据生成 golden(标 [Ignore],手动跑) + + /// + /// 从真实库拉一批 underlying_manager + china_bond_valuation, + /// 按当前生产逻辑生成 recorded golden JSON。 + /// 手动取消 [Ignore] 运行;生成后复制到 Resources/GoldenFiles/EodPriceGolden/ 持久化。 + /// + [TestMethod] + [Ignore] + [TestCategory("GoldenRecord")] + public void Record_FromRealDb() + { + Directory.CreateDirectory(GoldenDir); + + var golden = new EodPriceGoldenModel + { + Scenario = "标的种类与来源(真实库录制)", + Description = "从 underlying_manager/china_bond_valuation 采样,快照当前生产映射", + Source = "recorded", + RecordedAt = DateTime.Now + }; + + using (var db = DbContextFactory.GetYLDbContext()) + { + // 采样若干上线标的(含真实类型) + var uns = db.underlying_manager + .Where(x => x.LaunchState == "1") + .Select(x => new { x.UnderlyingCode, x.UnderlyingInstrumentType }) + .Take(30).ToList(); + + // 债券估值采样(来源:自动同步→系统,手工改过→人工) + var bonds = db.china_bond_valuation + .Select(b => new { b.bond_id }) + .Take(200).ToList(); + var bondCodes = new HashSet(bonds.Select(b => b.bond_id)); + + foreach (var un in uns) + { + bool isBond = bondCodes.Contains(un.UnderlyingCode); + + // 路由键:债券走真实类型,其余按来源表默认(这里录制以真实类型近似, + // 因为 recorded 主要用于快照真实分布;CI 用 synthetic 覆盖精确路由)。 + string routeKey = isBond + ? un.UnderlyingInstrumentType + : ConsGlobal.InstrumentType.CommodityFutures; + + golden.Rows.Add(new EodPriceGoldenRow + { + UnderlyingCode = un.UnderlyingCode, + RouteKey = routeKey, + RealInstrumentType = un.UnderlyingInstrumentType, + IsBond = isBond, + ExpectedTypeCn = ConsGlobal.InstrumentType.GetDesc(un.UnderlyingInstrumentType), + ExpectedDataSource = isBond ? EodPriceBase.系统 : null + }); + } + } + + var path = Path.Combine(GoldenDir, "golden_标的种类与来源_recorded.json"); + File.WriteAllText(path, JsonConvert.SerializeObject(golden, Formatting.Indented)); + Console.WriteLine($"✅ 录制 {golden.Rows.Count} 行 -> {path}"); + } + + #endregion + + #region 回归:新增日终价格可见性(连真实库,标 [Ignore] 手动跑) + + /// + /// 回归"新增日终价格后是否查得出",直接跑生产查询 SearchUnderlyingList。 + /// 守护点(与之前"新增后查不出"的修复一一对应): + /// (a) 今天 + 已上市(LaunchState=1) 标的 → 查得出; + /// (b) 估值日期=0001(未填) → 落在列表默认"仅今天"窗口外 → 查不出; + /// (c) 标的未上市(LaunchState!=1) → 被 inner join(underlying_manager.LaunchState=="1") 过滤 → 查不出。 + /// 复用库中已有标的(不新建 underlying_manager,避免触碰该表约束),只插入/清理临时债券估值行。 + /// + [TestMethod] + [Ignore] + [TestCategory("EodVisibility")] + [Description("新增日终价格可见性:(a)今天+已上市可查 (b)日期0001查不出 (c)未上市查不出")] + public void Record_NewRecordVisibility() + { + using (var db = DbContextFactory.GetYLDbContext()) + { + var svc = new EodPriceService(OptUserInfo.SystemUser); + var today = DateTime.Today; + var req = new EodCommodityFuturePriceReq { ValueDateStart = today, ValueDateEnd = today }; + + // 取一个已上市的债券类标的(正向用例);退而求其次取任意已上市标的 + var listedBond = db.underlying_manager + .FirstOrDefault(x => x.LaunchState == "1" && x.UnderlyingInstrumentType == ConsGlobal.InstrumentType.CreditBonds) + ?? db.underlying_manager.FirstOrDefault(x => x.LaunchState == "1"); + Assert.IsNotNull(listedBond, "需存在一个 LaunchState=1 的标的用于正向回归"); + + // 取一个未上市的标的(负向用例) + var unlisted = db.underlying_manager.FirstOrDefault(x => x.LaunchState != "1"); + Assert.IsNotNull(unlisted, "需存在一个 LaunchState!=1 的标的用于负向回归"); + + var insertedIds = new List(); + try + { + // (a) 今天 + 已上市 → 查得出 + var a = new ChinaBondValuation { bond_id = listedBond.UnderlyingCode, valuation_date = today, dirty_price_close = 100, net_price = 100, yield = 3 }; + db.china_bond_valuation.Add(a); + db.SaveChanges(); + insertedIds.Add(a.id); + var rA = svc.SearchUnderlyingList(req); + Assert.IsTrue(rA.rows.Any(x => x.id == a.id), "(a) 今天+已上市债券应查得出"); + + // (b) 日期=0001(未填) → 落在仅今天窗口外,查不出 + var b = new ChinaBondValuation { bond_id = listedBond.UnderlyingCode, valuation_date = DateTime.MinValue, dirty_price_close = 100, net_price = 100, yield = 3 }; + db.china_bond_valuation.Add(b); + db.SaveChanges(); + insertedIds.Add(b.id); + var rB = svc.SearchUnderlyingList(req); + Assert.IsFalse(rB.rows.Any(x => x.id == b.id), "(b) 日期0001 应查不出"); + + // (c) 未上市标的 → 被 inner join 过滤,查不出 + var c = new ChinaBondValuation { bond_id = unlisted.UnderlyingCode, valuation_date = today, dirty_price_close = 100, net_price = 100, yield = 3 }; + db.china_bond_valuation.Add(c); + db.SaveChanges(); + insertedIds.Add(c.id); + var rC = svc.SearchUnderlyingList(req); + Assert.IsFalse(rC.rows.Any(x => x.id == c.id), "(c) 未上市标的应查不出"); + } + finally + { + foreach (var id in insertedIds) + { + var e = db.china_bond_valuation.Find(id); + if (e != null) db.china_bond_valuation.Remove(e); + } + db.SaveChanges(); + } + } + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/EodModule/EodPriceUnderlyingIdGuardTest.cs b/UnitTestProject/Modules/EodModule/EodPriceUnderlyingIdGuardTest.cs new file mode 100644 index 00000000..4427770b --- /dev/null +++ b/UnitTestProject/Modules/EodModule/EodPriceUnderlyingIdGuardTest.cs @@ -0,0 +1,88 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; + +namespace YLErp.Modules.EodModule +{ + /// + /// FR007 错行根因的校正决策单测(GLMS-20260701)。 + /// 对应最近提交的 bugfix:eod_commodity_future_price 入库前以 UnderlyingCode(=FutureContractId) 为准 + /// 重派生 UnderlyingId,防止 UnderlyingId 与 FutureContractId 失同步导致"网页能查到、EOD 结算查不到"。 + /// 这里只测纯函数 ResolveUnderlyingIdForCode,不依赖数据库。 + /// + /// 生产事故还原:FR007 价格行的 FutureContractId='FR007',但 UnderlyingId 被错写成 + /// 511160.SH 的 2173889 / 159111.SZ 的 2173890,正确应为 FR007 的 2170838。 + /// 网页端按 UnderlyingId(int) JOIN underlying_manager 把 FR007 行误挂到 511160.SH; + /// 而 EOD 结算按 UnderlyingCode(string) JOIN 查不到,报"结算价格缺失"。 + /// + [TestClass] + public class EodPriceUnderlyingIdGuardTest + { + private const int Fr007CorrectId = 2170838; + private const int Id511160 = 2173889; // 511160.SH 的 id(被错写) + private const int Id159111 = 2173890; // 159111.SZ 的 id(被错写) + + [TestMethod] + public void 空UnderlyingCode_维持原值() + { + Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode("", 123, null)); + Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode(null, 123, 999)); + Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode(" ", 123, 999)); + } + + [TestMethod] + public void 标的不存在_维持原值() + { + // resolvedId=null 表示 underlying_manager 无此代码,无法校正 + Assert.AreEqual(Id511160, + EodPriceService.ResolveUnderlyingIdForCode("FR007", Id511160, null)); + } + + [TestMethod] + public void 已一致_维持原值() + { + Assert.AreEqual(Fr007CorrectId, + EodPriceService.ResolveUnderlyingIdForCode("FR007", Fr007CorrectId, Fr007CorrectId)); + } + + [TestMethod] + public void 不一致_校正为正确id_FR007生产错行_511160() + { + // 生产事故:FR007 行 UnderlyingId=2173889(511160.SH) → 应校正为 2170838 + Assert.AreEqual(Fr007CorrectId, + EodPriceService.ResolveUnderlyingIdForCode("FR007", Id511160, Fr007CorrectId)); + } + + [TestMethod] + public void 不一致_校正为正确id_FR007生产错行_159111() + { + // 生产事故:另两条错行 UnderlyingId=2173890(159111.SZ) → 应校正为 2170838 + Assert.AreEqual(Fr007CorrectId, + EodPriceService.ResolveUnderlyingIdForCode("FR007", Id159111, Fr007CorrectId)); + } + + [TestMethod] + public void 行对象_端到端校正_FR007() + { + var row = new eod_commodity_future_price + { + UnderlyingCode = "FR007", + UnderlyingId = Id511160 + }; + // 模拟 db.underlying_manager 解析到的正确 id + int resolved = Fr007CorrectId; + int? before = row.UnderlyingId; + row.UnderlyingId = EodPriceService.ResolveUnderlyingIdForCode(row.UnderlyingCode, row.UnderlyingId ?? 0, resolved); + + Assert.AreNotEqual(before, row.UnderlyingId); + Assert.AreEqual((int?)Fr007CorrectId, row.UnderlyingId); + } + + [TestMethod] + public void 典型非错配_不同标的_各自正确不互相覆盖() + { + // 511160.SH 自己的行(FutureContractId='511160.SH'),UnderlyingId 已是 2173889 → 不动 + Assert.AreEqual(Id511160, + EodPriceService.ResolveUnderlyingIdForCode("511160.SH", Id511160, Id511160)); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/ApplySwapTradeClosePercentBugTest.cs b/UnitTestProject/Modules/SwapModule/ApplySwapTradeClosePercentBugTest.cs new file mode 100644 index 00000000..2d486df2 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ApplySwapTradeClosePercentBugTest.cs @@ -0,0 +1,180 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// GLMS-20260701-0006 多次部分平仓后提前终止 Tab 序号2 平仓比例显示 32.50% 而非 50% 的回归测试 + /// ================================================================================ + /// 根因:SwapDealService.ApplySwapTrade 缺少 A→B 口径转换。 + /// - SwapUnwind 在入口处将 ClosePercent 从口径A(占期初) 转为 口径B(占剩余),SaveSwapDealInternal 落库时 B→A 还原。 + /// - ApplySwapTrade 没有做 A→B 转换,导致 SaveSwapDealInternal 的 B→A 还原出错: + /// 0.50(A) → ToOriginalClosePercent(0.50, 50000000, 32500000) = 0.50*32500000/50000000 = 0.325 ❌ + /// - 修复后:0.50(A) → ToRemainingClosePercent → 0.769(B) → ToOriginalClosePercent → 0.50(A) ✅ + /// + /// 测试策略: + /// 1) 纯函数测试:验证 A→B→A 往返转换的正确性 + /// 2) ApplySwapTrade 集成测试:验证 SaveSwapDeal 收到的 ClosePercent 已转为口径B + /// + [TestClass] + public class ApplySwapTradeClosePercentBugTest + { + // GLMS-20260701-0006 真实数据 + private const decimal OriginalNotional = 50_000_000m; // 期初名义本金 + private const decimal RemainingAfter1st = 32_500_000m; // 首次平35%后剩余 + private const decimal FirstClosePercent = 0.35m; // 第一次平仓比例(口径A) + private const decimal SecondClosePercent = 0.50m; // 第二次平仓比例(口径A, 用户输入50%) + + // ================================================================ + // 1) 纯函数:A→B→A 往返转换应还原原值 + // ================================================================ + + [TestMethod] + public void AC_001_口径转换_往返A到B到A应还原原值() + { + // 第二次部分平仓: 用户输入 50%(口径A) + decimal closePercentA = SecondClosePercent; + + // A → B(ApplySwapTrade/SwapUnwind 入口转换) + decimal closePercentB = SwapDealService.ToRemainingClosePercent( + closePercentA, OriginalNotional, RemainingAfter1st); + + // B → A(SaveSwapDealInternal 落库还原) + decimal closePercentA_restored = SwapDealService.ToOriginalClosePercent( + closePercentB, OriginalNotional, RemainingAfter1st); + + SwapDealTestFactory.AssertDecimalEqual(closePercentA, closePercentA_restored, 1e-10m, + "A→B→A 往返转换应还原原值"); + Console.WriteLine($"A={closePercentA}, B={closePercentB}, A_restored={closePercentA_restored}"); + } + + [TestMethod] + public void AC_002_口径转换_未修复时B到A会得到错误的0_325() + { + // 模拟 bug:ApplySwapTrade 未做 A→B 转换,直接把 A 传给 SaveSwapDealInternal 的 B→A 还原 + decimal closePercentA = SecondClosePercent; // 0.50 + + // bug 路径:SaveSwapDealInternal 误把 A 当 B 做还原 + decimal buggyResult = SwapDealService.ToOriginalClosePercent( + closePercentA, OriginalNotional, RemainingAfter1st); + + // 0.50 * 32500000 / 50000000 = 0.325 + SwapDealTestFactory.AssertDecimalEqual(0.325m, buggyResult, 1e-10m, + "bug 路径:0.50(A) 被误当 B 做还原 → 0.325"); + Assert.AreNotEqual(SecondClosePercent, buggyResult, + "bug 结果 0.325 不等于用户输入 0.50"); + Console.WriteLine($"Bug: 0.50(A) 误当 B → ToOriginalClosePercent → {buggyResult} (应为 0.50)"); + } + + // ================================================================ + // 2) ApplySwapTrade 集成测试:验证 SaveSwapDeal 收到的是口径B + // ================================================================ + + [TestMethod] + public void AC_003_ApplySwapTrade_第二次部分平仓50perc_应将ClosePercent转为口径B() + { + // 模拟 GLMS-20260701-0006 第二次部分平仓的场景 + var td = new trade + { + id = 1991, + TradeNumber = "GLMS-20260701-0006", + TradeType = "收益互换", + TradeStatus = "确认成交", + ValidState = "Valid", + StockEqvNotional = (double)RemainingAfter1st, // 32500000 + OriginalStockEqvNotional = (double)OriginalNotional, // 50000000 + Notional = (double)RemainingAfter1st, + TradeAmount = (double)RemainingAfter1st + }; + + var service = new TestableSwapDealService(td); + + // 前端传入的 UnwindData(ClosePercent = 0.50, 口径A) + var unwindData = new UnwindData + { + SwapTradeId = td.id, + SwapRealizedPnL = 1000m, + SwapCloseAmount = 1000m, + CloseMethod = (int)CloseMethodEnum.部分平仓, + ClosePercent = SecondClosePercent, // 0.50 (口径A, 用户输入50%) + CloseQty = 25000000m, + CloseNotionalValue = 25000000m, // 50% of original + PositionQty = RemainingAfter1st, // 32500000 + NotionalValue = OriginalNotional, // 50000000 (期初) + PosiNotionalValue = RemainingAfter1st, // 32500000 (剩余) + ValueDate = new DateTime(2026, 7, 14), + UnwindDate = new DateTime(2026, 7, 15), + StartDate = new DateTime(2026, 7, 1) + }; + + service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓); + + // 验证 SaveSwapDeal 被调用 + Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "ApplySwapTrade 应调用 SaveSwapDeal"); + + // 验证传给 SaveSwapDeal 的 ClosePercent 已转为口径B + var savedData = service.SaveSwapDealCalls[0].data; + decimal expectedB = SwapDealService.ToRemainingClosePercent( + SecondClosePercent, OriginalNotional, RemainingAfter1st); + + SwapDealTestFactory.AssertDecimalEqual(expectedB, savedData.ClosePercent, 1e-10m, + "ApplySwapTrade 应将 ClosePercent 从口径A转为口径B"); + + // 关键验证:B 值不应等于 A 值(0.50),也不应等于 bug 值(0.325) + Assert.AreNotEqual(SecondClosePercent, savedData.ClosePercent, + "口径B 不应等于口径A (0.50)"); + Assert.AreNotEqual(0.325m, savedData.ClosePercent, + "口径B 不应等于 bug 值 (0.325)"); + + Console.WriteLine($"输入: ClosePercent(A)={SecondClosePercent}"); + Console.WriteLine($"输出: ClosePercent(B)={savedData.ClosePercent}"); + Console.WriteLine($"期望: ClosePercent(B)={expectedB}"); + Console.WriteLine($"往返还原: ClosePercent(A)={SwapDealService.ToOriginalClosePercent(savedData.ClosePercent, OriginalNotional, RemainingAfter1st)}"); + } + + [TestMethod] + public void AC_004_ApplySwapTrade_第一次平仓35perc_口径转换正确() + { + // 第一次平仓:remaining = original, 所以 A = B = 0.35 + var td = new trade + { + id = 1991, + TradeNumber = "GLMS-20260701-0006", + TradeType = "收益互换", + TradeStatus = "确认成交", + ValidState = "Valid", + StockEqvNotional = (double)OriginalNotional, + OriginalStockEqvNotional = (double)OriginalNotional, + Notional = (double)OriginalNotional, + TradeAmount = (double)OriginalNotional + }; + + var service = new TestableSwapDealService(td); + + var unwindData = new UnwindData + { + SwapTradeId = td.id, + SwapRealizedPnL = 1000m, + SwapCloseAmount = 1000m, + CloseMethod = (int)CloseMethodEnum.部分平仓, + ClosePercent = FirstClosePercent, // 0.35 (口径A) + CloseQty = 17500000m, + CloseNotionalValue = 17500000m, + PositionQty = OriginalNotional, + NotionalValue = OriginalNotional, + PosiNotionalValue = OriginalNotional, // 首次平仓 remaining == original + ValueDate = new DateTime(2026, 7, 6), + UnwindDate = new DateTime(2026, 7, 7), + StartDate = new DateTime(2026, 7, 1) + }; + + service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓); + + var savedData = service.SaveSwapDealCalls[0].data; + // 首次平仓 remaining == original → A == B == 0.35 + SwapDealTestFactory.AssertDecimalEqual(FirstClosePercent, savedData.ClosePercent, 1e-10m, + "首次平仓 remaining==original → 口径A==口径B==0.35"); + Console.WriteLine($"首次平仓: ClosePercent(A=B)={savedData.ClosePercent}"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/DealFloatPositionsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealFloatPositionsScenarioTest.cs index 073d6240..4c509c8f 100644 --- a/UnitTestProject/Modules/SwapModule/DealFloatPositionsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealFloatPositionsScenarioTest.cs @@ -378,6 +378,39 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"分红增值税调整: 付息100, 税率6% → TdPosiDividend={result.TdPosiDividend}(期望{expected})✅"); } + [TestMethod] + public void DF_008_CopyBranch_RealizedPnlIncludesRealizedFee() + { + var service = new StubEodService + { + UnderlyingPrice = 1.002m, + TaxRate = 0m, + BondPayment = 0m + }; + var preEod = new eod_swap_position + { + id = 5001, + SwapTradeId = SwapTradeId, + PositionId = 3001, + ValueDate = PreSettleDate, + PosiQuantity = 10000m, + PosiGrossPrice = 1.002m, + PosiNetPrice = 1.005m, + UnderlyingCode = "210210.IB", + ContractSize = 1m, + PositionType = (int)PositionTypeFlag.Long, + PosiDirection = (int)SwapDirectionEnum.收取, + RealizedMtmPnL = 98000000m, + RealizedDividend = 0m, + RealizedFee = 100m, + RealizedPnl = 98000000m + }; + + var result = service.ExecuteCopyEodPosition(preEod, null, CreateTrade(), TradeDate, PreSettleDate); + + Assert.AreEqual(98000100m, result.RealizedPnl); + } + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "") { Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, diff --git a/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs b/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs index 233b547a..59b7856f 100644 --- a/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs +++ b/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs @@ -156,9 +156,9 @@ namespace YLErp.Modules.SwapModule /// /// [FC_006] 结息-债券多头-全量结算(基线) - /// income 用 CloseNotionalValue 而非 CloseQty,无 longRatio - /// EntryPrice=1.02, TradingAmountAvg=105(×100形态), CloseNotionalValue=10000 - /// MarkClosePnl = 10000×(105×0.01−1.02)×1 = 10000×0.03 = 300 + /// income 使用持仓数量和合约乘数,无 longRatio + /// EntryPrice=1.02, TradingAmountAvg=105(×100形态), PositionQty=10000, ContractSize=1 + /// MarkClosePnl = 10000×1×(105×0.01−1.02)×1 = 10000×0.03 = 300 /// [TestMethod] public void FC_006_结息_债券多头_全量结算() @@ -166,7 +166,9 @@ namespace YLErp.Modules.SwapModule var input = new UnwindInput { Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m, - CloseNotionalValue = 10000, // income 用名义本金 + PositionQty = 10000, + ContractSize = 1, + CloseNotionalValue = 10200, // 与数量刻意不同,守卫 income 不再误用名义本金 CloseQty = 0, // income 不用数量 PayDirection = 1, PositionType = 1, TradingFee = "0", TradingFeePending = "0", DividendIn = "0" @@ -188,7 +190,8 @@ namespace YLErp.Modules.SwapModule var input = new UnwindInput { Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 110m, - CloseNotionalValue = 10000, CloseQty = 0, + PositionQty = 10000, ContractSize = 1, + CloseNotionalValue = 10200, CloseQty = 0, PayDirection = 1, PositionType = 1, TradingFee = "0", TradingFeePending = "0", DividendIn = "0" }; @@ -209,7 +212,8 @@ namespace YLErp.Modules.SwapModule var input = new UnwindInput { Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m, - CloseNotionalValue = 10000, CloseQty = 0, + PositionQty = 10000, ContractSize = 1, + CloseNotionalValue = 10200, CloseQty = 0, PayDirection = 1, PositionType = 1, TradingFee = "0", TradingFeePending = "0", DividendIn = "0" }; @@ -225,6 +229,35 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"FC_008: SwapRealizedPnL={result.SwapRealizedPnL}, SwapMarginRebatePnl={result.SwapMarginRebatePnl} ✅"); } + /// + /// [FC_009] 结息-债券支付端:价差盈亏必须按数量计算,不能按期初名义本金计算。 + /// 纯价差 = 30000000×1×(80%−98%)×(−1) = 5400000;加分红-45000后合计5355000。 + /// + [TestMethod] + public void FC_009_结息_债券价差按数量计算() + { + var input = new UnwindInput + { + Multiplier = 100, + PosiGrossPrice = 0.98m, + TradingAmountAvg = 80m, + PositionQty = 30000000m, + ContractSize = 1m, + CloseNotionalValue = 29400000m, + CloseQty = 0m, + PayDirection = 2, + PositionType = 1, + TradingFee = "0", + TradingFeePending = "0", + DividendIn = "-45000" + }; + + var result = FrontendCalcReference.CalcIncome(input); + + AssertDecimalEqual(5400000m, result.MarkClosePnl, 0.01m, "income MarkClosePnl按数量计算"); + AssertDecimalEqual(5355000m, result.FloatPnlSum, 0.01m, "income FloatPnlSum包含分红"); + } + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "") { Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs new file mode 100644 index 00000000..4b41b962 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs @@ -0,0 +1,361 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 线上事故诊断:GLMS-20260701-0008 多次部分平仓后,预付金返还显示仍为原始值 + /// ============================================================================ + /// 直连测试库,录制真实数据快照并定位根因(DB 端还是计算端)。 + /// 测试结构: + /// 1) RecordSnapshot - 录 trade/position/eod_swap_position/eod_swap/flow_event + /// 2) Diagnose - 把每次部分平仓前后 InterestPrincipalFix 实际值序列打印, + /// 验证是否双重扣减;并调用 GetUnwindInterests 1.0 看后端返还值 + /// 3) 期望对比 - 多次部分平仓后,1.0 closePercent 应返"剩余本金"(=已扣减后), + /// 若仍返原始值 ⇒ 后端 EOD 路径 bug (SaveAutoEodWithCloseInterestPosition 双重扣减) + /// + [TestClass] + public class GLMS20260701DbDiagnoseTest + { + private const string TradeNumber_0008 = "GLMS-20260701-0008"; + private const string TradeNumber_0013 = "GLMS-20260701-0013"; + + #region 1) 录真实数据快照(手动跑) + + [TestMethod] + [Ignore] + [TestCategory("DbDiagnose")] + public void Record_RealSnapshot() + { + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } + + var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber_0008); + Assert.IsNotNull(td, $"测试库无交易 {TradeNumber_0008},请确认环境"); + + var snapshot = new JObject + { + ["TradeNumber"] = td.TradeNumber, + ["TradeId"] = td.id, + ["StockEqvNotional"] = td.StockEqvNotional, + ["Notional"] = td.Notional, + ["OriginalStockEqvNotional"] = td.OriginalStockEqvNotional, + ["TradeDate"] = td.TradeDate, + ["StartDate"] = td.StartDate, + ["ExerciseDate"] = td.ExerciseDate + }; + + // 1.1 当前所有仓位(含 IsInitial=初始 + !IsInitial=已平后剩余) + var positions = db.swap_position + .Where(p => p.SwapTradeId == td.id && !p.Invalid) + .OrderBy(p => p.IsInitial).ThenBy(p => p.id) + .ToList(); + snapshot["Positions"] = JArray.FromObject(positions, JsonSerializer.Create(new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + DateFormatHandling = DateFormatHandling.IsoDateFormat + })); + + // 1.2 EOD 持仓序列(关键:观察 InterestPrincipalFix 逐日变化) + var eodPositions = db.eod_swap_position + .Where(e => e.SwapTradeId == td.id && !e.Invalid && e.InterestMode == 5 || e.InterestMode == 6) + .OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId) + .ToList(); + snapshot["EodPositions_MarginLegOnly"] = JArray.FromObject(eodPositions, JsonSerializer.Create(new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + DateFormatHandling = DateFormatHandling.IsoDateFormat + })); + + // 1.3 EOD 交易级(eod_swap.NotionalValue 应该是初始值不变) + var eodSwaps = db.eod_swap.Where(e => e.SwapTradeId == td.id).OrderBy(e => e.ValueDate).ToList(); + snapshot["EodSwaps"] = JArray.FromObject(eodSwaps, JsonSerializer.Create(new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + DateFormatHandling = DateFormatHandling.IsoDateFormat + })); + + // 1.4 所有 flow_event(看平仓/互换事件序列,及 InterestPrincipal 实际写入值) + var flows = db.swap_flow_event.Where(f => f.SwapTradeId == td.id).OrderBy(f => f.EventDate).ThenBy(f => f.id).ToList(); + snapshot["FlowEvents"] = JArray.FromObject(flows, JsonSerializer.Create(new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + DateFormatHandling = DateFormatHandling.IsoDateFormat + })); + + var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260701"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"snapshot_{DateTime.Now:yyyyMMdd_HHmmss}.json"); + File.WriteAllText(path, JsonConvert.SerializeObject(snapshot, Formatting.Indented, + new JsonSerializerSettings { DateFormatHandling = DateFormatHandling.IsoDateFormat })); + Console.WriteLine($"✅ 快照已保存: {path}"); + } + + #endregion + + #region 2) 诊断:打印"预付金腿"逐日本金变化 + 后端 API 1.0 全平应返值 + + [TestMethod] + [TestCategory("DbDiagnose")] + public void Diagnose_InterestPrincipalFix_Progression_And_UnwindResult() + { + DiagnoseTrade(TradeNumber_0008); + } + + [TestMethod] + [TestCategory("DbDiagnose")] + public void Diagnose_0013_InterestPrincipalFix_Progression_And_UnwindResult() + { + DiagnoseTrade(TradeNumber_0013); + } + + private void DiagnoseTrade(string tradeNumber) + { + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } + + var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber); + if (td == null) { Assert.Inconclusive($"测试库无 {tradeNumber}"); return; } + + // 2.1 预付金腿 position.InterestPrincipalFix 当前值(多次平仓后应该已被扣减) + var marginPositions = db.swap_position + .Where(p => p.SwapTradeId == td.id && !p.Invalid + && (p.InterestMode == (int)InterestModeEnum.初始预付金 + || p.InterestMode == (int)InterestModeEnum.追加预付金)) + .ToList(); + + Console.WriteLine("============== 预付金腿 position 当前值(多次平仓后) =============="); + foreach (var p in marginPositions) + { + Console.WriteLine($"PositionId={p.id} Mode={p.InterestMode} Fix={p.InterestPrincipalFix} Rate={p.InterestRateDefault} Dir={p.InterestDirection} IsInitial={p.IsInitial}"); + } + + // 2.1b 所有 position 全景(含浮动腿),对比 IsInitial vs !IsInitial 的 PosiNotionalValue / Fix + var allPositions = db.swap_position + .Where(p => p.SwapTradeId == td.id && !p.Invalid) + .OrderBy(p => p.IsInitial).ThenBy(p => p.id) + .ToList(); + + Console.WriteLine("\n============== 全部 position 全景(对比 IsInitial 原始 vs !IsInitial 剩余) =============="); + Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}"); + foreach (var p in allPositions) + { + var ul = p.UnderlyingCode ?? ""; + Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-7}{p.PosiDirection,-8}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}{ul,-15}"); + } + + // 2.1c 关键诊断:GetUnwindInterests 内部 origPositions vs realPostitions 差异 + var origPositions = allPositions.Where(x => x.IsInitial).ToList(); + var realPostitions = allPositions.Where(x => !x.IsInitial).ToList(); + Console.WriteLine("\n============== GetUnwindInterests 关键源数据对比 =============="); + Console.WriteLine($" origPositions(IsInitial=True) 浮动腿 PosiNotionalValue 总和: {origPositions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)}"); + Console.WriteLine($" realPostitions(IsInitial=False) 浮动腿 PosiNotionalValue 总和: {realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)} ← 应为剩余值"); + Console.WriteLine($" origPositions(IsInitial=True) 预付金腿 Fix: {string.Join(",", origPositions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))}"); + Console.WriteLine($" realPostitions(IsInitial=False) 预付金腿 Fix: {string.Join(",", realPostitions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))} ← 应为剩余值"); + + // 2.1d 关键诊断:realLeg.PositionId == origPos.id 匹配校验(修复后端 Clone 是否会触发) + Console.WriteLine("\n============== realLeg.PositionId ↔ origPos.id 匹配校验(决定 Clone 是否生效)=============="); + foreach (var origPos in origPositions.Where(p => p.InterestMode == 5 || p.InterestMode == 6)) + { + var realLeg = realPostitions.FirstOrDefault(r => r.PositionId == origPos.id); + Console.WriteLine($" origPos.id={origPos.id} Fix={origPos.InterestPrincipalFix} | realLeg found={(realLeg != null)} | realLeg.id={realLeg?.id} realLeg.PositionId={realLeg?.PositionId} realLeg.Fix={realLeg?.InterestPrincipalFix} | 需Clone={(realLeg != null && realLeg.InterestPrincipalFix != origPos.InterestPrincipalFix)}"); + } + + // 2.2 EOD 持仓 InterestPrincipalFix 逐日序列 + var eodMarginSeq = db.eod_swap_position + .Where(e => e.SwapTradeId == td.id && !e.Invalid + && (e.InterestMode == (int)InterestModeEnum.初始预付金 + || e.InterestMode == (int)InterestModeEnum.追加预付金)) + .OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId) + .ToList(); + + Console.WriteLine("============== EOD 预付金腿 InterestPrincipalFix 逐日变化 =============="); + foreach (var e in eodMarginSeq) + { + Console.WriteLine($" ValueDate={e.ValueDate:yyyy-MM-dd} PositionId={e.PositionId} Fix={e.InterestPrincipalFix} TdInterestPrincipal={e.TdInterestPrincipal} PosiStatus={e.PosiStatus} Invalid={e.Invalid}"); + } + + // 2.3 平仓事件序列(看 InterestPrincipal 实际入库值) + var closeFlows = db.swap_flow_event + .Where(f => f.SwapTradeId == td.id && f.EventType == (int)SwapEventTypeEnum.平仓 + && f.DataState == (int)SwapFlowDateStateEnum.完成 + && (f.InterestMode == (int)InterestModeEnum.初始预付金 + || f.InterestMode == (int)InterestModeEnum.追加预付金)) + .OrderBy(f => f.EventDate).ToList(); + + Console.WriteLine("============== 历史平仓事件-预付金腿 实际 InterestPrincipal 序列 =============="); + foreach (var f in closeFlows) + { + Console.WriteLine($" EventDate={f.EventDate:yyyy-MM-dd} PositionId={f.PositionId} InterestPrincipal={f.InterestPrincipal} InterestAmount={f.InterestAmount} Quantity={f.Quantity} TradingAmount={f.TradingAmount}"); + } + + // 2.3b 直接调 ResolveInterestLegPositions,验证 Clone 是否真的把 Fix 覆盖成 realLeg 值 + var resolved = SwapDealService.ResolveInterestLegPositions(origPositions, realPostitions); + Console.WriteLine("\n============== ResolveInterestLegPositions 直接调用结果 =============="); + foreach (var rp in resolved.Where(x => x.InterestMode == 5 || x.InterestMode == 6)) + { + Console.WriteLine($" resolved: id={rp.id} PositionId={rp.PositionId} Mode={rp.InterestMode} Fix={rp.InterestPrincipalFix} (期望=realLeg.Fix)"); + } + + // 2.3c 模拟前端调用 controller 完整流程:前端传 closePercent=0.7(占期初) + notionalValue/posiNotionalValue + // controller 调 ToRemainingClosePercent 转为占剩余,再调 GetUnwindInterests + // 等价于 HTTP POST /swaptrade2/GetUnwindInterestList + Console.WriteLine("\n============== 模拟 HTTP API 调用(前端 closePercent=0.7 占期初)=============="); + decimal frontClosePercent = 0.7m; + decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初名义本金 + decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余名义本金 + Console.WriteLine($" 前端参数: closePercent={frontClosePercent} notionalValue={frontNotionalValue} posiNotionalValue={frontPosiNotionalValue}"); + decimal convertedClosePercent = SwapDealService.ToRemainingClosePercent(frontClosePercent, frontNotionalValue, frontPosiNotionalValue); + Console.WriteLine($" ToRemainingClosePercent 转换后: closePercent={convertedClosePercent}(占剩余)"); + var svc = new SwapDealService(new OptUserInfo(1, "UnitTest", OptUserFrom.UnitTest)); + var apiInterests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, convertedClosePercent, (int)SwapEventTypeEnum.平仓); + Console.WriteLine($" GetUnwindInterests 返回 {apiInterests.Count} 条,预付金腿:"); + foreach (var ai in apiInterests.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 || x.InterestMode == (int)InterestModeEnum.追加预付金)) + { + Console.WriteLine($" PositionId={ai.PositionId} Mode={ai.InterestMode} InterestPrincipal={ai.InterestPrincipal} InterestAmount={ai.InterestAmount}"); + } + + // 2.4 直调后端 GetUnwindInterests(closePercent=1.0) 看"按全部平仓应返"的预付金值 + try + { + var user = new OptUserInfo(0, nameof(GLMS20260701DbDiagnoseTest), OptUserFrom.UnitTest); + var svcFull = new SwapDealService(user); + var interests = svcFull.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.平仓); + + Console.WriteLine("============== 后端 GetUnwindInterests(1.0) 实际返回值-预付金腿 =============="); + foreach (var it in interests.Where(i => i.InterestMode == 5 || i.InterestMode == 6)) + { + Console.WriteLine($" PositionId={it.PositionId} Mode={it.InterestMode} InterestPrincipal={it.InterestPrincipal} InterestAmount={it.InterestAmount} InterestRate={it.InterestRate}"); + } + + // 诊断断言:1.0 全平应返 = realPostitions(剩余持仓)的 InterestPrincipalFix + // 后端为保持 eod_swap_position.PositionId 日终归档对齐,返回的 PositionId 仍是 origPositions.id, + // 但 InterestPrincipal 应等于 realPostitions[real.PositionId == orig.id].Fix(剩余值)。 + // 因此对比口径:apiRet.InterestPrincipal vs realLeg.Fix(剩余值),不是 vs origPos.Fix(原始值)。 + Console.WriteLine("============== 修复验证(apiRet.InterestPrincipal vs realLeg.Fix 剩余值)=============="); + int okCount = 0, badCount = 0; + foreach (var origPos in marginPositions.Where(p => p.IsInitial)) + { + var apiRet = interests.FirstOrDefault(i => i.PositionId == origPos.id); + if (apiRet == null) { Console.WriteLine($" ⚠ PositionId={origPos.id} 后端未返回"); continue; } + var realLeg = marginPositions.FirstOrDefault(p => !p.IsInitial && p.PositionId == origPos.id); + decimal expectedFix = realLeg?.InterestPrincipalFix ?? origPos.InterestPrincipalFix; + var diff = Math.Abs((double)(apiRet.InterestPrincipal - expectedFix)); + bool ok = diff < 0.01; + if (ok) okCount++; else badCount++; + Console.WriteLine($" {(ok ? "✓" : "✗")} PositionId={origPos.id}(origFix={origPos.InterestPrincipalFix}) → realLeg.Fix={expectedFix} 后端返={apiRet.InterestPrincipal} 差={diff:F4}"); + } + Console.WriteLine($"\n 结论:通过 {okCount} 条 / 失败 {badCount} 条"); + Assert.IsTrue(badCount == 0, $"修复未生效:{badCount} 条预付金腿后端返还值 ≠ realLeg.Fix 剩余值"); + } + catch (Exception ex) + { + Console.WriteLine($"⚠ GetUnwindInterests 调用失败:{ex.Message}"); + } + } + + #endregion + + #region 3) 诊断 GLMS-20260701-0006:部分平仓比例显示 32.50% 而非 50% + + [TestMethod] + [TestCategory("DbDiagnose")] + public void Diagnose_0006_UnwindPercentRate_Display() + { + const string tradeNumber = "GLMS-20260701-0006"; + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } + + var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber); + if (td == null) { Assert.Inconclusive($"测试库无 {tradeNumber}"); return; } + + Console.WriteLine($"===== 交易 {tradeNumber} (id={td.id}) ====="); + Console.WriteLine($" TradeType: {td.TradeType}"); + Console.WriteLine($" TradeStatus: {td.TradeStatus}"); + Console.WriteLine($" StockEqvNotional (剩余): {td.StockEqvNotional}"); + Console.WriteLine($" OriginalStockEqvNotional (期初): {td.OriginalStockEqvNotional}"); + Console.WriteLine($" Notional: {td.Notional}"); + Console.WriteLine($" OriginalNotional: {td.OriginalNotional}"); + Console.WriteLine($" TradeAmount: {td.TradeAmount}"); + Console.WriteLine($" HasPartialUnWind: {td.HasPartialUnWind}"); + Console.WriteLine($" 剩余比例 = StockEqvNotional/Original = {td.StockEqvNotional / td.OriginalStockEqvNotional}"); + Console.WriteLine(); + + Console.WriteLine($"===== trade_cash 记录 ====="); + var tradeCashList = db.trade_cash + .Where(t => t.TradeId == td.id && !t.IsDeleted && + (t.Action == "系统操作-平仓费" || t.Action == "系统操作-行权费")) + .OrderBy(t => t.ValueDate).ThenBy(t => t.id) + .ToList(); + + foreach (var tc in tradeCashList) + { + Console.WriteLine($" [id={tc.id}] ValueDate={tc.ValueDate:yyyy-MM-dd} Action={tc.Action}"); + Console.WriteLine($" UnwindType: {tc.UnwindType}"); + Console.WriteLine($" UnwindPercentRate: {tc.UnwindPercentRate} (=> {tc.UnwindPercentRate * 100}%)"); + Console.WriteLine($" UnwindStockEqvNotional: {tc.UnwindStockEqvNotional}"); + Console.WriteLine($" UnwindNotional: {tc.UnwindNotional}"); + Console.WriteLine($" UnwindTradeAmount: {tc.UnwindTradeAmount}"); + Console.WriteLine($" UnwindMethod: {tc.UnwindMethod}"); + Console.WriteLine($" ValidState: {tc.ValidState}"); + Console.WriteLine($" IsLastAction: {tc.IsLastAction}"); + Console.WriteLine($" ExerciseWay: {tc.ExerciseWay}"); + Console.WriteLine(); + } + + Console.WriteLine($"===== swap_event 记录 ====="); + var swapEvents = db.swap_event + .Where(e => e.SwapTradeId == td.id && !e.Invalid) + .OrderBy(e => e.ValueDate).ThenBy(e => e.id) + .ToList(); + + foreach (var se in swapEvents) + { + Console.WriteLine($" [id={se.id}] ValueDate={se.ValueDate:yyyy-MM-dd} EventType={se.EventType}"); + Console.WriteLine($" EventReason: {se.EventReason}"); + Console.WriteLine($" ClientCashId: {se.ClientCashId}"); + if (!string.IsNullOrEmpty(se.EventData)) + { + try + { + var ud = JsonConvert.DeserializeObject(se.EventData); + Console.WriteLine($" EventData.ClosePercent: {ud["ClosePercent"]}"); + Console.WriteLine($" EventData.CloseNotionalValue: {ud["CloseNotionalValue"]}"); + Console.WriteLine($" EventData.CloseQty: {ud["CloseQty"]}"); + Console.WriteLine($" EventData.NotionalValue: {ud["NotionalValue"]}"); + Console.WriteLine($" EventData.PosiNotionalValue: {ud["PosiNotionalValue"]}"); + Console.WriteLine($" EventData.PositionQty: {ud["PositionQty"]}"); + Console.WriteLine($" EventData.CloseMethod: {ud["CloseMethod"]}"); + } + catch (Exception ex) + { + Console.WriteLine($" EventData parse error: {ex.Message}"); + } + } + Console.WriteLine(); + } + + // 查询 swap_flow_event 记录 + Console.WriteLine($"===== swap_flow_event 记录 ====="); + var flowEvents = db.swap_flow_event + .Where(f => f.SwapTradeId == td.id) + .OrderBy(f => f.EventDate).ThenBy(f => f.id) + .ToList(); + + foreach (var fe in flowEvents) + { + Console.WriteLine($" [id={fe.id}] EventDate={fe.EventDate:yyyy-MM-dd} EventType={fe.EventType}"); + Console.WriteLine($" PositionId: {fe.PositionId}"); + Console.WriteLine($" Quantity: {fe.Quantity}"); + Console.WriteLine($" PositionQty: {fe.PositionQty}"); + Console.WriteLine(); + } + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs b/UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs new file mode 100644 index 00000000..dfc268b9 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs @@ -0,0 +1,103 @@ +namespace YLErp.Modules.SwapModule +{ + /// + /// InitUnwind 默认 ClosePercent 计算的回归测试。 + /// --------------------------------------------------------------- + /// 守卫提交 e2fb456b "fix 平仓(InitUnwind L267)硬编码 1 而不是剩余平仓比例"。 + /// + /// 旧 bug:InitUnwind 默认把 ClosePercent 硬编码为 1(按"占剩余 100%"), + /// 但前端约定 ClosePercent 是"占期初(original)"口径(A),1 表示平掉原始本金的 100%。 + /// 多次部分平仓后剩余本金 < 期初本金,此时默认 1 在前端语义上意味着"还要平掉原始全部", + /// 与"平掉剩余全部"意图不符,且会触发后端 ToRemainingClosePercent 转换后 >1 被 cap 到 1, + /// 表面看无差异但语义混乱,且若前端 / 事件展示直接用此值会出错。 + /// + /// 修复:ClosePercent = PosiNotionalValue / NotionalValue(占期初口径的"平剩余全部")。 + /// 抽出为纯函数 CalcDefaultInitClosePercent 以支持无库单测。 + /// + [TestClass] + public class InitUnwindDefaultClosePercentTest + { + // ================================================================ + // 场景1:未平仓 PosiNotionalValue == NotionalValue → ClosePercent = 1 + // ================================================================ + [TestMethod] + public void 未平仓_剩余等于期初_默认ClosePercent为1() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 1_000_000m, posiNotionalValue: 1_000_000m); + + Assert.AreEqual(1m, result, "未平仓:默认应平 100%(占期初)"); + } + + // ================================================================ + // 场景2:已平 30%(剩 70%)→ ClosePercent = 0.7 + // ================================================================ + [TestMethod] + public void 已平30_剩70_默认ClosePercent为0_7() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 1_000_000m, posiNotionalValue: 700_000m); + + Assert.AreEqual(0.7m, result, 0.0001m, "已平 30% 剩 70%:默认 ClosePercent=0.7(占期初)"); + } + + // ================================================================ + // 场景3:除零保护 NotionalValue = 0 → 返回 1(容错) + // ================================================================ + [TestMethod] + public void 期初名义本金为零_返回1_容错不除零() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 0m, posiNotionalValue: 100_000m); + + Assert.AreEqual(1m, result, "期初本金为 0 时容错返回 1,不应抛除零异常"); + } + + // ================================================================ + // 场景4:GLMS-20260701-0013 真实快照(已平 2 次) + // 期初 NotionalValue = 980,000 / 剩余 PosiNotionalValue = 686,000.07 + // 期望 ClosePercent ≈ 0.7(686000.07/980000) + // ================================================================ + [TestMethod] + public void GLMS20260701_0013_已平两次_默认ClosePercent约为0_7() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 980_000m, posiNotionalValue: 686_000.07m); + + // 686000.07 / 980000 = 0.700000071... + Assert.AreEqual(0.7m, result, 0.0001m, + "GLMS-20260701-0013 已平两次:默认 ClosePercent 应≈0.7(占期初),旧 bug 会硬编码 1"); + } + + // ================================================================ + // 场景5:与 ToRemainingClosePercent 联动验证 + // 前端拿 InitUnwind 返回的 A(占期初) 默认值,经 ToRemainingClosePercent 转 B(占剩余), + // 应恰好 = 1.0(因为"平剩余全部"在占剩余语义下就是 100%) + // ================================================================ + [TestMethod] + public void 默认A经ToRemainingClosePercent转B应为1_平剩余全部() + { + const decimal notionalValue = 1_000_000m; + const decimal posiNotionalValue = 600_000m; // 已平 40%,剩 60% + + var defaultA = SwapDealService.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue); + var convertedB = SwapDealService.ToRemainingClosePercent(defaultA, notionalValue, posiNotionalValue); + + Assert.AreEqual(0.6m, defaultA, 0.0001m, "占期初默认 A=0.6"); + Assert.AreEqual(1.0m, convertedB, 0.0001m, + "A=0.6 经 ToRemainingClosePercent 转换 → B=1.0(占剩余 100% = 平剩余全部),此为占期初/占剩余双语义自洽的关键不变式"); + } + + // ================================================================ + // 场景6:全平完(PosiNotionalValue=0)→ ClosePercent=0(边界,实际不会进 InitUnwind) + // ================================================================ + [TestMethod] + public void 全平完剩余为零_ClosePercent为零() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 1_000_000m, posiNotionalValue: 0m); + + Assert.AreEqual(0m, result, "剩余本金为 0 时 ClosePercent=0(边界场景,实际全部平完不会再进 InitUnwind)"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs b/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs deleted file mode 100644 index 85869479..00000000 --- a/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs +++ /dev/null @@ -1,359 +0,0 @@ -using Newtonsoft.Json; -using YLErp.DBModels; -using YLErp.DBModels.Enums; - -namespace YLErp.Modules.SwapModule -{ - /// - /// SwapDealService 手动结算(SwapIncome/SwapUnwind)内存单元测试 - /// ============================================================================ - /// 背景:SwapIncome/SwapUnwind 是写客户资金流水(ClientCashInCashOut)的核心入口, - /// 此前零单元测试(仅 DBRecording,CI 不跑)。本测试通过 7 个 virtual seam - /// 把 DB/事务/外部服务打桩,在纯内存下验证控制流、资金流水金额、持仓状态变更。 - /// - /// 命名规范说明(见《互换价格字段命名规范决策文档》): - /// 本测试引用现状字段(如 PosiGrossPrice/PosiNetPrice)时加对照注释, - /// 标明其真实含义与规范名,让测试可读、可作规范示范。 - /// - PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice - /// - PosiNetPrice 现状名,实为"期初全价含费"(非净价!),规范名 EntryDirtyFeePrice - /// ============================================================================ - [TestClass] - public class SwapDealSettlementTest - { - private const int SwapTradeId = 7700; - private static readonly DateTime ValueDate = new(2026, 6, 15); - private static readonly DateTime UnwindDate = new(2026, 6, 16); - - #region Stub - - /// - /// 继承 SwapDealService,override 7 个 seam,把 DB/事务/外部服务替换为内存收集器。 - /// 生产路径零改动(seam 生产实现 = 原逻辑),测试可纯内存运行。 - /// - private sealed class StubDealService : SwapDealService - { - private readonly trade _trade; - private readonly Dictionary _swapEvents; - private readonly Dictionary> _flowEventsByEventId; - public List<(double amount, string action, DateTime date)> ClientCashCalls = new(); - public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls = new(); - public int SaveAllChangesCount; - public int CloseReCheckCallCount; - - public StubDealService(trade td, - Dictionary swapEvents = null, - Dictionary> flowEventsByEventId = null) - : base(new OptUserInfo(0, nameof(SwapDealSettlementTest), OptUserFrom.UnitTest)) - { - _trade = td; - _swapEvents = swapEvents ?? new Dictionary(); - _flowEventsByEventId = flowEventsByEventId ?? new Dictionary>(); - } - - protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null; - - protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) - { - ClientCashCalls.Add((amount, action, valueDate)); - return ClientCashCalls.Count; // 返回自增 id - } - - // 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库 - protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) - { - SaveSwapDealCalls.Add((unwindData, eventType, clientCashId)); - return SaveSwapDealCalls.Count; // 返回自增 eventId - } - - // ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType) - protected override swap_event FindSwapEvent(int tradeId, int eventType) - { - return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null; - } - - // ApproveSwapTrade 查事件关联流水:从内存字典取 - protected override List FindFlowEventsByEventId(long eventId) - { - return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List(); - } - - // ApplySwapTrade 的前置校验:计数,不实际执行 - protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck) - { - CloseReCheckCallCount++; - } - - protected override void SaveAllChanges() { SaveAllChangesCount++; } - protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行 - protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作 - protected override void TriggerRealtimeSwapPosition() { } // 空操作 - } - - #endregion - - #region 数据构建 - - private static trade CreateTrade() - { - return new trade - { - id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888, - TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5), - ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用) - TradeStatus = "确认成交", ValidState = "Valid", - Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000 - }; - } - - /// 构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用) - private static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m, - decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m, - decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m) - { - return new UnwindData - { - SwapTradeId = SwapTradeId, - SwapRealizedPnL = swapRealizedPnL, - SwapMarginRebatePnl = swapMarginRebatePnl, - SwapMarginAmount = swapMarginAmount, - SwapCloseAmount = swapRealizedPnL, - CloseMethod = closeMethod, - ClosePercent = closePercent, - CloseQty = closeQty, - CloseNotionalValue = closeNotionalValue, - PositionQty = positionQty, - ValueDate = ValueDate, - UnwindDate = UnwindDate, - StartDate = new DateTime(2026, 1, 5) - }; - } - - #endregion - - // ================================================================ - // SD_001:SwapIncome 正常结息 —— 验证资金流水金额正确 - // ================================================================ - - /// - /// [SD_001] SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000 - /// ------------------------------------------------------------ - /// 后端 SwapDealService.cs:1553 直接用前端传入的 SwapRealizedPnL 记账: - /// AddClientCash(td, -SwapRealizedPnL, 系统操作_互换, ValueDate) - /// 本测试锁定:资金流水金额 = -SwapRealizedPnL,事件类型 = 互换(3)。 - /// - [TestMethod] - public void SD_001_SwapIncome_正常结息_资金流水金额正确() - { - var td = CreateTrade(); - td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支 - var service = new StubDealService(td); - var unwindData = CreateUnwindData(swapRealizedPnL: 1000m); - - service.SwapIncome(unwindData); - - Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)"); - Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL"); - Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换"); - Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); - Assert.AreEqual((int)SwapEventTypeEnum.互换, service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)"); - Console.WriteLine($"SD_001 通过:资金流水金额={service.ClientCashCalls[0].amount},事件类型=互换 ✅"); - } - - // ================================================================ - // SD_002:SwapIncome 含预付金返息 —— 两条资金流水 - // ================================================================ - - /// - /// [SD_002] SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200 - /// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200 - /// 后端 SwapDealService.cs:1556 条件:SwapMarginRebatePnl != 0 时追加预付金返息流水。 - /// - [TestMethod] - public void SD_002_SwapIncome_含预付金返息_两条资金流水() - { - var td = CreateTrade(); - td.ExerciseDate = new DateTime(2026, 12, 31); - var service = new StubDealService(td); - var unwindData = CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m); - - service.SwapIncome(unwindData); - - Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)"); - Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额 -SwapRealizedPnL"); - Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action); - Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息 -SwapMarginRebatePnl"); - Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action); - Console.WriteLine($"SD_002 通过:2条资金流水,互换={service.ClientCashCalls[0].amount},预付金返息={service.ClientCashCalls[1].amount} ✅"); - } - - // ================================================================ - // SD_003:SwapUnwind 全平仓 —— 持仓归零、资金流水、状态变更 - // ================================================================ - - /// - /// [SD_003] SwapUnwind 全平仓:ClosePercent=1 → TradeStatus=已平仓、持仓扣减、资金流水正确 - /// 后端 SwapDealService.cs SwapUnwind:全平时 TradeStatus=已平仓,StockEqvNotional/TradeAmount 扣减。 - /// - [TestMethod] - public void SD_003_SwapUnwind_正常平仓_资金流水与持仓状态正确() - { - var td = CreateTrade(); - var service = new StubDealService(td); - // 全平:ClosePercent=1, CloseQty=10000, CloseNotionalValue=1000000 - var unwindData = CreateUnwindData( - swapRealizedPnL: 5000m, swapMarginAmount: 0m, - closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, - closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); - - service.SwapUnwind(unwindData); - - // 资金流水:平仓费 = -SwapRealizedPnL - Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水"); - Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); - Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); - // 持仓状态 - Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓"); - // 全平仓走"已平仓"分支,不设 HasPartialUnWind(仅部分平仓才设=1) - Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind(仅部分平仓设=1)"); - // 持仓扣减:原 StockEqvNotional=1000000 - CloseNotionalValue=1000000 = 0 - Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0"); - Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0"); - // 事件类型 - Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)"); - Console.WriteLine($"SD_003 通过:TradeStatus={td.TradeStatus},StockEqvNotional={td.StockEqvNotional} ✅"); - } - - // ================================================================ - // SD_004:DealFloatPosition 含费价重算正确(后端唯二真做计算的地方) - // ================================================================ - - /// - /// [SD_004] DealFloatPosition 含费价重算(SwapDealService.cs:1713-1725) - /// ------------------------------------------------------------ - /// 平仓事件重算三个字段(规范语义,见命名文档): - /// TradingAmountFeeAvg(ExitDirtyFeePrice)= TradingAmountAvg(ExitDirtyPrice) + TradingFeePending/CloseQty × shortRatio - /// TradingAmountNetFeeAvg(ExitCleanFeePrice)= TradingAmountNetAvg(ExitCleanPrice) + TradingFeePending/CloseQty × shortRatio - /// TradingAmount = TradingAmountAvg × CloseQty - /// 这是后端少数真正做计算(而非透传前端值)的地方,需锁住。 - /// - /// 手算:ExitDirtyPrice=1.02, TradingFeePending=50, CloseQty=1000, Long(多头,shortRatio=-1) - /// ExitDirtyFeePrice = 1.02 + 50/1000 × (-1) = 1.02 - 0.05 = 0.97 - /// ExitCleanFeePrice = 1.00 + 50/1000 × (-1) = 1.00 - 0.05 = 0.95 - /// TradingAmount = 1.02 × 1000 = 1020 - /// - [TestMethod] - public void SD_004_DealFloatPosition_含费价重算正确() - { - var td = CreateTrade(); - var service = new StubDealService(td); - - // 构造平仓事件(PositionType>0 触发重算) - var closeEvent = new swap_flow_event - { - EventType = (int)SwapEventTypeEnum.平仓, - PositionType = (int)PositionTypeFlag.Long, // 多头,shortRatio=-1 - // TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice - TradingAmountAvg = 1.02m, - // TradingAmountNetAvg 现状名,实为"期末净价不含费",规范名 ExitCleanPrice - TradingAmountNetAvg = 1.00m, - TradingFeePending = 50m, - }; - var unwindData = CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m); - unwindData.FlowEvents.Add(closeEvent); - - service.SwapUnwind(unwindData); - - // ExitDirtyFeePrice(TradingAmountFeeAvg)= 1.02 + 50/1000×(-1) = 0.97 - Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m, - $"TradingAmountFeeAvg(ExitDirtyFeePrice) 应=ExitDirtyPrice(1.02)+Fee/CloseQty×(-1)=0.97,实际={closeEvent.TradingAmountFeeAvg}"); - // ExitCleanFeePrice(TradingAmountNetFeeAvg)= 1.00 + 50/1000×(-1) = 0.95 - Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m, - $"TradingAmountNetFeeAvg(ExitCleanFeePrice) 应=ExitCleanPrice(1.00)+Fee/CloseQty×(-1)=0.95,实际={closeEvent.TradingAmountNetFeeAvg}"); - // TradingAmount = ExitDirtyPrice × CloseQty = 1.02 × 1000 = 1020 - Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m, - $"TradingAmount 应=ExitDirtyPrice(1.02)×CloseQty(1000)=1020,实际={closeEvent.TradingAmount}"); - Console.WriteLine($"SD_004 通过:ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg},ExitCleanFeePrice={closeEvent.TradingAmountNetFeeAvg},TradingAmount={closeEvent.TradingAmount} ✅"); - } - - // ================================================================ - // SD_005:ApproveSwapTrade 审核通过 —— 反序列化事件、资金流水、持仓状态 - // ================================================================ - - /// - /// [SD_005] ApproveSwapTrade 审核通过全部平仓 - /// ------------------------------------------------------------ - /// 后端 SwapDealService.ApproveSwapTrade:从 swap_event.EventData 反序列化 UnwindData, - /// 据此生成资金流水 + 更新持仓状态。 - /// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario4,验证: - /// - SwapRealizedPnL 从事件反序列化正确(EventData JSON) - /// - 资金流水金额 = -SwapRealizedPnL - /// - 全平仓 → TradeStatus=已平仓 - /// - [TestMethod] - public void SD_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账() - { - var td = CreateTrade(); - // 构造待审核事件:EventData 里序列化了 UnwindData(含 SwapRealizedPnL=8000) - var unwindData = CreateUnwindData(swapRealizedPnL: 8000m, - closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, - closeQty: 10000m, closeNotionalValue: 1000000m); - var swapEvent = new swap_event - { - id = 1, SwapTradeId = SwapTradeId, - EventType = (int)SwapEventTypeEnum.平仓, Invalid = false, - EventData = JsonConvert.SerializeObject(unwindData) - }; - var flowEvents = new Dictionary> - { - [1] = new List { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } } - }; - var service = new StubDealService(td, - swapEvents: new Dictionary { [(int)SwapEventTypeEnum.平仓] = swapEvent }, - flowEventsByEventId: flowEvents); - - service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓); - - // 资金流水:从反序列化的 SwapRealizedPnL(8000) 记账 → -8000 - Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水"); - Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL"); - // 持仓状态 - Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓"); - Console.WriteLine($"SD_005 通过:审核反序列化 SwapRealizedPnL=8000,资金流水={service.ClientCashCalls[0].amount},TradeStatus={td.TradeStatus} ✅"); - } - - // ================================================================ - // SD_006:ApplySwapTrade 提交审核 —— 前置校验 + 保存事件 - // ================================================================ - - /// - /// [SD_006] ApplySwapTrade 提交审核 - /// ------------------------------------------------------------ - /// 后端 SwapDealService.ApplySwapTrade:调 CloseReCheckSetTrade 前置校验 + SaveSwapDeal(approve=true)。 - /// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario5,验证: - /// - CloseReCheckSetTrade 被调用1次 - /// - SaveSwapDeal 以 approve=true 调用(事件类型正确) - /// - SwapRealizedPnL = SwapCloseAmount(ApplySwapTrade 内部赋值) - /// - [TestMethod] - public void SD_006_ApplySwapTrade_提交审核_前置校验与保存事件() - { - var td = CreateTrade(); - var service = new StubDealService(td); - // 前端提交时 SwapCloseAmount=6000(前端算好的总额),SwapRealizedPnL 初始可能为0 - var unwindData = CreateUnwindData(swapRealizedPnL: 0m); - unwindData.SwapCloseAmount = 6000m; // 模拟前端传入的平仓总额 - - service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓); - - // 前置校验被调用 - Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次"); - // SaveSwapDeal 以 approve=true 调用 - Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); - Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓"); - // SwapRealizedPnL 应被赋值为 SwapCloseAmount(ApplySwapTrade 内部 cs:1631) - Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m, - "SwapRealizedPnL 应=SwapCloseAmount(6000)"); - Console.WriteLine($"SD_006 通过:CloseReCheck 调用{service.CloseReCheckCallCount}次,SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅"); - } - } -} diff --git a/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs new file mode 100644 index 00000000..4be39977 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs @@ -0,0 +1,206 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService.CalculateSwapRealizedPnl 的回归测试。 + /// --------------------------------------------------------------- + /// 守卫张名锐提交 6676b625 "fix(swap): 修正掉期产品保证金利息计算逻辑"。 + /// + /// 旧 bug:eod_swap.RealizedPnL 直接 Sum(s.RealizedPnl),未对保证金腿利息做方向反向, + /// 导致"收取对手方保证金"产生的利息被错误计入我方收益(实际是我方支付给对手方的成本), + /// 框架合约已实现收益虚高。 + /// + /// 修复:新增 CalculateSwapRealizedPnl —— + /// 非保证金腿:interestRatio = Direction==收取 ? 1 : -1(维持数据库方向) + /// 保证金腿(初始预付金 5 / 追加预付金 6):interestRatio 反向 + /// 最终:RealizedInterest × interestRatio + 其他 4 字段 + /// + /// 抽为 public static 纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 + /// 本测试直接锁定方向反向契约,防止后续误改回归。 + /// + [TestClass] + public class SwapEodRealizedPnlCalcTest + { + // ================================================================ + // 场景1:非保证金腿收取方向 → RealizedInterest × +1(维持原向) + // ================================================================ + [TestMethod] + public void 非保证金腿_收取方向_利息维持原向系数为1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(1000m, result, 0.0001m, + "非保证金腿收取方向:利息 ×(+1)=1000"); + } + + // ================================================================ + // 场景2:非保证金腿支付方向 → RealizedInterest × -1(维持原向) + // ================================================================ + [TestMethod] + public void 非保证金腿_支付方向_利息维持原向系数为负1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.支付, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(-1000m, result, 0.0001m, + "非保证金腿支付方向:利息 ×(-1)=-1000"); + } + + // ================================================================ + // 场景3:保证金腿(初始预付金)收取方向 → 利息反向,系数 -1 + // 这是 6676b625 修复的核心场景:收取对手方保证金产生的利息是我方支付成本 + // ================================================================ + [TestMethod] + public void 保证金腿_初始预付金_收取方向_利息反向系数为负1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(-1000m, result, 0.0001m, + "保证金腿收取方向:利息应反向 ×(-1)=-1000(修复前会错误得 +1000)"); + } + + // ================================================================ + // 场景4:保证金腿(初始预付金)支付方向 → 利息反向,系数 +1 + // ================================================================ + [TestMethod] + public void 保证金腿_初始预付金_支付方向_利息反向系数为1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.支付, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(1000m, result, 0.0001m, + "保证金腿支付方向:利息应反向 ×(+1)=1000"); + } + + // ================================================================ + // 场景5:追加预付金同初始预付金,同样走反向逻辑 + // ================================================================ + [TestMethod] + public void 保证金腿_追加预付金_收取方向_利息反向() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.追加预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedInterest: 500m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(-500m, result, 0.0001m, + "追加预付金(mode=6)与初始预付金(mode=5)同走反向逻辑"); + } + + // ================================================================ + // 场景6:完整 5 字段汇总(MtmPnL + Dividend + Fee + Interest×ratio + InterestFee) + // 保证金腿收取方向,Interest=200, 其他各 100 + // 期望:100 + 100 + 100 + 200×(-1) + 100 = 200 + // ================================================================ + [TestMethod] + public void 完整5字段汇总_保证金腿收取方向_利息反向后合计正确() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedMtmPnL: 100m, + realizedDividend: 100m, + realizedFee: 100m, + realizedInterest: 200m, + realizedInterestFee: 100m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + // 100 + 100 + 100 + 200×(-1) + 100 = 200 + Assert.AreEqual(200m, result, 0.0001m, + "5 字段汇总:保证金腿收取方向,Interest×(-1) 后合计=200,验证所有字段都参与计算"); + } + + // ================================================================ + // 场景7:完整 5 字段汇总(非保证金腿收取方向) + // 非保证金腿收取方向,Interest=200, 其他各 100 + // 期望:100 + 100 + 100 + 200×(+1) + 100 = 600 + // ================================================================ + [TestMethod] + public void 完整5字段汇总_非保证金腿收取方向_利息原向合计正确() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + realizedMtmPnL: 100m, + realizedDividend: 100m, + realizedFee: 100m, + realizedInterest: 200m, + realizedInterestFee: 100m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + // 100 + 100 + 100 + 200×(+1) + 100 = 600 + Assert.AreEqual(600m, result, 0.0001m, + "5 字段汇总:非保证金腿收取方向,Interest×(+1) 后合计=600"); + } + + // ================================================================ + // 场景8:RealizedInterest=0 边界 —— 方向反向无影响,结果为其他 4 字段之和 + // ================================================================ + [TestMethod] + public void 利息为零_方向反向无影响_结果为其他4字段之和() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedMtmPnL: 100m, + realizedDividend: 50m, + realizedFee: 30m, + realizedInterest: 0m, + realizedInterestFee: 20m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + // 100 + 50 + 30 + 0×(-1) + 20 = 200 + Assert.AreEqual(200m, result, 0.0001m, + "RealizedInterest=0 时方向反向无影响,结果为其他 4 字段之和"); + } + + // ================================================================ + // Helper:构造 eod_swap_position(只设置参与计算的 7 个字段) + // ================================================================ + private static eod_swap_position NewPosition( + int interestMode, + int interestDirection, + decimal realizedMtmPnL = 0m, + decimal realizedDividend = 0m, + decimal realizedFee = 0m, + decimal realizedInterest = 0m, + decimal realizedInterestFee = 0m) + { + return new eod_swap_position + { + InterestMode = interestMode, + InterestDirection = interestDirection, + RealizedMtmPnL = realizedMtmPnL, + RealizedDividend = realizedDividend, + RealizedFee = realizedFee, + RealizedInterest = realizedInterest, + RealizedInterestFee = realizedInterestFee + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs new file mode 100644 index 00000000..5a1d4308 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs @@ -0,0 +1,67 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 互换结息(SwapIncome)测试 + /// ============================================================================ + /// 借鉴 testable 分支命名,基于当前分支 TestableSwapDealService 共享 stub。 + /// SwapIncome 是写客户资金流水(ClientCashInCashOut)的核心入口之一。 + /// ============================================================================ + [TestClass] + public class SwapIncomeScenarioTest + { + // ================================================================ + // 场景1:SwapIncome 正常结息 —— 资金流水金额正确 + // ================================================================ + + /// + /// SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000。 + /// 后端 SwapDealService SwapIncome 直接用前端传入的 SwapRealizedPnL 记账。 + /// + [TestMethod] + public void SI_001_SwapIncome_正常结息_资金流水金额正确() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支 + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 1000m); + + service.SwapIncome(unwindData); + + Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)"); + Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL"); + Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换"); + Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); + Assert.AreEqual((int)SwapEventTypeEnum.互换, service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)"); + Console.WriteLine($"SI_001: 资金流水={service.ClientCashCalls[0].amount}, 事件类型=互换 ✅"); + } + + // ================================================================ + // 场景2:SwapIncome 含预付金返息 —— 两条资金流水 + // ================================================================ + + /// + /// SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200 + /// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200。 + /// + [TestMethod] + public void SI_002_SwapIncome_含预付金返息_两条资金流水() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m); + + service.SwapIncome(unwindData); + + Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)"); + Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额"); + Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action); + Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息"); + Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action); + Console.WriteLine($"SI_002: 互换={service.ClientCashCalls[0].amount}, 预付金返息={service.ClientCashCalls[1].amount} ✅"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs new file mode 100644 index 00000000..86c3dac1 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -0,0 +1,259 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapPositionCompose 日终归档端到端测试 + /// ============================================================================ + /// 借鉴 testable 分支 SwapPositionComposeScenarioTest,基于当前分支 seam 重写。 + /// 覆盖 DealFloatPositions 的首次归档/Copy/Update/异常路径。 + /// 利息腿场景(自动互换)因 CalcSwapInterests 参数适配复杂留后续。 + /// ============================================================================ + [TestClass] + public class SwapPositionComposeScenarioTest + { + private const int SwapTradeId = 100; + private static readonly DateTime SettleDate = new(2025, 4, 24); + private static readonly DateTime PreSettleDate = new(2025, 4, 23); + + #region 可测试化子类 + + /// + /// 继承 SwapEodPositionService,override SwapPositionCompose 路径上的 seam。 + /// 适配当前分支 seam 签名(GetUnderlyingPrice 带 out、GetCurrencyRate 返回 double 等)。 + /// + private sealed class TestableSwapEodService : SwapEodPositionService + { + private readonly List _trades; + private readonly List _positions; + private readonly List _eodPositions; + private readonly List _eodSwaps; + private readonly List _extends; + private readonly List _flowEvents; + private readonly decimal _price; + private readonly decimal _vobp; + + public List CreatedEodPositions { get; } = new(); + public List<(double amount, string action)> ClientCashCalls { get; } = new(); + + public TestableSwapEodService( + List trades, List positions, + List eodPositions, List eodSwaps, + List extends, List flowEvents, + decimal price = 100m, decimal vobp = 0m) + : base(new OptUserInfo(0, nameof(SwapPositionComposeScenarioTest), OptUserFrom.UnitTest)) + { + _trades = trades; _positions = positions; _eodPositions = eodPositions; + _eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents; + _price = price; _vobp = vobp; + } + + // SwapPositionCompose 路径 seam override + protected override List FindActiveSwapTrades(DateTime settleDate, IEnumerable clientIds) => _trades; + protected override List FindAllSwapPositions(List tradeIds) => _positions; + protected override List FindTradeExtends(List tradeIds) => _extends; + protected override List FindEodSwapsByDate(DateTime valueDate) => _eodSwaps; + protected override List FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents; + protected override List FindEodSwapPositions(int swapTradeId, DateTime preSettleDate) + => _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList(); + protected override List FindSwapPositions(int swapTradeId) + => _positions.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial).ToList(); + + // DealFloatPositions 路径 seam override + protected override underlying_manager GetUnderlyingData(string underlyingCode) + => new underlying_manager { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" }; + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) + { vobp = _vobp; return _price; } + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m; + + // 持久化/事务 seam override + protected override void PersistEodSwapPosition(eod_swap_position position) { CreatedEodPositions.Add(position); } + protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { } + protected override void SaveAllChanges() { } + protected override void ExecuteInTransaction(Action action) => action(); + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) + { ClientCashCalls.Add((amount, action)); return ClientCashCalls.Count; } + protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) { } + public override void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { } + protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) + { return new swap_event { id = 1 }; } + protected override List CalcSwapInterests( + trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, + List eodPositions, List positions, + decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, + decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, + decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + List closeList = null) => new List(); + protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) => 1.0; + + public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) + => SwapPositionCompose(settleDate, preSettleDate, null); + } + + #endregion + + #region 工厂方法 + + private static trade CreateTrade(DateTime? startDate = null) + { + var date = startDate ?? SettleDate; + return new trade + { + id = SwapTradeId, TradeNumber = "TEST-COMPOSE-001", ClientId = 10, + TradeType = "收益互换", TradeDate = date, StartDate = date, + ExerciseDate = SettleDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid", + QuoteCurrency = "CNY", SettlementCurrency = "CNY", StructureType = "普通债券类收益互换", + OriginalStockEqvNotional = 100000, TradePrice = 0 + }; + } + + private static trade_extend CreateExtend() + { + return new trade_extend + { + TradeId = SwapTradeId, + ExtendJson = @"{""NeedOpenFee"":false,""AnnualDays"":365,""SettlementRules"":0,""Direction"":1,""FlowBookMode"":0}" + }; + } + + private static swap_position CreateFloatPosition(long positionId, decimal qty) + { + return new swap_position + { + id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId, + PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "220205.IB", UnderlyingInstrumentType = "TBonds", + ContractSize = 1m, CountRatio = 1m, IsInitial = true, Invalid = false, + PosiQuantity = qty, PosiNotionalValue = qty, + PosiNetPrice = 1.0050m, PosiGrossPrice = 1.0020m, + PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 0.9970m, + InterestDirection = 0 + }; + } + + private static eod_swap_position CreateFloatEodPosition(long positionId, decimal qty, decimal grossPrice) + { + return new eod_swap_position + { + SwapTradeId = SwapTradeId, PositionId = positionId, ValueDate = PreSettleDate, + PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false, + PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m, + PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m, + UnderlyingCode = "220205.IB", ContractSize = 1m, + InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty + }; + } + + private static swap_flow_event CreateCloseFlowEvent(long positionId, decimal qty) + { + return new swap_flow_event + { + SwapTradeId = SwapTradeId, PositionId = positionId, + EventType = (int)SwapFlowEventTypeEnum.平仓, + Quantity = qty, EventDate = SettleDate, UnwindDate = SettleDate, + MarkClosePnl = 500m, CloseFee = 10m, DividendIn = 5m, + TradingAmountAvg = 1.0030m, DataState = (int)SwapFlowDateStateEnum.完成 + }; + } + + #endregion + + // ================================================================ + // 场景1:首次归档(无前日eod,交易首日) + // ================================================================ + + [TestMethod] + public void SPC_001_首次归档_无前日Eod_直接取初始持仓() + { + var td = CreateTrade(); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var service = new TestableSwapEodService( + new List { td }, positions, + new List(), new List(), + new List { extend }, new List()); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + Assert.IsTrue(service.CreatedEodPositions.Count >= 1, "应创建至少1条eod"); + var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1); + Assert.IsNotNull(floatEod, "应创建浮动腿持仓"); + Assert.AreEqual(1000m, floatEod.PosiQuantity, "首次归档 PosiQuantity=初始持仓数量"); + Console.WriteLine($"SPC_001: PosiQuantity={floatEod.PosiQuantity} ✅"); + } + + // ================================================================ + // 场景2:有前日eod无事件 → Copy + // ================================================================ + + [TestMethod] + public void SPC_002_Copy分支_有前日Eod无事件_价格原样复制() + { + var td = CreateTrade(); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var prevEod = new List { CreateFloatEodPosition(1, 1000, 1.0020m) }; + var service = new TestableSwapEodService( + new List { td }, positions, + prevEod, new List(), + new List { extend }, new List()); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1); + Assert.IsNotNull(floatEod); + Assert.AreEqual(1000m, floatEod.PosiQuantity, "Copy分支 PosiQuantity不变"); + Assert.AreEqual(1.0020m, floatEod.PosiGrossPrice, "Copy分支 PosiGrossPrice从前日eod复制"); + Console.WriteLine($"SPC_002: PosiQuantity={floatEod.PosiQuantity}, PosiGrossPrice={floatEod.PosiGrossPrice} ✅"); + } + + // ================================================================ + // 场景3:有平仓事件 → Update(持仓扣减) + // ================================================================ + + [TestMethod] + public void SPC_003_Update分支_有平仓事件_持仓扣减() + { + var td = CreateTrade(); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var prevEod = new List { CreateFloatEodPosition(1, 1000, 1.0020m) }; + var flowEvents = new List { CreateCloseFlowEvent(1, 400) }; + var service = new TestableSwapEodService( + new List { td }, positions, + prevEod, new List(), + new List { extend }, flowEvents); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1); + Assert.IsNotNull(floatEod); + Assert.AreEqual(600m, floatEod.PosiQuantity, "Update分支 PosiQuantity=1000-400=600"); + Assert.AreEqual(400m, floatEod.TdCloseQty, "TdCloseQty=平仓数量400"); + Console.WriteLine($"SPC_003: PosiQuantity={floatEod.PosiQuantity}, TdCloseQty={floatEod.TdCloseQty} ✅"); + } + + // ================================================================ + // 场景4:未收盘抛异常 + // ================================================================ + + [TestMethod] + public void SPC_004_未收盘_非交易首日无前日Eod_抛异常() + { + // 交易起始日早于收盘日(非交易首日),且无前日eod + var td = CreateTrade(startDate: SettleDate.AddDays(-10)); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var service = new TestableSwapEodService( + new List { td }, positions, + new List(), new List(), + new List { extend }, new List()); + + var ex = Assert.ThrowsException(() => + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate)); + Assert.IsTrue(ex.Message.Contains("未收盘"), $"异常消息应含'未收盘',实际:{ex.Message}"); + Console.WriteLine($"SPC_004: 抛异常'{ex.Message}' ✅"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs new file mode 100644 index 00000000..c8645fac --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs @@ -0,0 +1,112 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 诊断测试:验证「浮动腿 fpositions 仍用 origPositions(orig 100M)」对本 deal 的 + /// 预付金/返回预付金结果是否产生影响。结论预期:本 deal 利息腿只有 mode 9(标的期初全价) + /// 与 mode 5(初始预付金),CalcNotionalByMode 中 posiLong/posiShort 仅在「多头/空头存续名义本金」 + /// 分支被消费(L709-716),故本 deal 即便 fpositions 用 orig 100M,预付金腿结果也不受其影响。 + /// 本测试仅做诊断/验证,不改动任何生产代码;用反射调用 private CalcNotionalByMode 以直接证明 + /// “mode 9 / mode 5 的 closePrincipal 不依赖 posiLong/posiShort”。 + /// + [TestClass] + public class SwapUnwindFloatingLegDiagnosticTdd + { + private sealed class StubSwapDealService : SwapDealService + { + public StubSwapDealService(OptUserInfo optUser) : base(optUser) { } + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { rate = 0; return false; } + } + + private const decimal OrigFix = 99_000m; // 期初预付金腿初始本金 + private const decimal RealFix = 66_813.12m; // 实时预付金腿剩余本金(4 次平仓后) + private const decimal OrigLong = 100_000_000m; // 期初标的(多头)名义本金 + private const decimal RealLong = 68_947_200m; // 实时标的(多头)剩余名义本金 + private const decimal ClosePct = 0.1m; // 本次平仓比例 10% + + private static readonly DateTime D0 = new(2026, 7, 1); + private static readonly DateTime D1 = new(2026, 7, 16); + + private SwapDealService _svc; + [TestInitialize] public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindFloatingLegDiagnosticTdd), OptUserFrom.UnitTest)); + + // ---- GLMS 双轨持仓构造 ---- + private static swap_position OrigPrepay(decimal fix = OrigFix) => new swap_position + { id = 35798, SwapTradeId = 1993, PosiDirection = 0, InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = fix, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + interest_rest_days = 1, InterestDirection = (int)SwapDirectionEnum.收取, InterestSwapInterval = "[]" }; + private static swap_position RealPrepay(decimal fix = RealFix) => new swap_position + { id = 35871, SwapTradeId = 1993, PositionId = 35798, PosiDirection = 0, InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = fix, IsInitial = false, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + interest_rest_days = 1, InterestDirection = (int)SwapDirectionEnum.收取, InterestSwapInterval = "[]" }; + private static swap_position OrigBasePrice() => new swap_position + { id = 35797, SwapTradeId = 1993, PosiDirection = 0, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestPrincipalFix = 0, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + interest_rest_days = 1, InterestSwapInterval = "[]" }; + private static swap_position RealBasePrice() => new swap_position + { id = 35870, SwapTradeId = 1993, PositionId = 35797, PosiDirection = 0, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestPrincipalFix = 0, IsInitial = false, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + interest_rest_days = 1, InterestSwapInterval = "[]" }; + private static swap_position OrigLongLeg() => new swap_position + { id = 35799, SwapTradeId = 1993, PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long, InterestMode = 0, + PosiNotionalValue = OrigLong, IsInitial = true, Invalid = false }; + private static swap_position RealLongLeg() => new swap_position + { id = 35872, SwapTradeId = 1993, PositionId = 35799, PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long, InterestMode = 0, + PosiNotionalValue = RealLong, IsInitial = false, Invalid = false }; + + private static trade MakeTrade() + { + var extend = new trade_extend { TradeId = 1993, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { AnnualDays = 365, InterestCalcMode = "10", SettlementRules = 0 }) }; + return new trade { id = 1993, TradeNumber = "GLMS-20260701-0008", ClientId = 999998, TradeType = "收益互换", + TradeDate = D0, StartDate = D0, ExerciseDate = D1, TradeStatus = "确认成交", ValidState = "Valid", + StockEqvNotional = (double)RealLong, Notional = (double)RealLong, trade_extend = extend }; + } + + /// 用反射调用 private CalcNotionalByMode,直接证明各 mode 的 closePrincipal 是否依赖 posiLong/posiShort。 + private (decimal close, decimal posi, decimal pct) CallCalcNotionalByMode(swap_position position, decimal closePct, decimal posiNotional, decimal posiLong, decimal posiShort) + { + var m = typeof(SwapDealService).GetMethod("CalcNotionalByMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return ((decimal, decimal, decimal))m.Invoke(_svc, new object[] { position, closePct, posiNotional, posiLong, posiShort }); + } + + [TestMethod] + public void 诊断_mode9_标的期初全价_closePrincipal_不依赖posiLong_而用posiNotional() + { + // mode 9 分支:closePrincipal = posiNotional * closePercent + var baseP = OrigBasePrice(); + var (close, posi, _) = CallCalcNotionalByMode(baseP, ClosePct, RealLong * ClosePct, OrigLong, 0m); + Console.WriteLine($"[mode9] posiNotional={RealLong * ClosePct} posiLong(orig)={OrigLong} → closePrincipal={close}"); + Assert.AreEqual(RealLong * ClosePct * ClosePct, close, "mode9 应 = posiNotional(=real剩余*closePct) * closePct,与 posiLong(orig 100M) 无关"); + } + + [TestMethod] + public void 诊断_mode5_预付金_closePrincipal_用自身Fix_不依赖posiLong() + { + // mode 5 分支:closePrincipal = position.InterestPrincipalFix * closePercent(用 Clone 后的 real Fix) + var prepay = RealPrepay(); // Fix = RealFix(66,813.12) + var (close, posi, _) = CallCalcNotionalByMode(prepay, ClosePct, RealLong * ClosePct, OrigLong, 0m); + Console.WriteLine($"[mode5] Fix(cloned real)={RealFix} posiLong(orig)={OrigLong} → closePrincipal={close}"); + Assert.AreEqual(RealFix * ClosePct, close, "mode5 应 = 实时腿剩余本金(real Fix) * closePct,与 posiLong(orig 100M) 无关"); + Assert.AreNotEqual(OrigFix * ClosePct, close, "务必不是期初 99,000 * closePct(证明后端修复生效)"); + } + + [TestMethod] + public void 诊断_若将来有_多头存续名义本金_腿_posiLong用orig才出错_本deal无此腿_故不影响() + { + // 构造一个「多头存续名义本金」腿,证明此时 posiLong 取值(orig vs real)会直接决定结果—— + // 说明本 deal 没有这种腿,所以 fpositions 用 orig 100M 不影响;但普通收益互换若有此腿则会踩坑。 + var longLeg = new swap_position { id = 35799, InterestMode = (int)InterestModeEnum.多头存续名义本金 }; + var byOrig = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, OrigLong, 0m); // 当前代码:posiLong=orig 100M + var byReal = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, RealLong, 0m); // 若修正为 real 75.6M + Console.WriteLine($"[多头存续名义本金] orig100M→close={byOrig.close} ; real75.6M→close={byReal.close}"); + Assert.AreEqual(OrigLong * ClosePct, byOrig.close, "现状:多头存续名义本金用 orig 100M → 多次部分平仓后会偏大"); + Assert.AreEqual(RealLong * ClosePct, byReal.close, "正确应:用 real 剩余本金 75.6M"); + Assert.AreNotEqual(byOrig.close, byReal.close, "★ 潜在同类 bug:普通收益互换(含多头/空头存续名义本金腿)在多次部分平仓后,posiLong/posiShort 用 orig 会算错——本 deal 无此腿故不触发,属本轮修复范围外"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs new file mode 100644 index 00000000..e74b1274 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs @@ -0,0 +1,178 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 多次部分平仓"返回预付金"默认显示仍是初始值 bug 的回归测试(根因修复后应为全绿)。 + /// --------------------------------------------------------------- + /// 生产铁证 GLMS-20260701-0008(SwapTradeId=1993,dev DB 192.168.2.96 / glms_yltrs_ylcms): + /// 预付金腿(InterestMode=5) 双轨记录—— + /// orig 35798 (IsInitial=1, PosiDirection=0, InterestPrincipalFix=99,000) ← 期初腿,恒为初始值 + /// real 35871 (IsInitial=0, PositionId=35798, InterestPrincipalFix=66,813.12) ← 实时腿,已扣减 4 次平仓 + /// (9,900 + 15,840 + 3,663 + 2,783.88 = 32,186.88;99,000 − 32,186.88 = 66,813.12,与 dev 库实时腿完全勾稽) + /// + /// 根因:GetUnwindInterests 的利息腿迭代源取 origPositions(IsInitial=1),其预付金腿 + /// InterestPrincipalFix 恒=99,000;而"当前剩余本金"66,813.12 存在 real 腿。GetInterests 算 + /// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv 都读 position.InterestPrincipalFix, + /// 于是多次部分平仓后打开平仓页,"返回预付金"仍按初始 99,000 计算——完全不对。 + /// 首次平仓时 orig==real,掩盖了该 bug(解释"为何只修好一次部分平仓")。 + /// + /// 修复:SwapDealService.ResolveInterestLegPositions —— 迭代源仍用 origPositions(保留 + /// orig.id → eod_swap_position.PositionId 的日终匹配,全库 25,441 行 eod 均按 orig.id 归档, + /// 换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿(初始5/追加6) Clone 覆盖 + /// InterestPrincipalFix 为实时腿剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。 + /// + /// 覆盖盲区说明:既有 SwapUnwindPrepayPrincipalBugTdd 的 19 个用例全部直接调 GetInterests + /// 并只喂一条 IsInitial=true 的持仓,完全绕过 GetUnwindInterests 的 orig-vs-real 选择逻辑, + /// 测不到本次 bug。本类直接单测抽出的纯函数 ResolveInterestLegPositions 以锁定该契约。 + /// + [TestClass] + public class SwapUnwindPrepayOrigVsRealBugTdd + { + // 生产 GLMS-20260701-0008 精确值 + private const long OrigId = 35798; + private const long RealId = 35871; + private const decimal InitialFix = 99_000m; // orig 腿初始本金 + private const decimal RemainingFix = 66_813.12m; // real 腿剩余本金(已扣减 4 次平仓 9,900+15,840+3,663+2,783.88=32,186.88) + + private static swap_position OrigPrepay(decimal fix = InitialFix, int mode = (int)InterestModeEnum.初始预付金) + => new swap_position + { + id = OrigId, + SwapTradeId = 1993, + PosiDirection = 0, // 利息端(收/支) + InterestMode = mode, + InterestPrincipalFix = fix, + IsInitial = true, + Invalid = false + }; + + private static swap_position RealPrepay(long positionId = OrigId, decimal fix = RemainingFix, int mode = (int)InterestModeEnum.初始预付金) + => new swap_position + { + id = RealId, + SwapTradeId = 1993, + PositionId = positionId, // 指向对应 orig 的 id + PosiDirection = 0, + InterestMode = mode, + InterestPrincipalFix = fix, + IsInitial = false, + Invalid = false + }; + + [TestMethod] + public void 多次部分平仓后_预付金腿本金应取实时腿剩余本金_而非原始腿初始值() + { + var origs = new List { OrigPrepay() }; + var reals = new List { RealPrepay() }; + + var result = SwapDealService.ResolveInterestLegPositions(origs, reals); + + Assert.AreEqual(1, result.Count, "应保留 1 条利息腿"); + Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix, + "多次部分平仓后:预付金腿本金应=实时腿剩余本金 66,813.12,而非原始腿初始值 99,000(bug 症状)"); + // 必须是 Clone,不能污染原始腿(原始腿要保留 99,000 供其他路径/审计) + Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix, + "修复必须走 Clone,绝不能就地改写 origPositions 的初始本金"); + } + + [TestMethod] + public void 首次平仓_实时腿等于原始腿_返回原始腿本身_零改动() + { + var origs = new List { OrigPrepay(InitialFix) }; + var reals = new List { RealPrepay(fix: InitialFix) }; // 尚未平仓,real==orig + + var result = SwapDealService.ResolveInterestLegPositions(origs, reals); + + Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "首次平仓 orig==real,本金保持初始值"); + Assert.AreSame(origs[0], result[0], "orig==real 时不应克隆,直接返回原始腿本身(行为与修复前一致)"); + } + + [TestMethod] + public void 追加预付金腿_同样取实时腿剩余本金() + { + var origs = new List { OrigPrepay(InitialFix, (int)InterestModeEnum.追加预付金) }; + var reals = new List { RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.追加预付金) }; + + var result = SwapDealService.ResolveInterestLegPositions(origs, reals); + + Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix, + "追加预付金(mode=6)与初始预付金(mode=5)同源修复,同样取实时腿剩余本金"); + } + + [TestMethod] + public void 非预付金腿_不受影响_始终保持原始腿本金() + { + // 标的期初全价(=9)等非预付金腿:即便 real 腿本金不同也不应被覆盖(其本金语义不同,不走此纠正) + var orig = OrigPrepay(InitialFix, (int)InterestModeEnum.标的期初全价); + var real = RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.标的期初全价); + var result = SwapDealService.ResolveInterestLegPositions( + new List { orig }, new List { real }); + + Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "非预付金腿本金不被实时腿覆盖"); + Assert.AreSame(orig, result[0], "非预付金腿应原样返回,不克隆"); + } + + [TestMethod] + public void 无匹配实时腿_返回原始腿() + { + // real 腿 PositionId 指向别的 orig(或根本没有实时腿)→ 找不到匹配,保持原始腿 + var origs = new List { OrigPrepay() }; + var mismatched = new List { RealPrepay(positionId: 99999) }; + + var r1 = SwapDealService.ResolveInterestLegPositions(origs, mismatched); + Assert.AreEqual(InitialFix, r1[0].InterestPrincipalFix, "无匹配实时腿:保持原始腿初始本金"); + + var r2 = SwapDealService.ResolveInterestLegPositions(origs, new List()); + Assert.AreEqual(InitialFix, r2[0].InterestPrincipalFix, "实时腿为空:保持原始腿初始本金"); + + var r3 = SwapDealService.ResolveInterestLegPositions(origs, null); + Assert.AreEqual(InitialFix, r3[0].InterestPrincipalFix, "实时腿为 null:应容错并保持原始腿初始本金"); + } + + [TestMethod] + public void 只保留利息腿_过滤掉标的腿() + { + // PosiDirection>0 的标的腿不属于利息端,应被过滤(与原实现 Where(PosiDirection==0) 一致) + var underlyingLeg = new swap_position + { + id = 40000, SwapTradeId = 1993, PosiDirection = 1, + InterestMode = (int)InterestModeEnum.标的期初全价, IsInitial = true, Invalid = false + }; + var origs = new List { OrigPrepay(), underlyingLeg }; + var reals = new List { RealPrepay() }; + + var result = SwapDealService.ResolveInterestLegPositions(origs, reals); + + Assert.AreEqual(1, result.Count, "只应保留利息腿(PosiDirection==0),标的腿被过滤"); + Assert.AreEqual(OrigId, result[0].id, "保留的应是预付金利息腿"); + Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix, "且其本金已对齐实时剩余本金"); + } + + /// + /// 生产 Live Snapshot(2026-07-16 11:00,dev DB 192.168.2.96 / glms_yltrs_ylcms 直连核实): + /// GLMS-20260701-0008 已 4 次部分平仓。预付金腿(orig 35798 / real 35871) 实际值—— + /// orig InterestPrincipalFix = 99,000(期初腿恒为初始值) + /// real InterestPrincipalFix = 66,813.12(= 99,000 − 9,900 − 15,840 − 3,663 − 2,783.88) + /// swap_flow_event 4 次平仓返还:9,900 / 15,840 / 3,663 / 2,783.88,合计 32,186.88。 + /// 本用例把这份真实数据硬编码进来,断言修复后取实时腿剩余本金 66,813.12(非 99,000), + /// 作为该 deal 在此快照点的忠实回归;日后该 deal 再被平仓,剩余本金会变,本例仍应同步更新。 + /// + [TestMethod] + public void GLMS20260701_四次部分平仓_LiveSnapshot_预付金腿应取实时腿剩余66813_12() + { + // 与生产一致的双轨数据:期初腿 99,000 / 实时腿 4 次平仓后 66,813.12 + var origs = new List { OrigPrepay(InitialFix) }; + var reals = new List { RealPrepay(fix: 66_813.12m) }; + + var result = SwapDealService.ResolveInterestLegPositions(origs, reals); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(66_813.12m, result[0].InterestPrincipalFix, + "4 次部分平仓后:预付金腿本金应=实时腿剩余本金 66,813.12,而非原始腿初始值 99,000"); + // 不污染原始腿 + Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix, "修复必须走 Clone,不能改写 origPositions 的初始本金 99,000"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs new file mode 100644 index 00000000..e1124ec7 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs @@ -0,0 +1,567 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 预付金(保证金)腿 平仓"应返还本金" bug 的回归测试(根因修复后应为全绿)。 + /// --------------------------------------------------------------- + /// 业务预期:平仓"应返还本金"(swap_flow_event.InterestPrincipal) 应等于该预付金腿的 + /// 保证金本金(InterestPrincipalFix * closePercent),且与逐日利息计算无关; + /// 同时预付金腿的逐日利息计息基数也应基于"保证金本金"自身,而非整笔交易的名义本金。 + /// + /// 根因:GetUnwindInterests 对全部腿统一用 orginPv = lastEod.NotionalValue ?? stockEqvNotional(整笔交易名义本金), + /// 缺了"预付金腿用自身保证金"的分支;公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv + /// 把交易名义本金(千万~亿级)当减项扣掉,使 InterestPrincipal 与计息基数变成巨负值。 + /// + /// 根因修复(SwapDealService.InitSwapDealInterest):对预付金腿(初始/追加)在利息计算前把 + /// orginPv 对齐为 position.InterestPrincipalFix,与日终路径(SwapEodPositionService)一致。 + /// 仅作用于 InterestMode 5/6;债券本金腿(标的期初全价=9)等仍用交易名义本金,不受影响。 + /// + /// 设计:标的名义本金 100万、预付金(保证金)本金 10万(维度不同,放大错配); + /// 另含客户截图级 / 真实库 Trade1813 的精确复现用例。 + /// + [TestClass] + public class SwapUnwindPrepayPrincipalBugTdd + { + private sealed class StubSwapDealService : SwapDealService + { + public StubSwapDealService(OptUserInfo optUser) : base(optUser) { } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + rate = 0; + return false; // 预付金腿无浮动标的,不查库 + } + } + + private const decimal UnderlyingNotional = 1_000_000m; // 标的名义本金(股票维度) + private const decimal PrepayPrincipal = 100_000m; // 预付金/保证金本金(预付金维度) + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 4, 27); + private static readonly DateTime ExerciseDate = new(2027, 4, 27); + private static readonly DateTime UnwindDate = new(2026, 4, 28); + + private SwapDealService _svc; + + [TestInitialize] + public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindPrepayPrincipalBugTdd), OptUserFrom.UnitTest)); + + private static trade MakeTrade(decimal notional = UnderlyingNotional) + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", // 算头不算尾 + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, TradeNumber = "UT-PREPAY-TDD", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", + StockEqvNotional = (double)notional, Notional = (double)notional, + trade_extend = extend + }; + } + + private static swap_position MakePrepayPosition(decimal fix = PrepayPrincipal, decimal rate = 0.01m) + { + return new swap_position + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestRateDefault = rate, InterestPrincipalFix = fix, + PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, interest_rest_days = 1, + interest_rule = 0, FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + } + + private swap_flow_event CalcUnwind(decimal closePercent, List eodPositions) + { + eodPositions ??= new List(); + var td = MakeTrade(); + var position = MakePrepayPosition(); + var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, new List { position }, + UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, closePercent, + (int)SwapEventTypeEnum.平仓, + false, false, 0, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event"); + return interests[0]; + } + + /// + /// 客户/真实库场景:自定义 标的名义本金(notional) 与 保证金本金(fix)。 + /// orginPv 用 notional(与 GetUnwindInterests 行为一致:lastEod.NotionalValue ?? stockEqvNotional)。 + /// + private swap_flow_event CalcUnwindWith(decimal closePercent, List eodPositions, decimal notional, decimal fix, decimal rate = 0.01m) + { + eodPositions ??= new List(); + var td = MakeTrade(notional); + var position = MakePrepayPosition(fix, rate); + var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, new List { position }, + notional, notional, notional, notional, closePercent, + (int)SwapEventTypeEnum.平仓, + false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event"); + return interests[0]; + } + + [TestMethod] + public void 无历史归档_全平_应返还本金应等于保证金本金() + { + var fe = CalcUnwind(1m, null); // 无 eod 归档 → preEod.id==0 + Console.WriteLine($"[TDD] 无归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})"); + Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal, + "无归档全平: InterestPrincipal(应返还本金) 应=保证金本金(预付金本金),不应被利息公式改写为含 -orginPv 与 double 的怪值"); + } + + [TestMethod] + public void 有历史归档_全平_应返还本金应等于保证金本金() + { + var eod = new List + { + new eod_swap_position + { + id = 1, SwapTradeId = 1, PositionId = 1001, + ValueDate = new DateTime(2026, 4, 27), + TdInterestPrincipal = PrepayPrincipal, + PosiNotionalValue = PrepayPrincipal, + InterestProfitSum = 0m + } + }; + var fe = CalcUnwind(1m, eod); + Console.WriteLine($"[TDD] 有归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})"); + Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal, + "有归档全平: 计息区间被跳过,InterestPrincipal 应保持初始正确值=保证金本金"); + } + + // ---- 客户截图级 / 真实库场景(验证"前后是否真 Fix")---- + + [TestMethod] + public void 客户截图级_全平_应返还本金应等于保证金本金() + { + // 生产铁证(用户提供真实交易):TradeAmount=3亿,StockEqvNotional=306,191,860.26, + // StructureType=普通债券类收益互换;预付金腿 swap_position id=34009 InterestMode=5 + // InterestPrincipalFix=9,185,755.81。 + // swap_flow_event(该腿, mode5) 三条: + // 9202 EventId=null dir2 IP=9,185,755.81 (建仓支付预付金 ✓) + // 9489 EventId=15997 dir1 IP=-287,820,348.64 (平仓, 盘中路径 BUG ✗) + // 9492 EventId=15998 dir1 IP=9,185,755.81 (平仓, EOD正确路径 ✓) + // 同一腿出现"盘中错 / EOD对"两条平仓记录,恰好佐证修复方向(盘中 orginPv 对齐 EOD=Fix)正确。 + // 根因复现:2*Fix - Notional = 2*9,185,755.81 - 306,191,860.26 = -287,820,348.64(与生产 15997 精确 0 误差)。 + // 该预付金腿三条 event 的 InterestAmount 全=0(债券类预付金腿不计息), + // 故本笔生产仅 InterestPrincipal 中招、计息基数未受影响 → rate=0 贴合生产。 + const decimal notional = 306_191_860.26m; + const decimal fix = 9_185_755.81m; + var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m); + Console.WriteLine($"[TDD][客户] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})"); + Assert.AreEqual(fix, fe.InterestPrincipal, + "客户级: 应返还本金应=保证金本金 9,185,755.81,不应被算成 -287,820,348.64"); + Assert.AreEqual(0m, fe.InterestAmount, + "客户级: 该预付金腿不计息,InterestAmount 应=0(与生产三条 event 全为 0 一致);仅 InterestPrincipal 中招"); + } + + [TestMethod] + public void 真实库Trade1813_全平_应返还本金应等于保证金本金() + { + // 测试库 Trade=1813 / Pos=34204:Fix=35,140,Notional=12,100,000, + // 实际存储 InterestPrincipal=-12,029,720.00(=2*35,140-12,100,000,公式精确 0 误差)。 + // 同属债券类预付金腿(与生产同模式,不计息),rate=0 贴合生产,仅验证 InterestPrincipal 修复。 + const decimal notional = 12_100_000m; + const decimal fix = 35_140m; + var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m); + Console.WriteLine($"[TDD][Trade1813] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})"); + Assert.AreEqual(fix, fe.InterestPrincipal, + "Trade1813: 应返还本金应=保证金本金 35,140,不应被算成 -12,029,720.00"); + Assert.AreEqual(0m, fe.InterestAmount, + "Trade1813: 同属债券类预付金腿不计息,InterestAmount 应=0;仅 InterestPrincipal 中招"); + } + + // ---- 多次部分平仓(验证最小修复是否覆盖"多次部分成交")---- + + [TestMethod] + public void 多次部分平仓_显示值每次返回比例份额且总计等于保证金() + { + // 模拟分 3 次平仓:0.3 / 0.5 / 1.0(剩余)。每次传入的 fix = 该次剩余保证金本金 + // (真实系统中每次部分平仓后 position.InterestPrincipalFix 会被扣减,下一笔用剩余值)。 + // 根因修复后:InterestPrincipal 由利息公式基于 Fix 正确得出 = fix * closePercent。 + decimal total = 0; + var r1 = CalcUnwindWith(0.3m, null, 306_191_860.26m, 100_000m); + total += r1.InterestPrincipal; + var r2 = CalcUnwindWith(0.5m, null, 306_191_860.26m, 70_000m); // 剩余 7万 + total += r2.InterestPrincipal; + var r3 = CalcUnwindWith(1.0m, null, 306_191_860.26m, 35_000m); // 剩余 3.5万 + total += r3.InterestPrincipal; + + Console.WriteLine($"[TDD][多次部分] r1={r1.InterestPrincipal} r2={r2.InterestPrincipal} r3={r3.InterestPrincipal} 合计={total}"); + Assert.AreEqual(30_000m, r1.InterestPrincipal, "第1次(30%)应返还 3万"); + Assert.AreEqual(35_000m, r2.InterestPrincipal, "第2次(50% of 剩余7万)应返还 3.5万"); + Assert.AreEqual(35_000m, r3.InterestPrincipal, "第3次(剩余全平)应返还 3.5万"); + Assert.AreEqual(100_000m, total, "多次部分平仓合计应=保证金本金 10万"); + } + + // ---- 盘中路径 CalcDailySimpleInterest 的 closePercent^N 指数级缩小 bug ---- + // 生产铁证 GLMS-20260701-0006:预付金腿 Fix=9,180,000、interest_rest_days=7、单利、不计息。 + // 平仓弹窗(swaptrade2/GetUnwindInterestList → 盘中路径 CalcDailySimpleInterest)返回: + // 100% → 9,180,000 (对) 50% → 71,718.75 (错) 10% → 0.918 (错) + // 数学关系精确成立:9,180,000×0.5^7 = 71,718.75、9,180,000×0.1^7 = 0.918。 + // 根因:CalcDailySimpleInterest 非重置日 else 分支 + // flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent; + // tdDynomicPrincipal = flowEvent.InterestPrincipal; // ★把"已×closePercent"的值回填 + // 使下一个非重置日再乘一次 closePercent → InterestPrincipal = Fix × closePercent^N(N=计息天数), + // 而正确应为 Fix × closePercent(线性,与日终 CalcDailySimpleInterestByEod:1164-1165 只乘一次一致)。 + // 现有 6 个用例 interest_rest_days=1 且 UnwindDate=StartDate+1(calcDays=1),循环首尾都被 continue 跳过、 + // 从不进 else,故漏掉此 bug;本组用例用 restDays=7、跨多日、带 eod 归档触发 else 累积复现之。 + private const decimal ProdPrepayFix = 9_180_000m; + private static readonly DateTime ProdPosiStart = new(2026, 7, 2); + private static readonly DateTime ProdEodValueDate = new(2026, 7, 4); + private static readonly DateTime ProdUnwindDate = new(2026, 7, 13); + + /// + /// 盘中路径复现:restDays=7、PosiStart→Unwind 跨 11 天、eod 归档到 07-04。 + /// 与生产 GLMS-20260701-0006 完全对齐,buggy 代码产出 Fix × closePercent^7。 + /// + private swap_flow_event CalcUnwindMultiDay(decimal closePercent, decimal fix = ProdPrepayFix, int restDays = 7, + decimal rate = 0m) + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", // 算头不算尾(与生产一致) + SettlementRules = 0 + }) + }; + var td = new trade + { + id = 1, TradeNumber = "UT-PREPAY-EXP", ClientId = 999998, + TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart, + ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid", + StockEqvNotional = (double)fix, Notional = (double)fix, + trade_extend = extend + }; + var position = new swap_position + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestRateDefault = rate, InterestPrincipalFix = fix, + PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1), + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, interest_rest_days = restDays, + interest_rule = 0, FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + var eod = new List + { + new eod_swap_position + { + id = 7, SwapTradeId = 1, PositionId = 1001, + ValueDate = ProdEodValueDate, + TdInterestPrincipal = fix, // 生产 eod_swap_position(35774) TdInterestPrincipal=9,180,000 + PosiNotionalValue = fix, + InterestProfitSum = 0m, FloatRate = 0m + } + }; + var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate, + eod, new List { position }, + fix, fix, fix, fix, closePercent, + (int)SwapEventTypeEnum.平仓, + false, false, 0, fix, false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event"); + return interests[0]; + } + + [TestMethod] + public void 部分平仓50_盘中重置周期7天_应返还本金应线性缩放而非指数级() + { + var fe = CalcUnwindMultiDay(0.5m); + Console.WriteLine($"[TDD][盘中50%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=71,718.75, 期望=4,590,000)"); + // 正确:Fix × closePercent = 9,180,000 × 0.5 = 4,590,000(100%返 9,180,000 的一半)。 + // buggy:Fix × 0.5^7 = 71,718.75(生产实测),被指数级缩小 ~64 倍。 + Assert.AreEqual(4_590_000m, fe.InterestPrincipal, + "50% 平仓: 应返还本金应=Fix×0.5=4,590,000,不应被 closePercent^7 缩成 71,718.75"); + } + + [TestMethod] + public void 部分平仓10_盘中重置周期7天_应返还本金应线性缩放而非指数级() + { + var fe = CalcUnwindMultiDay(0.1m); + Console.WriteLine($"[TDD][盘中10%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=0.918, 期望=918,000)"); + // 正确:Fix × 0.1 = 918,000。buggy:Fix × 0.1^7 = 0.918(生产实测),缩小 100 万倍。 + Assert.AreEqual(918_000m, fe.InterestPrincipal, + "10% 平仓: 应返还本金应=Fix×0.1=918,000,不应被 closePercent^7 缩成 0.918"); + } + + [TestMethod] + public void 全平_盘中重置周期7天_应返还本金应等于保证金本金() + { + // closePercent=1 → 1^N=1,指数 bug 对 100% 无影响(故用户看 100% 正常),此用例锚定不回归。 + var fe = CalcUnwindMultiDay(1m); + Console.WriteLine($"[TDD][盘中100%] 实测 InterestPrincipal={fe.InterestPrincipal} (期望=9,180,000)"); + Assert.AreEqual(ProdPrepayFix, fe.InterestPrincipal, + "100% 平仓: 应返还本金应=Fix=9,180,000(closePercent=1 时指数 bug 不显现,须保持正确)"); + } + + // ---- 非预付金腿(标的期初全价=9)同样验证:证明修复对所有"单利盘中"腿通用且正确 ---- + // CalcDailySimpleInterest 是所有单利腿(InterestType=0)的盘中计息通用函数,非预付金专用。 + // 用户关切:修复会否波及非预付金腿?结论—— + // · closePercent=1(日常计息/全平)时 1^N=1=1^1,修复前后逐位恒等,零影响; + // · closePercent<1(部分平仓)时,所有单利腿此前都被同一 bug 指数级缩小,修复后统一为 + // 正确的线性缩放(平仓 X% => 本金×X),这是修正而非破坏。 + // 本组用非预付金腿(标的期初全价=9,orginPv 不被对齐为 Fix、走交易名义本金)独立复现并锁定。 + private const decimal NonPrepayNotional = 1_000_000m; + + private swap_flow_event CalcUnwindMultiDayNonPrepay(decimal closePercent, decimal notional = NonPrepayNotional, + int restDays = 7, decimal rate = 0m) + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", // 算头不算尾(与生产一致) + SettlementRules = 0 + }) + }; + var td = new trade + { + id = 1, TradeNumber = "UT-NONPREPAY-EXP", ClientId = 999998, + TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart, + ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid", + StockEqvNotional = (double)notional, Notional = (double)notional, + trade_extend = extend + }; + var position = new swap_position + { + id = 2002, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, // 非预付金腿(=9):orginPv 不会被对齐为 Fix + InterestRateDefault = rate, InterestPrincipalFix = 0m, + PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1), + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, interest_rest_days = restDays, + interest_rule = 0, FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + var eod = new List + { + new eod_swap_position + { + id = 8, SwapTradeId = 1, PositionId = 2002, + ValueDate = ProdEodValueDate, + TdInterestPrincipal = notional, // 计息基数=名义本金 → dynomicPrincipal = eodTd + notional - orginPv = notional + PosiNotionalValue = notional, + InterestProfitSum = 0m, FloatRate = 0m + } + }; + // orginPv 传 notional:非预付金腿不走 877-881 的 Fix 对齐,dynomicPrincipal = notional + notional - notional = notional + var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate, + eod, new List { position }, + notional, notional, notional, notional * closePercent, closePercent, + (int)SwapEventTypeEnum.平仓, + false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count, "非预付金腿应生成 1 条 flow_event"); + return interests[0]; + } + + [TestMethod] + public void 非预付金腿_部分平仓50_盘中重置周期7天_应线性缩放不受指数bug影响() + { + var fe = CalcUnwindMultiDayNonPrepay(0.5m); + Console.WriteLine($"[TDD][非预付金50%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=7,812.5, 期望=500,000)"); + // 正确:N×0.5=500,000。buggy:N×0.5^7=7,812.5(同一指数 bug,证明非预付金腿此前也中招)。 + Assert.AreEqual(500_000m, fe.InterestPrincipal, + "非预付金腿(标的期初全价) 50% 平仓应=名义本金×0.5=500,000,不应被 closePercent^7 缩小"); + } + + [TestMethod] + public void 非预付金腿_部分平仓10_盘中重置周期7天_应线性缩放不受指数bug影响() + { + var fe = CalcUnwindMultiDayNonPrepay(0.1m); + Console.WriteLine($"[TDD][非预付金10%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=0.1, 期望=100,000)"); + Assert.AreEqual(100_000m, fe.InterestPrincipal, + "非预付金腿(标的期初全价) 10% 平仓应=名义本金×0.1=100,000,不应被 closePercent^7 缩小"); + } + + [TestMethod] + public void 非预付金腿_全平_修复前后恒等_零影响() + { + // closePercent=1 时 1^N=1=1^1:这是"修复不波及非平仓/全平计息"的数学不变量证明。 + var fe = CalcUnwindMultiDayNonPrepay(1m); + Console.WriteLine($"[TDD][非预付金100%] 实测 InterestPrincipal={fe.InterestPrincipal} (期望=1,000,000)"); + Assert.AreEqual(NonPrepayNotional, fe.InterestPrincipal, + "非预付金腿 全平应=名义本金(closePercent=1 时修复前后恒等,日常计息/全平零影响)"); + } + + [TestMethod] + public void 多次部分平仓_计息基数也被根因修复_利息基于保证金本金() + { + // 显式带息加固用例(合成,非用户那笔生产的真实症状): + // 用户那笔生产(3亿债券类TRS)预付金腿不计息(InterestAmount 全=0),仅 InterestPrincipal 中招; + // 本例用 rate=0.01 构造"若该腿计息"的场景,验证根因修复后计息基数也基于保证金本金自身 + // (而非交易名义本金):InterestAmount 为小额正、且 < fix。 + const decimal notional = 306_191_860.26m; + const decimal fix = 9_185_755.81m; + var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0.01m); + + Assert.AreEqual(fix, fe.InterestPrincipal, "显示值(应返还本金)已=保证金本金"); + Console.WriteLine($"[TDD][计息基数] InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount}"); + Assert.IsTrue(fe.InterestAmount > 0, + "根因修复后(显式带息): 预付金腿 InterestAmount 应基于保证金本金算出小额正值(约 fix*rate),不再是巨负"); + Assert.IsTrue(fe.InterestAmount < fix, + "利息基数必须为保证金维度(远小于 fix),证明 orginPv 已用预付金自身 Fix,而非交易名义本金 notional"); + } + + // ===== 覆盖完整性补强:所有单利腿模式 + 日终路径 ===== + // 调用链事实(已用代码确认): + // CalcDailySimpleInterest 的唯一真实调用链 = GetInterests(settment=false) → CalcUnwindInterest → 本函数。 + // 日终(settment=true)走 CalcEodInterest → CalcDailySimpleInterestByEod(closePercent 硬编码 1m、 + // 且该函数从不改写 InterestPrincipal),根本不调用本函数。故"含日终"的正确命题是: + // 日终不受本 bug 影响,且应有用例锁定这一不变量。 + // 本组用同一入口驱动各 InterestMode 在 closePercent<1 + rest_days=7 多天场景,断言 + // InterestPrincipal = closePrincipal(线性),捕捉任何指数级回归;并显式加日终(settment=true)用例, + // 断言日终结果恒为线性 closePrincipal(证明日终不受盘中 bug 影响,与正确的 ByEod 变体对齐)。 + private swap_flow_event CalcByMode(int mode, decimal baseP, decimal closePercent, int restDays = 7, bool eodPath = false) + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", // 算头不算尾(与生产一致) + SettlementRules = 0 + }) + }; + var td = new trade + { + id = 1, TradeNumber = "UT-MODE-COV", ClientId = 999998, + TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart, + ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid", + StockEqvNotional = (double)baseP, Notional = (double)baseP, + trade_extend = extend + }; + bool isPrepayOrFixed = mode == (int)InterestModeEnum.初始预付金 + || mode == (int)InterestModeEnum.追加预付金 + || mode == (int)InterestModeEnum.固定值; + var position = new swap_position + { + id = 3003, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = mode, + InterestRateDefault = 0m, + InterestPrincipalFix = isPrepayOrFixed ? baseP : 0m, + PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1), + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, interest_rest_days = restDays, + interest_rule = 0, FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + // 使 dynomicPrincipal = posiPrincipal:eod.TdInterestPrincipal = orginPv(=baseP), + // 非预付金腿 orginPv 传 baseP;预付金/固定值腿 orginPv 被内部对齐为 Fix=baseP(同样成立)。 + var eodPos = new List + { + new eod_swap_position + { + id = 30, SwapTradeId = 1, PositionId = 3003, + ValueDate = ProdEodValueDate, + TdInterestPrincipal = baseP, + PosiNotionalValue = baseP, + InterestProfitSum = 0m, FloatRate = 0m + } + }; + var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate, + eodPos, new List { position }, + baseP, baseP, baseP, baseP * closePercent, closePercent, + (int)SwapEventTypeEnum.平仓, + false, false, 0, baseP, false, settment: eodPath, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count, $"mode={mode} 应生成 1 条 flow_event"); + return interests[0]; + } + + // ---- 追加预付金(6):与初始预付金(5)同源修复,显式覆盖避免遗漏 ---- + [TestMethod] + public void 追加预付金腿_盘中_部分平仓重置周期7天_应线性缩放() + { + var fe = CalcByMode((int)InterestModeEnum.追加预付金, ProdPrepayFix, 0.5m); + Assert.AreEqual(4_590_000m, fe.InterestPrincipal, "追加预付金 50% 应=Fix×0.5(与初始预付金同源修复)"); + var fe1 = CalcByMode((int)InterestModeEnum.追加预付金, ProdPrepayFix, 0.1m); + Assert.AreEqual(918_000m, fe1.InterestPrincipal, "追加预付金 10% 应=Fix×0.1"); + } + + // ---- 多头/空头存续名义本金(7/8):经同一 CalcDailySimpleInterest,需证明修复通用 ---- + [TestMethod] + public void 多头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放() + { + const decimal baseP = 2_000_000m; + var fe = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.5m); + Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "多头存续 50% 应=posiLong×0.5"); + var fe1 = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.1m); + Assert.AreEqual(200_000m, fe1.InterestPrincipal, "多头存续 10% 应=posiLong×0.1"); + } + + [TestMethod] + public void 空头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放() + { + const decimal baseP = 2_000_000m; + var fe = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.5m); + Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "空头存续 50% 应=posiShort×0.5"); + var fe1 = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.1m); + Assert.AreEqual(200_000m, fe1.InterestPrincipal, "空头存续 10% 应=posiShort×0.1"); + } + + // ---- 合约名义本金规模(2):CalcNotionalByMode 默认分支(posiNotional×cp) ---- + [TestMethod] + public void 合约名义本金规模腿_盘中_部分平仓重置周期7天_应线性缩放() + { + const decimal baseP = 2_000_000m; + var fe = CalcByMode((int)InterestModeEnum.合约名义本金规模, baseP, 0.5m); + Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "合约名义本金规模 50% 应=posiNotional×0.5"); + } + + // ---- 固定值(1):CalcNotionalByMode 强制 newClosePercent=1,对 closePercent 免疫(输入 0.5 也不缩放) ---- + [TestMethod] + public void 固定值腿_盘中_部分平仓_对平仓比例免疫_返回Fix本金() + { + const decimal baseP = 2_000_000m; + var fe = CalcByMode((int)InterestModeEnum.固定值, baseP, 0.5m); + Assert.AreEqual(baseP, fe.InterestPrincipal, "固定值腿 newClosePercent=1,InterestPrincipal 恒=Fix,不随平仓比例缩放"); + } + + // ---- 日终路径(settment=true):证明走 CalcDailySimpleInterestByEod,结果恒为线性 closePrincipal,不受盘中 bug 影响 ---- + [TestMethod] + public void 日终_预付金腿_部分平仓_结果应线性且不受盘中bug影响() + { + var fe = CalcByMode((int)InterestModeEnum.初始预付金, ProdPrepayFix, 0.5m, eodPath: true); + Console.WriteLine($"[TDD][EOD 预付金50%] InterestPrincipal={fe.InterestPrincipal} (期望={4_590_000m})"); + Assert.AreEqual(4_590_000m, fe.InterestPrincipal, "日终预付金 50% 应=Fix×0.5(ByEod 正确变体,closePercent 走 closePrincipal 线性)"); + var fe1 = CalcByMode((int)InterestModeEnum.初始预付金, ProdPrepayFix, 0.1m, eodPath: true); + Assert.AreEqual(918_000m, fe1.InterestPrincipal, "日终预付金 10% 应=Fix×0.1"); + } + + [TestMethod] + public void 日终_非预付金腿_部分平仓_结果应线性且不受盘中bug影响() + { + const decimal baseP = 2_000_000m; + var fe = CalcByMode((int)InterestModeEnum.标的期初全价, baseP, 0.5m, eodPath: true); + Console.WriteLine($"[TDD][EOD 标的期初全价50%] InterestPrincipal={fe.InterestPrincipal} (期望={1_000_000m})"); + Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "日终非预付金腿 50% 应=名义本金×0.5(ByEod 正确,不受影响)"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs new file mode 100644 index 00000000..7d1dfc27 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs @@ -0,0 +1,241 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 互换平仓全流程测试(SwapUnwind/ApproveSwapTrade/ApplySwapTrade/DealFloatPosition) + /// ============================================================================ + /// 借鉴 testable 分支 SwapUnwindScenarioTest,基于当前分支 TestableSwapDealService 共享 stub。 + /// 命名规范说明(见《互换价格字段命名规范决策文档》): + /// PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice + /// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice + /// ============================================================================ + [TestClass] + public class SwapUnwindScenarioTest + { + // ================================================================ + // 场景1:SwapUnwind 全平仓 —— 持仓归零、TradeStatus=已平仓 + // ================================================================ + + [TestMethod] + public void UW_001_SwapUnwind_全平仓_持仓归零且资金流水正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 5000m, swapMarginAmount: 0m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水"); + Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); + Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); + Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓"); + Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind"); + Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0"); + Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0"); + Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)"); + Console.WriteLine($"UW_001: TradeStatus={td.TradeStatus}, StockEqvNotional={td.StockEqvNotional} ✅"); + } + + // ================================================================ + // 场景2:SwapUnwind 部分平仓 —— HasPartialUnWind=1,TradeStatus 不变 + // ================================================================ + + [TestMethod] + public void UW_002_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 3000m, swapMarginAmount: 0m, + closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m, + closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1"); + Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); + Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000"); + Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000"); + Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水"); + Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); + Console.WriteLine($"UW_002: HasPartialUnWind={td.HasPartialUnWind}, TradeStatus={td.TradeStatus} ✅"); + } + + // ================================================================ + // 场景3:SwapUnwind 含预付金 —— 两条资金流水 + // ================================================================ + + [TestMethod] + public void UW_003_SwapUnwind_含预付金_两条资金流水() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 5000m, swapMarginAmount: 2000m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水"); + Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费"); + Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); + Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金"); + Assert.AreEqual(ClientCashInCashOut.系统操作_应付预付金, service.ClientCashCalls[1].action); + Console.WriteLine($"UW_003: 平仓费={service.ClientCashCalls[0].amount}, 应付预付金={service.ClientCashCalls[1].amount} ✅"); + } + + // ================================================================ + // 场景4:DealFloatPosition 含费价重算(后端唯二真做计算的地方) + // ================================================================ + + /// + /// 平仓事件重算三字段(SwapDealService DealFloatPosition): + /// TradingAmountFeeAvg(ExitDirtyFeePrice) = TradingAmountAvg(ExitDirtyPrice) + Fee/CloseQty × shortRatio + /// TradingAmountNetFeeAvg(ExitCleanFeePrice) = TradingAmountNetAvg(ExitCleanPrice) + Fee/CloseQty × shortRatio + /// TradingAmount = TradingAmountAvg × CloseQty + /// 手算:ExitDirtyPrice=1.02, Fee=50, CloseQty=1000, Long(shortRatio=-1) + /// ExitDirtyFeePrice = 1.02 + 50/1000×(-1) = 0.97 + /// ExitCleanFeePrice = 1.00 + 50/1000×(-1) = 0.95 + /// TradingAmount = 1.02 × 1000 = 1020 + /// + [TestMethod] + public void UW_004_DealFloatPosition_含费价重算正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var closeEvent = new swap_flow_event + { + EventType = (int)SwapEventTypeEnum.平仓, + PositionType = (int)PositionTypeFlag.Long, + TradingAmountAvg = 1.02m, // ExitDirtyPrice + TradingAmountNetAvg = 1.00m, // ExitCleanPrice + TradingFeePending = 50m, + }; + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m); + unwindData.FlowEvents.Add(closeEvent); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m, + $"TradingAmountFeeAvg(ExitDirtyFeePrice)=ExitDirtyPrice+Fee/Qty×(-1)=0.97"); + Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m, + $"TradingAmountNetFeeAvg(ExitCleanFeePrice)=ExitCleanPrice+Fee/Qty×(-1)=0.95"); + Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m, + $"TradingAmount=ExitDirtyPrice×CloseQty=1020"); + Console.WriteLine($"UW_004: ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg}, TradingAmount={closeEvent.TradingAmount} ✅"); + } + + // ================================================================ + // 场景5:ApproveSwapTrade 审核通过全平仓 —— 反序列化事件并记账 + // ================================================================ + + [TestMethod] + public void UW_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 8000m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m); + var swapEvent = new swap_event + { + id = 1, SwapTradeId = SwapDealTestFactory.SwapTradeId, + EventType = (int)SwapEventTypeEnum.平仓, Invalid = false, + EventData = JsonConvert.SerializeObject(unwindData) + }; + var flowEvents = new Dictionary> + { + [1] = new List { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } } + }; + var service = new TestableSwapDealService(td, + swapEvents: new Dictionary { [(int)SwapEventTypeEnum.平仓] = swapEvent }, + flowEventsByEventId: flowEvents); + + service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓); + + Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水"); + Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL"); + Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓"); + Console.WriteLine($"UW_005: 反序列化SwapRealizedPnL=8000, 资金流水={service.ClientCashCalls[0].amount}, TradeStatus={td.TradeStatus} ✅"); + } + + // ================================================================ + // 场景6:ApplySwapTrade 提交审核 —— 前置校验与保存事件 + // ================================================================ + + [TestMethod] + public void UW_006_ApplySwapTrade_提交审核_前置校验与保存事件() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m); + unwindData.SwapCloseAmount = 6000m; + + service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓); + + Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次"); + Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); + Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓"); + Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m, + "SwapRealizedPnL 应=SwapCloseAmount(6000)"); + Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅"); + } + + // ================================================================ + // 场景7:前端传"占期初(A)"语义,后端入口转"占剩余(B)" —— 全平判定 + // 原始名义本金 100M / 剩余 60M,前端传 A=0.6(平掉原始 60M = 剩余全部) + // B = A × Notional/Posi = 0.6 × 100/60 = 1.0 → 触发全平 + // ================================================================ + [TestMethod] + public void UW_007_SwapUnwind_占期初A转占剩余B_全平判定正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 0.6m, + closeQty: 600000m, closeNotionalValue: 600000m, positionQty: 600000m); + unwindData.NotionalValue = 1000000m; // 期初名义本金 + unwindData.PosiNotionalValue = 600000m; // 剩余名义本金 + + service.SwapUnwind(unwindData); + + // 桩 SaveSwapDeal 收集的是转换后的 B(落库 A 还原在生产 SaveSwapDealInternal 中,桩跳过) + Assert.AreEqual(1.0m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m, + "入口 A=0.6 应转为 B=1.0(占剩余全平)"); + Assert.AreEqual("已平仓", td.TradeStatus, "B==1 触发全平 TradeStatus=已平仓"); + Console.WriteLine($"UW_007: A=0.6→B={service.SaveSwapDealCalls[0].data.ClosePercent}, TradeStatus={td.TradeStatus} ✅"); + } + + // ================================================================ + // 场景8:占期初(A)转占剩余(B) —— 部分平仓 + // 原始 100M / 剩余 60M,前端传 A=0.3(平掉原始 30M = 剩余的 50%) + // B = A × Notional/Posi = 0.3 × 100/60 = 0.5 → 部分平仓 + // ================================================================ + [TestMethod] + public void UW_008_SwapUnwind_占期初A转占剩余B_部分平仓正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.3m, + closeQty: 300000m, closeNotionalValue: 300000m, positionQty: 600000m); + unwindData.NotionalValue = 1000000m; // 期初名义本金 + unwindData.PosiNotionalValue = 600000m; // 剩余名义本金 + + service.SwapUnwind(unwindData); + + Assert.AreEqual(0.5m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m, + "入口 A=0.3 应转为 B=0.5(占剩余 50%)"); + Assert.AreEqual(1, td.HasPartialUnWind, "B≠1 应为部分平仓,设 HasPartialUnWind=1"); + Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); + Console.WriteLine($"UW_008: A=0.3→B={service.SaveSwapDealCalls[0].data.ClosePercent}, HasPartialUnWind={td.HasPartialUnWind} ✅"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs new file mode 100644 index 00000000..8b404f7e --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -0,0 +1,127 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapDealService 的可测试化子类(共享 stub)。 + /// 继承 SwapDealService,override seam 把 DB/事务/外部服务替换为内存收集器。 + /// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用,避免重复。 + /// + public class TestableSwapDealService : SwapDealService + { + private readonly trade _trade; + private readonly Dictionary _swapEvents; + private readonly Dictionary> _flowEventsByEventId; + + /// 捕获 AddClientCash 的每次调用(金额, 操作, 日期) + public List<(double amount, string action, DateTime date)> ClientCashCalls { get; } = new(); + + /// 捕获 SaveSwapDeal 的每次调用(unwindData, eventType, clientCashId) + public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls { get; } = new(); + + public int SaveAllChangesCount; + public int CloseReCheckCallCount; + + public TestableSwapDealService(trade td, + Dictionary swapEvents = null, + Dictionary> flowEventsByEventId = null) + : base(new OptUserInfo(0, nameof(TestableSwapDealService), OptUserFrom.UnitTest)) + { + _trade = td; + _swapEvents = swapEvents ?? new Dictionary(); + _flowEventsByEventId = flowEventsByEventId ?? new Dictionary>(); + } + + protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null; + + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) + { + ClientCashCalls.Add((amount, action, valueDate)); + return ClientCashCalls.Count; // 返回自增 id + } + + // 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库 + protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) + { + SaveSwapDealCalls.Add((unwindData, eventType, clientCashId)); + return SaveSwapDealCalls.Count; // 返回自增 eventId + } + + // ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType) + protected override swap_event FindSwapEvent(int tradeId, int eventType) + { + return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null; + } + + // ApproveSwapTrade 查事件关联流水:从内存字典取 + protected override List FindFlowEventsByEventId(long eventId) + { + return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List(); + } + + // ApplySwapTrade 的前置校验:计数,不实际执行 + protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck) + { + CloseReCheckCallCount++; + } + + protected override void SaveAllChanges() { SaveAllChangesCount++; } + protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行 + protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作 + protected override void TriggerRealtimeSwapPosition() { } // 空操作 + } + + /// + /// SwapDealService 测试的共享工厂方法(TestableSwapDealService + UnwindData 构造)。 + /// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用。 + /// + public static class SwapDealTestFactory + { + public const int SwapTradeId = 7700; + public static readonly DateTime ValueDate = new(2026, 6, 15); + public static readonly DateTime UnwindDate = new(2026, 6, 16); + + public static trade CreateTrade() + { + return new trade + { + id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888, + TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5), + ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用) + TradeStatus = "确认成交", ValidState = "Valid", + Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000 + }; + } + + /// 构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用) + public static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m, + decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m, + decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m) + { + return new UnwindData + { + SwapTradeId = SwapTradeId, + SwapRealizedPnL = swapRealizedPnL, + SwapMarginRebatePnl = swapMarginRebatePnl, + SwapMarginAmount = swapMarginAmount, + SwapCloseAmount = swapRealizedPnL, + CloseMethod = closeMethod, + ClosePercent = closePercent, + CloseQty = closeQty, + CloseNotionalValue = closeNotionalValue, + PositionQty = positionQty, + ValueDate = ValueDate, + UnwindDate = UnwindDate, + StartDate = new DateTime(2026, 1, 5) + }; + } + + public static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "") + { + Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, + $"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}"); + } + } +} diff --git a/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs b/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs new file mode 100644 index 00000000..9e8f364d --- /dev/null +++ b/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs @@ -0,0 +1,133 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.Modules.TradeModule; + +namespace YLErp.Modules.TradeModule +{ + /// + /// TradeServiceBase.CalcSwapCloseNotionalFromEventData / CalcOptionCloseNotional 的回归测试。 + /// --------------------------------------------------------------- + /// 守卫锦麟王提交 23108016 "BugFix 互换本次名义本金取错"。 + /// + /// 旧 bug:BuildTriggerContext 了结场景统一用 trade_cash.UnwindPercentRate × 期初名义本金 + /// 算本次名义本金,但收益互换的 trade_cash.UnwindPercentRate 口径与期权不同, + /// 导致互换审批触发条件用错本金,可能绕过/误触发审批阈值。 + /// + /// 修复:互换分支从 swap_event.EventData 反序列化取 CloseNotionalValue 绝对值; + /// 期权分支保留旧逻辑(期初名义本金 × UnwindPercentRate 绝对值)。 + /// + /// 抽出两个静态纯函数以支持无库单测,重点验证容错(null/空/非法 JSON)不会抛异常 + /// 而是返回 0,避免静默吞异常导致名义本金为 0 进而绕过审批阈值。 + /// + [TestClass] + public class TradeServiceBaseCloseNotionalCalcTest + { + // ================================================================ + // 一、CalcSwapCloseNotionalFromEventData 容错与绝对值语义 + // ================================================================ + + [TestMethod] + public void 互换_EventData为null_返回0_不抛异常() + { + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(null); + Assert.AreEqual(0d, result, 0.0001, "null EventData 应容错返回 0"); + } + + [TestMethod] + public void 互换_EventData为空字符串_返回0_不抛异常() + { + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(""); + Assert.AreEqual(0d, result, 0.0001, "空字符串 EventData 应容错返回 0"); + } + + [TestMethod] + public void 互换_EventData为非法JSON_返回0_不抛异常() + { + // 旧实现 catch{} 静默吞异常,抽函数后必须保持此容错契约 + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData("not-a-json"); + Assert.AreEqual(0d, result, 0.0001, "非法 JSON 应被 catch 返回 0,不能抛异常"); + } + + [TestMethod] + public void 互换_EventData为合法JSON_正数CloseNotionalValue_原值返回() + { + var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 500_000m }); + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData); + Assert.AreEqual(500_000d, result, 0.01, "正数 CloseNotionalValue 应原值返回"); + } + + [TestMethod] + public void 互换_EventData为合法JSON_负数CloseNotionalValue_取绝对值() + { + // 修复的核心契约:Math.Abs 取绝对值,防止方向反向导致名义本金变负 + var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = -500_000m }); + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData); + Assert.AreEqual(500_000d, result, 0.01, "负数 CloseNotionalValue 应取绝对值返回 500000"); + } + + [TestMethod] + public void 互换_EventData为合法JSON_CloseNotionalValue为零_返回0() + { + var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 0m }); + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData); + Assert.AreEqual(0d, result, 0.0001, "CloseNotionalValue=0 应返回 0"); + } + + // ================================================================ + // 二、CalcOptionCloseNotional 容错与绝对值语义 + // ================================================================ + + [TestMethod] + public void 期权_两者都为null_返回0() + { + var result = TradeServiceBase.CalcOptionCloseNotional(null, null); + Assert.AreEqual(0d, result, 0.0001, "两者 null 应返回 0"); + } + + [TestMethod] + public void 期权_期初名义本金为null_返回0() + { + var result = TradeServiceBase.CalcOptionCloseNotional(null, 0.5d); + Assert.AreEqual(0d, result, 0.0001, "originalStockEqvNotional=null 应返回 0"); + } + + [TestMethod] + public void 期权_平仓比例为null_返回0() + { + var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, null); + Assert.AreEqual(0d, result, 0.0001, "unwindPercentRate=null 应返回 0"); + } + + [TestMethod] + public void 期权_两者都有值_正数相乘_返回乘积() + { + // 1,000,000 × 0.3 = 300,000 + var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, 0.3d); + Assert.AreEqual(300_000d, result, 0.01, "1M × 0.3 = 300K"); + } + + [TestMethod] + public void 期权_期初名义本金为负数_取绝对值后相乘() + { + // 异常但容错:-1,000,000 × 0.3 → Abs → 300,000 + var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, 0.3d); + Assert.AreEqual(300_000d, result, 0.01, "期初名义本金为负数应取绝对值后相乘"); + } + + [TestMethod] + public void 期权_平仓比例为负数_取绝对值后相乘() + { + // 异常但容错:1,000,000 × -0.3 → Abs → 300,000 + var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, -0.3d); + Assert.AreEqual(300_000d, result, 0.01, "平仓比例为负数应取绝对值后相乘"); + } + + [TestMethod] + public void 期权_两者都为负数_取绝对值后相乘() + { + // -1,000,000 × -0.3 = 300,000(先乘后取 Abs,结果一致) + var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, -0.3d); + Assert.AreEqual(300_000d, result, 0.01, "两者都为负数应取绝对值后相乘"); + } + } +} diff --git a/UnitTestProject/Resources/GoldenFiles/EodPriceGolden/golden_标的种类与来源_synthetic.json b/UnitTestProject/Resources/GoldenFiles/EodPriceGolden/golden_标的种类与来源_synthetic.json new file mode 100644 index 00000000..ae9f1fa1 --- /dev/null +++ b/UnitTestProject/Resources/GoldenFiles/EodPriceGolden/golden_标的种类与来源_synthetic.json @@ -0,0 +1,51 @@ +{ + "Scenario": "标的种类与数据来源", + "Description": "合成 Mock:覆盖现券/贵金属/真期货/股票 的种类显示,以及债券来源(自动同步→系统/手工改过→人工)。回放守护'不再一律显示商品期货'+'路由键不变'+'债券来源=系统'。", + "Source": "synthetic", + "RecordedAt": null, + "Rows": [ + { + "UnderlyingCode": "019547.IB", + "RouteKey": "CommodityFutures", + "RealInstrumentType": "CreditBonds", + "IsBond": true, + "ExpectedTypeCn": "信用债", + "ExpectedDataSource": "系统" + }, + { + "UnderlyingCode": "220210.IB", + "RouteKey": "CommodityFutures", + "RealInstrumentType": "TBonds", + "IsBond": true, + "ExpectedTypeCn": "利率债", + "ExpectedDataSource": "系统" + }, + { + "UnderlyingCode": "AU9999.SGE", + "RouteKey": "CommodityFutures", + "RealInstrumentType": "GoldSpot", + "IsBond": false, + "JSID": null, + "ExpectedTypeCn": "黄金现货", + "ExpectedDataSource": null + }, + { + "UnderlyingCode": "IF2409", + "RouteKey": "CommodityFutures", + "RealInstrumentType": "CommodityFutures", + "IsBond": false, + "JSID": null, + "ExpectedTypeCn": "商品期货", + "ExpectedDataSource": null + }, + { + "UnderlyingCode": "600000.SH", + "RouteKey": "Stock", + "RealInstrumentType": "Stock", + "IsBond": false, + "JSID": null, + "ExpectedTypeCn": "股票", + "ExpectedDataSource": null + } + ] +} diff --git a/YLErpDAL/Helpers/ConditionEvaluator.cs b/YLErpDAL/Helpers/ConditionEvaluator.cs new file mode 100644 index 00000000..4a03fb01 --- /dev/null +++ b/YLErpDAL/Helpers/ConditionEvaluator.cs @@ -0,0 +1,348 @@ +using BaseOUDAL; +using Newtonsoft.Json; + +namespace YLErp.Helpers +{ + /// + /// 审批条件求值器:统一支撑「节点触发条件」与「分支网关条件」。 + /// 需求①(节点触发)与需求③(多分支)共用同一套条件模型,避免两套条件语义。 + /// 支持混合「且/或」与「括号」的布尔表达式,如 A and (B or C)。 + /// + public static class ConditionEvaluator + { + /// + /// 求值条件 JSON。 + /// + /// 条件 JSON(结构见 ConditionExpressionConfig);为空/null 视为无条件,返回 false(不触发)。 + /// 业务上下文(交易/发起人等)。 + /// 是否满足条件 + public static bool Evaluate(string conditionJson, ConditionContext context) + { + if (string.IsNullOrWhiteSpace(conditionJson)) + { + return false; + } + + ConditionExpressionConfig config; + try + { + config = JsonConvert.DeserializeObject(conditionJson); + } + catch + { + // 容错:非法 JSON 不阻断审批主流程,视为不触发。 + return false; + } + + if (config == null || config.tokens == null || config.tokens.Count == 0) + { + return false; + } + + // 递归下降求值(支持括号、且/或混合)。 + var parser = new ConditionParser(config.tokens, context); + return parser.Parse(); + } + + /// + /// 从起点节点开始,向后查找第一个满足触发条件(或无触发条件)的审批节点(需求①)。 + /// 用于交易提交进入审批流程时确定起始审批节点:若起点节点配置了触发条件且当前业务不满足, + /// 则跳过该节点继续向后找,直到找到可进入的节点;若从起点到末尾均不满足则返回 null(表示无需审批,直接通过)。 + /// + /// 流程全部节点(已按 order 排序) + /// 起点节点 + /// 条件求值上下文 + /// 第一个应进入审批的节点;若无需审批则返回 null + public static approvalprocess FindFirstTriggeredNode( + List tradeProcess, + approvalprocess start, + ConditionContext ctx) + { + if (start == null) return null; + var current = start; + while (current != null) + { + // 无触发条件,或满足触发条件 → 该节点需审批 + if (string.IsNullOrWhiteSpace(current.triggerCondition) + || Evaluate(current.triggerCondition, ctx)) + { + return current; + } + // 不满足 → 向后取下一个主干节点(node=0) + current = tradeProcess.FirstOrDefault(x => x.order > current.order && x.node == 0); + } + return null; + } + + /// 求值单个条件。 + internal static bool EvaluateSingle(ConditionItem cond, ConditionContext context) + { + if (cond == null || string.IsNullOrEmpty(cond.field) || string.IsNullOrEmpty(cond.op)) + { + return false; + } + var leftValue = FieldResolver.ResolveValue(cond.field, context); + return OperatorCompare(leftValue, cond.op, cond.value); + } + + /// 比较:能转数值时按数值比,否则按字符串比。 + private static bool OperatorCompare(object left, string op, object right) + { + if (TryToDouble(left, out var ld) && TryToDouble(right, out var rd)) + { + return CompareNumeric(ld, op, rd); + } + var ls = left?.ToString() ?? string.Empty; + var rs = right?.ToString() ?? string.Empty; + return CompareString(ls, op, rs); + } + + private static bool CompareNumeric(double left, string op, double right) + { + return NormalizeOp(op) switch + { + ">" => left > right, + "<" => left < right, + ">=" => left >= right, + "<=" => left <= right, + "==" => Math.Abs(left - right) < 1e-9, + "!=" => Math.Abs(left - right) >= 1e-9, + _ => false + }; + } + + private static bool CompareString(string left, string op, string right) + { + return NormalizeOp(op) switch + { + "==" => left == right, + "!=" => left != right, + ">" => string.Compare(left, right, StringComparison.Ordinal) > 0, + "<" => string.Compare(left, right, StringComparison.Ordinal) < 0, + ">=" => string.Compare(left, right, StringComparison.Ordinal) >= 0, + "<=" => string.Compare(left, right, StringComparison.Ordinal) <= 0, + "in" => right.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Any(r => string.Equals(r.Trim(), left, StringComparison.OrdinalIgnoreCase)), + _ => false + }; + } + + /// + /// 归一化操作符:兼容字母标识符(gt/lt/gte/lte/eq/neq)与符号(> < >= <= == != =)。 + /// + private static string NormalizeOp(string op) + { + if (string.IsNullOrEmpty(op)) return op; + return op.ToLowerInvariant() switch + { + "gt" or ">" => ">", + "lt" or "<" => "<", + "gte" or ">=" => ">=", + "lte" or "<=" => "<=", + "eq" or "==" or "=" => "==", + "neq" or "!=" or "<>" => "!=", + _ => op + }; + } + + private static bool TryToDouble(object value, out double result) + { + result = 0; + if (value == null) return false; + return double.TryParse(value.ToString(), out result); + } + } + + /// + /// 递归下降解析器:按 token 顺序求值布尔表达式,支持括号与「且/或」优先级。 + /// 文法:Expr := Term (("and"|"or") Term)* ;Term := condition | "(" Expr ")"。 + /// 优先级:and 高于 or(与常规布尔代数一致);同级从左到右。 + /// + internal class ConditionParser + { + private readonly List _tokens; + private readonly ConditionContext _context; + private int _pos; + + public ConditionParser(List tokens, ConditionContext context) + { + _tokens = tokens ?? new List(); + _context = context; + _pos = 0; + } + + public bool Parse() + { + if (_tokens.Count == 0) return false; + return ParseOr(); + } + + // 低优先级:ParseOr := ParseAnd ( "or" ParseAnd )* 左结合,遇 or 短路(为 true 直接返回后续不再求值) + private bool ParseOr() + { + var left = ParseAnd(); + while (true) + { + var op = Peek(); + if (op == null || op.type != "operator" || !IsOr(op.connector)) break; + _pos++; // 消费 or + var right = ParseAnd(); + left = left || right; + } + return left; + } + + // 高优先级:ParseAnd := ParseTerm ( "and" ParseTerm )* 左结合,遇 and 短路(为 false 直接返回) + private bool ParseAnd() + { + var left = ParseTerm(); + while (true) + { + var op = Peek(); + if (op == null || op.type != "operator" || IsOr(op.connector)) break; + _pos++; // 消费 and + var right = ParseTerm(); + left = left && right; + } + return left; + } + + // Term := condition | "(" ParseOr ")" + private bool ParseTerm() + { + var tok = Peek(); + if (tok == null) return false; + + if (tok.type == "lparen") + { + _pos++; // 消费 "(" + var val = ParseOr(); + var rp = Peek(); + if (rp != null && rp.type == "rparen") _pos++; // 消费 ")" + return val; + } + + if (tok.type == "condition") + { + _pos++; + return ConditionEvaluator.EvaluateSingle(tok.condition, _context); + } + + return false; + } + + private ConditionToken Peek() => _pos < _tokens.Count ? _tokens[_pos] : null; + + private static bool IsOr(string connector) + => string.Equals(connector, "or", StringComparison.OrdinalIgnoreCase); + } + + /// 条件业务上下文:求值时由调用方构造,封装可参与判断的业务字段。 + public class ConditionContext + { + /// 发起人 userId(用于查发起人审批组)。 + public int? UserId { get; set; } + + /// 交易实体(期初名义本金等字段来源)。开户场景可为 null。 + public trade Trade { get; set; } + + /// 交易流程类别:开仓(TradeProcess) / 了结(CloseProcess)。需求②用。 + public string ProcessCategory { get; set; } + + /// 预解析的发起人审批组(避免重复查库;为空时由 FieldResolver 查)。 + public int? InitGroupId { get; set; } + + /// 本次交易名义本金的取值(了结场景由调用方从 trade_cash.UnwindStockEqvNotional 取绝对值传入)。需求①。 + public double? CurrentNotional { get; set; } + } + + /// + /// 条件表达式配置(对应 conditionConfig / triggerCondition 列的 JSON 结构)。 + /// tokens:token 序列,支持「且/或」混合与括号,按表达式顺序排列。 + /// + public class ConditionExpressionConfig + { + /// token 序列:条件 / 且或连接符 / 左右括号,按表达式顺序排列。 + public List tokens { get; set; } + } + + /// + /// 表达式 token:一个条件、一个连接符、或一个括号。 + /// + public class ConditionToken + { + /// token 类型:condition | operator | lparen | rparen + public string type { get; set; } + + /// 当 type=condition 时的条件体。 + public ConditionItem condition { get; set; } + + /// 当 type=operator 时的连接符:and | or + public string connector { get; set; } + } + + /// 单个条件:左值字段 + 操作符 + 右值。 + public class ConditionItem + { + /// 左值字段 key,见 FieldResolver(initGroup/notional/tradeType 等)。 + public string field { get; set; } + + /// 操作符:> < >= <= == != = in + public string op { get; set; } + + /// 右值 + public object value { get; set; } + } + + /// + /// 条件左值解析:把 field key 映射到具体业务字段值。 + /// 触发条件字段(需求①): + /// - initialNotional:交易的期初名义本金(trade.OriginalStockEqvNotional,已取绝对值) + /// - currentNotional :本次交易名义本金(了结场景,由调用方从 trade_cash 取本次影响金额绝对值传入) + /// 历史兼容:initGroup/tradeType/processCategory/notional 仍可解析。 + /// + public static class FieldResolver + { + public static object ResolveValue(string field, ConditionContext context) + { + if (string.IsNullOrEmpty(field) || context == null) + { + return null; + } + + switch (field.ToLowerInvariant()) + { + // 需求①:触发条件字段(仅这两个对外暴露) + case "initialnotional": + // 交易的期初名义本金 + return context.Trade?.OriginalStockEqvNotional ?? 0; + + case "currentnotional": + // 本次交易名义本金(了结时按本次影响金额绝对值,由调用方传入) + return context.CurrentNotional ?? 0; + + // 以下为历史兼容,前端不再暴露 + case "notional": + return context.Trade?.StockEqvNotional ?? 0; + + case "initgroup": + if (context.InitGroupId.HasValue) + { + return context.InitGroupId.Value; + } + return context.UserId.HasValue + ? UserBLL.GetApprovalProcessGroup(context.UserId.Value) + : 0; + + case "tradetype": + return context.Trade?.TradeType ?? string.Empty; + + case "processcategory": + return context.ProcessCategory ?? string.Empty; + + default: + return null; + } + } + } +} diff --git a/YLErpDAL/Helpers/FrontendCalcReference.cs b/YLErpDAL/Helpers/FrontendCalcReference.cs index cd2585e8..7f9880af 100644 --- a/YLErpDAL/Helpers/FrontendCalcReference.cs +++ b/YLErpDAL/Helpers/FrontendCalcReference.cs @@ -89,7 +89,7 @@ namespace YLErp.Helpers /// /// 计算结息页(income)的盯市盈亏与汇总。 /// 对应 incomeSwapTrade.js:128-178。 - /// 差异:用 CloseNotionalValue(非 CloseQty)作量纲,无 longRatio,无 Math.round/10000。 + /// 差异:用剩余持仓数量和合约乘数作量纲,无 longRatio,无 Math.round/10000。 /// public static UnwindResult CalcIncome(UnwindInput input) { @@ -102,9 +102,9 @@ namespace YLErp.Helpers decimal tradingFeePending = ParseOrZero(input.TradingFeePending); decimal dividendIn = ParseOrZero(input.DividendIn); - // MarkClosePnl = CloseNotionalValue × (TradingAmountAvg × scale − EntryPrice) × floatRatio + // MarkClosePnl = PositionQty × ContractSize × (TradingAmountAvg × scale − EntryPrice) × floatRatio // (无 longRatio、无 Math.round/10000) - decimal markClosePnl = input.CloseNotionalValue * (input.TradingAmountAvg * scale - entryPrice) * floatRatio; + decimal markClosePnl = input.PositionQty * input.ContractSize * (input.TradingAmountAvg * scale - entryPrice) * floatRatio; markClosePnl = StockEqvNotional(markClosePnl); decimal floatPnlSum = decimal.Parse( @@ -156,7 +156,9 @@ namespace YLErp.Helpers public decimal PosiGrossPrice; // EntryDirtyPrice(期初全价不含费) public decimal TradingAmountAvg; // 用户可改的期末标的价格(界面×multiplier形态) public decimal CloseQty; // 平仓数量 - public decimal CloseNotionalValue;// 平仓名义本金(income 用) + public decimal PositionQty; // 结息时的剩余持仓数量 + public decimal ContractSize = 1m; // 合约乘数 + public decimal CloseNotionalValue;// 平仓名义本金 public int PayDirection; // 1=收取,-1=支付 public int PositionType; // 1=多头,2=空头 public string TradingFee; // 交易费用(前端是字符串) diff --git a/YLErpDAL/Model/ClientSwapPositionRequest.cs b/YLErpDAL/Model/ClientSwapPositionRequest.cs index c4883a06..50783450 100644 --- a/YLErpDAL/Model/ClientSwapPositionRequest.cs +++ b/YLErpDAL/Model/ClientSwapPositionRequest.cs @@ -15,12 +15,27 @@ namespace YLErp.Model { /// - /// - /// + /// 估值日。每日估值报告的互换估值查询当前按该日期精确筛选, + /// 交易日期同时不得晚于该日期。 + /// public DateTime? ValueDate { get; set; } + /// + /// 请求携带的估值日下界。互换持仓明细、交易流水等调用方可使用该字段; + /// 当前 GetSearchEodPositionList 未启用该下界,仍是单日估值查询。 + /// public DateTime? ValueDateFrom { get; set; } + /// + /// 对手方筛选条件。为空时不按对手方收窄结果。 + /// public int? ClientId { get; set; } + /// + /// 簿记账户筛选条件,对应 trade.AssetId;为空时包含该对手方下全部簿记账户。 + /// + public int? BookId { get; set; } + /// + /// 调用方传入的结构类型。互换估值查询当前固定同时覆盖普通债券类收益互换和普通收益互换。 + /// public string StructureType { get; set; } } /// diff --git a/YLErpDAL/Model/Configcolumn.cs b/YLErpDAL/Model/Configcolumn.cs index 9ce1a72c..b3af09ea 100644 --- a/YLErpDAL/Model/Configcolumn.cs +++ b/YLErpDAL/Model/Configcolumn.cs @@ -156,6 +156,7 @@ namespace YLErp.Model public const string 交易预付金配置 = "tradeMarginTemplateList"; public const string 互换估值 = "SettmentEodSwapPositionList"; + public const string 互换估值V1 = "SettmentEodSwapPositionListV1"; public const string 衡泰对账 = "compare_heitai_data"; } diff --git a/YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs b/YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs index 18b31de5..c59eca99 100644 --- a/YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs +++ b/YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs @@ -140,6 +140,9 @@ namespace YLErp.Modules.DataProviderModule { if (item.UnderlyingInstrumentType == "Bonds") { + // [Layer2-待统一] 债券映射口径:SettlePrice=全价(dirty_price_close),ClosePrice=净价(net_price)。 + // 注意:这与 EodPriceQueryService.GetBondPrice 的映射【完全相反】(GetBondPrice: ClosePrice=全价,SettlePrice=净价)。 + // 两处对"债券收盘价/结算价"的净全价定义不一致属历史遗留,请勿随意改动单侧,需业务先定调后统一(见 TryGetSettlementEodPrice 注释)。 item.SettlePrice = Convert.ToDouble(BondPriceConverter.ToStorage(item.DeciSettlePrice)); item.ClosePrice = Convert.ToDouble(BondPriceConverter.ToStorage(item.DeciClosePrice)); item.ReferencePrice = Convert.ToDouble(BondPriceConverter.ToStorage(item.DeciReferencePrice)); diff --git a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs index 02e7787b..7792e5e1 100644 --- a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs +++ b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs @@ -114,6 +114,23 @@ namespace YLErp.Modules.DataProviderModule return (eodPrice = GetBondPrice(valueDate, underlyingCode)) != null; } /// + /// 统一日终结算取价(债券感知)。 + /// 用于交易/期权到期结算:债券标的走中债估值表(TryGetBondEodPrice),期货/股票走原 InnerGetEodPrice。 + /// 解决到期路径(tradeExpireInner / MultipleTradeExpireConfirm)漏查债券表导致"结算价未找到"的问题。 + /// 注:债券 ClosePrice/SettlePrice 映射沿用 GetBondPrice 口径(ClosePrice=全价 dirty_price_close,SettlePrice=净价 net_price), + /// 与 EodPriceProvider 的映射(ClosePrice=净价,SettlePrice=全价)相反——属历史不一致(见 EodPriceProvider.Initialize 与 GetBondPrice 的注释), + /// 本方法保持与系统既有"债券现价"约定(UnderlyingCodePrice)一致,不引入新口径。 + /// + public static bool TryGetSettlementEodPrice(DateTime valueDate, string underlyingCode, out EodPrice eodPrice) + { + var um = DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode); + if (um != null && ConsGlobal.InstrumentType.IsBond(um.UnderlyingInstrumentType)) + { + return TryGetBondEodPrice(valueDate, underlyingCode, out eodPrice); + } + return TryGetEodPrice(valueDate, underlyingCode, out eodPrice); + } + /// /// 尝试获取标的某日的日终价 /// public static bool TryGetEodPrice(DateTime valueDate, int underlyingId, out EodPrice eodPrice) @@ -137,6 +154,8 @@ namespace YLErp.Modules.DataProviderModule if (data != null) { + // FR007 行 ReferencePrice 已是小数口径(无论 bond-sync 自动同步还是界面手工录入,写入时均已 ÷100), + // 利息腿计算直接作为 floatRate 参与 principal*(fixedRate+floatRate)/annualDays,无需再 ÷100。 price = data.ReferencePrice ?? 0; return true; @@ -229,6 +248,9 @@ namespace YLErp.Modules.DataProviderModule Vobp = bondPrice.vobp, ValueDate = valueDate, UnderlyingCode = underlyingCode, + // [Layer2-待统一] 债券映射口径:ClosePrice=全价(dirty_price_close),SettlePrice=净价(net_price)。 + // 注意:这与 EodPriceProvider.Initialize 的映射【完全相反】(EodPriceProvider: ClosePrice=净价,SettlePrice=全价)。 + // 两处对"债券收盘价/结算价"的净全价定义不一致属历史遗留,请勿随意改动单侧,需业务先定调后统一(见 TryGetSettlementEodPrice 注释)。 ClosePrice = Convert.ToDouble(BondPriceConverter.ToStorage(bondPrice.dirty_price_close)), SettlePrice = Convert.ToDouble(BondPriceConverter.ToStorage(bondPrice.net_price)), ReferencePrice = Convert.ToDouble(BondPriceConverter.ToStorage(bondPrice.yield)) diff --git a/YLErpDAL/Modules/EodModule/EodPriceService.cs b/YLErpDAL/Modules/EodModule/EodPriceService.cs index ea8ebfab..449a4c75 100644 --- a/YLErpDAL/Modules/EodModule/EodPriceService.cs +++ b/YLErpDAL/Modules/EodModule/EodPriceService.cs @@ -1,6 +1,5 @@ -using BaseOUDAL; -using DocumentFormat.OpenXml.Bibliography; -using NPOI.POIFS.NIO; +using BaseOUDAL; +using YLErp.BLL; using YLErp.Helpers; namespace YLErp.Modules.EodModule @@ -15,10 +14,81 @@ namespace YLErp.Modules.EodModule } + /// + /// 解析日终价格列表的"估值日期"查询窗口。抽成 static 以便纯单测锁定行为(避免改坏)。 + /// 规则: + /// - 起始日期年份 > 2000(前端传了有效日期)→ 用传入值;否则回退到 今天-1年。 + /// - 结束日期年份 > 2000 → 用传入值+1天(闭区间转半开);否则回退到 今天+1年。 + /// 注意:列表页默认把起止都设成"今天",于是窗口=[今天, 今天+1天)=仅今天 → 仅返回当天的记录 + /// (即"页面始终5条"现象的真正成因,非分页/查询 bug)。要看历史须把起始日期调早。 + /// + public static (DateTime start, DateTime end) ResolveValueDateWindow(DateTime reqStart, DateTime reqEnd) + { + var start = reqStart.Year > 2000 ? reqStart : DateTime.Today.AddYears(-1); + var end = reqEnd.Year > 2000 ? reqEnd.AddDays(1) : DateTime.Today.AddYears(1); + return (start, end); + } + + /// + /// 校正 eod_commodity_future_price 行的 UnderlyingId,使其与 UnderlyingCode(=FutureContractId) 一致。 + /// 背景:网页日终价格列表按 UnderlyingId(int) JOIN underlying_manager,而 EOD 结算(EodPriceProvider.Initialize) + /// 按 UnderlyingCode(string) JOIN。两列一旦失同步(典型如 FR007 的价格行 UnderlyingId 被错写成 511160.SH 的 id), + /// 会出现"网页能查到、结算却查不到"的错价缺失,进而 EodCheckSettlePrice 报"结算价格缺失"。 + /// 这里以 UnderlyingCode 为准重新派生 UnderlyingId——该列才是上传/结算使用的自然键(FutureContractId), + /// 在入库前强制两列一致,既阻止产生新的错行,又通过告警日志把失同步暴露给运维追查上游写入来源。 + /// + /// + /// 纯函数:根据 UnderlyingCode 校正决策。给定当前 UnderlyingId 与从 underlying_manager 解析到的正确 id, + /// 返回应使用的 UnderlyingId。UnderlyingCode 为空或库中无对应标的(resolvedId=null)时维持原值, + /// 已一致时也维持原值,仅在不一致时返回正确 id。抽成纯函数便于无数据库单测(覆盖 GLMS-20260701 FR007 错行根因)。 + /// + public static int ResolveUnderlyingIdForCode(string underlyingCode, int currentId, int? resolvedId) + { + if (string.IsNullOrWhiteSpace(underlyingCode)) + { + return currentId; + } + if (resolvedId == null) + { + return currentId; + } + if (resolvedId.Value == currentId) + { + return currentId; + } + return resolvedId.Value; + } + + /// + /// 校正 eod_commodity_future_price 行的 UnderlyingId,使其与 UnderlyingCode(=FutureContractId) 一致。 + /// 背景:网页日终价格列表按 UnderlyingId(int) JOIN underlying_manager,而 EOD 结算(EodPriceProvider.Initialize) + /// 按 UnderlyingCode(string) JOIN。两列一旦失同步(典型如 FR007 的价格行 UnderlyingId 被错写成 511160.SH 的 id), + /// 会出现"网页能查到、结算却查不到"的错价缺失,进而 EodCheckSettlePrice 报"结算价格缺失"。 + /// 这里以 UnderlyingCode 为准重新派生 UnderlyingId——该列才是上传/结算使用的自然键(FutureContractId), + /// 在入库前强制两列一致,既阻止产生新的错行,又通过告警日志把失同步暴露给运维追查上游写入来源。 + /// + public static void SyncUnderlyingIdFromCode(YLContext db, eod_commodity_future_price row) + { + if (row == null || string.IsNullOrWhiteSpace(row.UnderlyingCode)) + { + return; + } + + var um = db.underlying_manager.FirstOrDefault(u => u.UnderlyingCode == row.UnderlyingCode); + var resolvedId = um == null ? (int?)null : um.id; + var before = row.UnderlyingId; + row.UnderlyingId = ResolveUnderlyingIdForCode(row.UnderlyingCode, row.UnderlyingId ?? 0, resolvedId); + if (row.UnderlyingId != before) + { + LogFactory.GetLogger("EodPrice").Info( + $"eod_commodity_future_price.UnderlyingId 与 UnderlyingCode 不一致,已自动校正: " + + $"FutureContractId={row.UnderlyingCode}, 原UnderlyingId={before}, 修正为={row.UnderlyingId}"); + } + } + public SearchListResult SearchUnderlyingList(EodCommodityFuturePriceReq req) { - var valueDtStart = req.ValueDateStart.Year > 2000 ? req.ValueDateStart : DateTime.Today.AddYears(-1); - var valueDtEnd = req.ValueDateEnd.Year > 2000 ? req.ValueDateEnd.AddDays(1) : DateTime.Today.AddYears(1); + var (valueDtStart, valueDtEnd) = ResolveValueDateWindow(req.ValueDateStart, req.ValueDateEnd); var predicatUn = PredicateBuilder.Create(d => d.LaunchState == "1"); var predicatEoc = PredicateBuilder.Create(source => source.ValueDate >= valueDtStart && source.ValueDate < valueDtEnd); @@ -29,13 +99,20 @@ namespace YLErp.Modules.EodModule { predicatEoc = predicatEoc.And(d => d.DataSource.Contains(req.DataSource)); predicatEot = predicatEot.And(d => d.DataSource.Contains(req.DataSource)); - if (req.DataSource=="系统") + // 债券来源:自动同步(中债, update_user 为空)归为"系统"、被手工改过的(update_user 非空)归为"人工"。 + // 筛选须与后处理显示口径一致:按"系统"只命中 update_user 为空(中债自动同步)的债券; + // 按"人工"只命中被手工改过(update_user 非空)的债券;其他来源值视为无效→无命中。 + if (req.DataSource == EodPriceBase.人工) { - predicatEob= predicatEob.And(d => d.JSID!=null); + predicatEob = predicatEob.And(d => d.update_user != null); + } + else if (req.DataSource == EodPriceBase.系统) + { + predicatEob = predicatEob.And(d => d.update_user == null); } else { - predicatEob = predicatEob.And(d => d.JSID==null); + predicatEob = predicatEob.And(d => false); } } @@ -57,6 +134,10 @@ namespace YLErp.Modules.EodModule IsBond=false, id = source.id, DataSource = source.DataSource, + // EF Core Concat 要求各分支投影成员集合完全一致: + // 债券分支设了 UpdateUser,故期货/股票分支也必须显式设(置 null),否则翻译期抛 + // "The given key 'UpdateUser/DataSource' was not present in the dictionary"。 + UpdateUser = (long?)null, LaunchState = un.LaunchState, MarketName = un.MarketName, UnderlyingId = un.id, @@ -65,6 +146,7 @@ namespace YLErp.Modules.EodModule UnderlyingState = un.UnderlyingState, UnderlyingType = un.UnderlyingType, UnderlyingInstrumentType = "CommodityFutures", + RealInstrumentType = un.UnderlyingInstrumentType, ValueDate = source.ValueDate, SettlePrice = source.SettlePrice, ClosePrice = source.ClosePrice, @@ -73,8 +155,7 @@ namespace YLErp.Modules.EodModule SourceTime = source.SourceTime, DeciClosePrice=0, DeciSettlePrice = 0, - DeciReferencePrice=0, - JSID = null + DeciReferencePrice=0 }; var query2 = from un in queryUn @@ -84,6 +165,7 @@ namespace YLErp.Modules.EodModule IsBond = false, id = stockClose.id, DataSource = stockClose.DataSource, + UpdateUser = (long?)null, // 对齐 Concat 投影成员,见 query1 注释 LaunchState = un.LaunchState, MarketName = un.MarketName, UnderlyingId = un.id, @@ -92,6 +174,7 @@ namespace YLErp.Modules.EodModule UnderlyingState = un.UnderlyingState, UnderlyingType = un.UnderlyingType, UnderlyingInstrumentType = "Stock", + RealInstrumentType = un.UnderlyingInstrumentType, ValueDate = stockClose.ValueDate, SettlePrice = stockClose.ClosePrice, ClosePrice = stockClose.ClosePrice, @@ -100,8 +183,7 @@ namespace YLErp.Modules.EodModule SourceTime = stockClose.SourceTime, DeciClosePrice = 0, DeciSettlePrice = 0, - DeciReferencePrice = 0, - JSID=null + DeciReferencePrice = 0 }; var query3 = from un in queryUn join bondClose in DbContext.china_bond_valuation.Where(predicatEob) on un.UnderlyingCode equals bondClose.bond_id @@ -109,7 +191,10 @@ namespace YLErp.Modules.EodModule { IsBond = true, id = bondClose.id, - DataSource="人工", + UpdateUser = bondClose.update_user, + // 债券 DataSource 在后处理统一置为"人工"/"系统"(见下方 foreach); + // 此处仍须显式设 null 以对齐 Concat 各分支投影成员集合(见 query1 注释)。 + DataSource = null, LaunchState = un.LaunchState, MarketName = un.MarketName, UnderlyingId = un.id, @@ -118,6 +203,7 @@ namespace YLErp.Modules.EodModule UnderlyingState = un.UnderlyingState, UnderlyingType = un.UnderlyingType, UnderlyingInstrumentType = un.UnderlyingInstrumentType, + RealInstrumentType = un.UnderlyingInstrumentType, ValueDate = bondClose.valuation_date, SettlePrice=0, DeciSettlePrice =bondClose.net_price, @@ -126,8 +212,7 @@ namespace YLErp.Modules.EodModule UpdateTime = bondClose.update_time, ReferencePrice=0, DeciReferencePrice = bondClose.yield, - SourceTime="", - JSID=bondClose.JSID + SourceTime="" }; var unionQuery = query1.Concat(query2); var finalQuery = unionQuery.Concat(query3); @@ -136,19 +221,18 @@ namespace YLErp.Modules.EodModule req.sidx = "ValueDate"; req.sord = "desc"; } - var result= finalQuery.ToSearchList(req); + var result = finalQuery.ToSearchList(req); + foreach (var item in result.rows) { if (item.IsBond) { + // 债券来源:被手工改过的(update_user 非空)→"人工";其余(中债自动同步)→"系统"。 + item.DataSource = ResolveBondDisplaySource(item.UpdateUser); item.SourceTime = item.UpdateTime.HasValue? item.UpdateTime.Value.ToString("yyyy-MM-dd HH:mm:ss"):""; item.SettlePrice=Convert.ToDouble(item.DeciSettlePrice); item.ClosePrice = Convert.ToDouble(item.DeciClosePrice); item.ReferencePrice = Convert.ToDouble(item.DeciReferencePrice); - if (item.JSID.HasValue) - { - item.DataSource = "系统"; - } } } return result; @@ -189,6 +273,9 @@ namespace YLErp.Modules.EodModule dbmodel.DataSource = EodPriceBase.人工; + // 入库前强制 UnderlyingId 与 UnderlyingCode(FutureContractId) 一致,避免网页/结算两套 JOIN 失同步。 + SyncUnderlyingIdFromCode(DbContext, dbmodel); + DbContext.SaveChanges(); return dbmodel; @@ -215,11 +302,42 @@ namespace YLErp.Modules.EodModule } UpdateChanges(dbmodel, req); } + // 记录手工编辑人:写入登录用户ID到已有列(create_user/update_user), + // 不新增字段。聚源同步路径(SettlementPriceImportService)不写这两列,故 NULL 即"自动同步"。 + StampBondOperator(dbmodel, UserId, req.id == 0); dbmodel.update_time = DateTime.Now; DbContext.SaveChanges(); return dbmodel; } + + /// + /// 标记债券估值(china_bond_valuation)的操作人。 + /// 该表已有 create_user/update_user 两列(bigint),但聚源同步路径不写入, + /// 因此:NULL = 聚源/中债自动同步;有值 = 被人手工编辑(记录登录用户ID)。 + /// 抽出为纯静态函数,供 SaveBondPrice 与单元测试共用。 + /// + /// 债券估值实体 + /// 当前登录用户ID + /// 是否为新增(true 时同时写 create_user) + public static void StampBondOperator(ChinaBondValuation model, int userId, bool isNew) + { + model.update_user = userId; + if (isNew) + { + model.create_user = userId; + } + } + + /// + /// 债券来源列该显示什么:被手工改过的(update_user 有值)→"人工";其余(中债自动同步)→"系统"。 + /// 抽为纯静态函数,便于无库单元测试。 + /// + public static string ResolveBondDisplaySource(long? updateUser) + { + return updateUser.HasValue ? EodPriceBase.人工 : EodPriceBase.系统; + } + /// /// 保存日终股票价格 /// @@ -313,7 +431,13 @@ namespace YLErp.Modules.EodModule public string UnderlyingInstrumentType { get; set; } - public string UnderlyingInstrumentTypeCn => ConsGlobal.InstrumentType.GetDesc(UnderlyingInstrumentType); + /// + /// 真实标的种类(取自 underlying_manager),仅供列表"标的种类"列显示。 + /// UnderlyingInstrumentType 仍作为"存储表路由键"使用,二者解耦,避免改动历史路由逻辑。 + /// + public string RealInstrumentType { get; set; } + + public string UnderlyingInstrumentTypeCn => ConsGlobal.InstrumentType.GetDesc(RealInstrumentType ?? UnderlyingInstrumentType); public string UnderlyingState { get; set; } @@ -337,6 +461,11 @@ namespace YLErp.Modules.EodModule public bool IsBond { get; set; } - public long? JSID { get; set; } + /// + /// 手工改过估值时的操作人ID(china_bond_valuation.update_user)。 + /// NULL = 中债自动同步;有值 = 被人手工改过(来源列显示"人工")。 + /// 仅债券行可能非空,用于列表来源列区分"人工"/"系统"。 + /// + public long? UpdateUser { get; set; } } } diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodSyntheticPriceSaveService.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodSyntheticPriceSaveService.cs index 370d8ad2..aac67efa 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodSyntheticPriceSaveService.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodSyntheticPriceSaveService.cs @@ -143,7 +143,7 @@ namespace YLErp.Modules.EodModule.SettlementModule dto.CommodityPrice.OptDate = DateTime.Now; dto.CommodityPrice.ClosePrice = closePrice; dto.CommodityPrice.SettlePrice = settlePrice; - dto.CommodityPrice.DataSource = "系统"; + dto.CommodityPrice.DataSource = EodPriceBase.系统; } } else @@ -156,9 +156,12 @@ namespace YLErp.Modules.EodModule.SettlementModule UnderlyingId = dto.RelUnderlyingId, ClosePrice = closePrice, SettlePrice = settlePrice, - DataSource = "系统" + DataSource = EodPriceBase.系统 }; + // 入库前强制 UnderlyingId 与 UnderlyingCode(FutureContractId) 一致,避免网页/结算两套 JOIN 失同步。 + EodPriceService.SyncUnderlyingIdFromCode(DbContext, dto.CommodityPrice); + DbContext.eod_commodity_future_price.Add(dto.CommodityPrice); } @@ -207,7 +210,7 @@ namespace YLErp.Modules.EodModule.SettlementModule OptDate = DateTime.Now, ClosePrice = closePrice, SettlePrice = closePrice, - DataSource = "系统", + DataSource = EodPriceBase.系统, }; DbContext.eod_stock_price.Add(dto.StockPrice); diff --git a/YLErpDAL/Modules/EodModule/SettlementPriceImportService.cs b/YLErpDAL/Modules/EodModule/SettlementPriceImportService.cs index 18cae95d..594edcb4 100644 --- a/YLErpDAL/Modules/EodModule/SettlementPriceImportService.cs +++ b/YLErpDAL/Modules/EodModule/SettlementPriceImportService.cs @@ -128,6 +128,10 @@ namespace YLErp.Modules.EodModule if (item.ReferencePrice.HasValue) eodPrice.yield = Convert.ToDecimal(item.ReferencePrice); eodPrice.update_time = DateTime.Now; + // 手工上传也是登录用户的人工动作,戳操作人到已有列(create_user/update_user), + // 使列表来源列显示上传人姓名(与 SaveBondPrice 口径一致);聚源/中债自动同步(外部ETL)不写这两列。 + // eodPrice.id==0 表示本次新增(尚未落库),非0为命中已有行的更新。 + EodPriceService.StampBondOperator(eodPrice, UserId, eodPrice.id == 0); result.SuccessCount++; } else if (!underlying.CalcTypeIsStock()) @@ -154,6 +158,8 @@ namespace YLErp.Modules.EodModule eodPrice.ValueDate = item.date; eodPrice.UnderlyingCode = underlying.UnderlyingCode; eodPrice.UnderlyingId = underlying.id; + // 入库前强制 UnderlyingId 与 UnderlyingCode(FutureContractId) 一致,避免网页/结算两套 JOIN 失同步。 + EodPriceService.SyncUnderlyingIdFromCode(DbContext, eodPrice); if (item.closePrice.HasValue) eodPrice.ClosePrice = item.closePrice ?? 0; if (item.settlePrice.HasValue) diff --git a/YLErpDAL/Modules/ReportModule/SettlementReportModule/DingShiReportEmail.cs b/YLErpDAL/Modules/ReportModule/SettlementReportModule/DingShiReportEmail.cs index 8572925a..077f9a76 100644 --- a/YLErpDAL/Modules/ReportModule/SettlementReportModule/DingShiReportEmail.cs +++ b/YLErpDAL/Modules/ReportModule/SettlementReportModule/DingShiReportEmail.cs @@ -28,6 +28,13 @@ /// public string MarginDetail { get; set; } public int ClientId { get; set; } + + /// + /// 每日估值报告页面选择的簿记账户。为空时按客户维度生成全量报告; + /// 有值时仅筛选互换估值页的交易所属账户。 + /// + public int? BookId { get; set; } + public DateTime From { get; set; } public DateTime To { get; set; } public double PayableMargin { get; set; } diff --git a/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportFotShanXiService.cs b/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportFotShanXiService.cs index 8df595e0..316119e5 100644 --- a/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportFotShanXiService.cs +++ b/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportFotShanXiService.cs @@ -1,9 +1,11 @@ using BaseOUDAL; +using Newtonsoft.Json; using OfficeOpenXml; using OfficeOpenXml.Style; using Org.BouncyCastle.Ocsp; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -14,6 +16,7 @@ using YLErp.BLL; using YLErp.BLL.EodSettlement; using YLErp.Configuration; using YLErp.Core.Helpers; +using YLErp.DBModels; using YLErp.Enums; using YLErp.Helpers; using YLErp.Model; @@ -60,7 +63,16 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule #endregion if (emailData.SendContent.Contains("互换估值")) { - var eodReq = new ClientSwapPositionRequest { ClientId = emailData.ClientId, ValueDate = emailData.To, ValueDateFrom = emailData.From,page=1, rows=10000,StructureType= "普通债券类收益互换" }; + var eodReq = new ClientSwapPositionRequest + { + ClientId = emailData.ClientId, + BookId = emailData.BookId, + ValueDate = emailData.To, + ValueDateFrom = emailData.From, + page = 1, + rows = 10000, + StructureType = "普通债券类收益互换" + }; report.EodSwapPositions = swapEodPositionService.SearchEodPositionList(eodReq).rows.ToList(); } if (emailData.SendContent.Contains("互换持仓明细")) @@ -134,7 +146,7 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule }); if (fileTypes.Contains("pdf")) { - var filepath = GenerateFileEntry(report, "pdf"); + var filepath = GenerateFileEntry(report, "pdf", applyFrontendColumnConfig: false); if (!string.IsNullOrEmpty(filepath)) { attachFiles.Add(filepath); @@ -147,7 +159,7 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule } else { - var filepath = GenerateFileEntry(report, "excel"); + var filepath = GenerateFileEntry(report, "excel", applyFrontendColumnConfig: false); if (!string.IsNullOrEmpty(filepath)) { attachFiles.Add(filepath); @@ -190,15 +202,15 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule /// /// /// - /// + /// 是否按当前用户的前端列配置隐藏并重排互换估值列 /// - public string GenerateFileEntry(ClientDingShiReport_ShanXi report, string type) + public string GenerateFileEntry(ClientDingShiReport_ShanXi report, string type, bool applyFrontendColumnConfig = true) { - var filepath = GenerateReportExcel(report, type.ToLower() == "pdf"); + var filepath = GenerateReportExcel(report, type.ToLower() == "pdf", applyFrontendColumnConfig); return filepath; } - public string GenerateReportExcel(ClientDingShiReport_ShanXi report,bool needToPdf) + public string GenerateReportExcel(ClientDingShiReport_ShanXi report, bool needToPdf, bool applyFrontendColumnConfig = true) { var tempFolder = OtcAppContext.MapPath("~/App_Docs/Temp/结算报告"); tempFolder = MosPathHelper.Combine(tempFolder, ""); @@ -208,7 +220,7 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule Directory.CreateDirectory(targetPath); } var clientName = report.client.Name; - var fileName = report.ReportFrom == DateTime.MinValue ? $"证券_估值表_{report.ReportEnd:yyyyMMdd}_{clientName}" : $"证券_估值表_{report.ReportFrom:yyyyMMdd}_{report.ReportEnd:yyyyMMdd}_{clientName}"; + var fileName = $"{clientName}_每日估值报告_{report.ReportEnd:yyyyMMdd}"; var targetFileName = Path.Combine(targetPath, $"{fileName}.xlsx"); var excelDeclareModel = new ExcelDeclareModel() @@ -238,14 +250,22 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule { modelDict.Add("互换估值", new { + // 明细列表供模板渲染;以下 *Sum 字段用于“互换估值”页签合计行。 EodSwapPositions = report.EodSwapPositions, + // 持仓规模、期间收益和利息/分红类金额合计。 PosiNotionalValueSum= report.EodSwapPositions.Sum(x => x.position.PosiNotionalValue), - PosiQuantitySum= report.EodSwapPositions.Sum(x => x.position.PosiQuantity), - PeriodAmountSum= report.EodSwapPositions.Sum(x => x.PeriodAmount), + PeriodAmountSum= report.EodSwapPositions.Sum(x => x.PeriodAmount) ?? 0m, + DividendAmountSum = report.EodSwapPositions.Sum(x => x.DividendAmount) ?? 0m, InterestAmountSum= report.EodSwapPositions.Sum(x => x.InterestAmount), PosiFeePendingSum = report.EodSwapPositions.Sum(x => x.position.PosiFeePending), PosiProfitSum= report.EodSwapPositions.Sum(x => x.position.PosiProfitSum), + // 保证金相关收益和保证金占用金额合计。 + MarginInterestAmountSum = report.EodSwapPositions.Sum(x => x.MarginInterestAmount), + OpenMarginAmountSum = report.EodSwapPositions.Sum(x => x.OpenMarginAmount), + AdditionalMarginAmountSum = report.EodSwapPositions.Sum(x => x.AdditionalMarginAmount), + // 净结算金额为日终估值口径;TRS价值在净结算金额基础上叠加期初/追加保证金。 NetSettmentAmountSum= report.EodSwapPositions.Sum(x => x.NetSettmentAmount), + TrsValueSum = report.EodSwapPositions.Sum(x => x.TrsValue), }); } if (report.SwapPositions != null) @@ -287,7 +307,14 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule string sourceFileName = Path.Combine(sourcePath, $"结算报告模板.xlsx"); ExcelPackage.LicenseContext = LicenseContext.NonCommercial; var pdffile = ExcelTemplate.GeneratePDFFromExeclTemplate(sourcePath, sourceFileName, modelDict, targetPath, targetFileName - , shouldDeleteSheet: true, needToPdf: false); + , shouldDeleteSheet: true, needToPdf: false, callback: sheets => + { + FormatSwapValuationDisplayCells(sheets, report.EodSwapPositions); + if (applyFrontendColumnConfig) + { + ApplySwapValuationColumnConfig(sheets); + } + }); if (needToPdf) { var targetPdfFileName = FileHelper.ReplaceExtension(targetFileName, ".pdf"); @@ -297,6 +324,200 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule return Path.Combine(targetPath, targetFileName); } + /// + /// 部分 Office 版本会将可选小数格式(例如 #,##0.##)错误显示为 20,000.。 + /// 互换估值中的这些列是展示字段,不参与 Excel 公式计算,因此在模板替换完成后写为已格式化文本, + /// 以遵守各字段的去尾零或固定小数位展示口径,且避免留下孤立的小数点。 + /// + private static void FormatSwapValuationDisplayCells(IEnumerable sheets, IEnumerable positions) + { + var worksheet = sheets.FirstOrDefault(x => x.Name == "互换估值"); + var positionList = positions?.ToList() ?? new List(); + if (worksheet == null || !positionList.Any()) + { + return; + } + + const int dataStartRow = 2; + for (var index = 0; index < positionList.Count; index++) + { + var row = dataStartRow + index; + var item = positionList[index]; + SetTrimmedExcelText(worksheet.Cells[row, 8], item.position.PosiNotionalValue, 2); + SetTrimmedExcelText(worksheet.Cells[row, 9], item.position.PosiQuantity, 9); + SetTrimmedExcelText(worksheet.Cells[row, 10], item.PeriodAmount, 2); + SetTrimmedExcelText(worksheet.Cells[row, 11], item.DividendAmount, 2); + SetTrimmedExcelText(worksheet.Cells[row, 13], item.InitYtm, 4, percent: true); + // 浮动利率(绝对)和预付金利率要求固定保留四位小数,不去尾零。 + // 模板首列为空白占位列,渲染后的工作表会移除该列;最终文件中两列分别为 P、T。 + SetFixedExcelText(worksheet.Cells[row, 16], item.FloatRateAbs, 4, percent: true); + SetFixedExcelText(worksheet.Cells[row, 20], item.OpenMarginRate, 4, percent: true); + } + + var totalRow = dataStartRow + positionList.Count; + SetTrimmedExcelText(worksheet.Cells[totalRow, 8], positionList.Sum(x => x.position.PosiNotionalValue), 2); + SetTrimmedExcelText(worksheet.Cells[totalRow, 10], positionList.Sum(x => x.PeriodAmount) ?? 0m, 2); + SetTrimmedExcelText(worksheet.Cells[totalRow, 11], positionList.Sum(x => x.DividendAmount) ?? 0m, 2); + } + + private static void SetTrimmedExcelText(ExcelRange cell, decimal? value, int decimalPlaces, bool percent = false) + { + if (!value.HasValue) + { + cell.Value = null; + return; + } + + var displayValue = percent ? value.Value * 100m : value.Value; + var format = "#,##0." + new string('#', decimalPlaces); + var text = displayValue.ToString(format, CultureInfo.InvariantCulture).TrimEnd('.'); + cell.Value = percent ? text + "%" : text; + cell.Style.Numberformat.Format = "@"; + } + + private static void SetFixedExcelText(ExcelRange cell, decimal? value, int decimalPlaces, bool percent = false) + { + if (!value.HasValue) + { + cell.Value = null; + return; + } + + var displayValue = percent ? value.Value * 100m : value.Value; + var text = displayValue.ToString("F" + decimalPlaces, CultureInfo.InvariantCulture); + cell.Value = percent ? text + "%" : text; + cell.Style.Numberformat.Format = "@"; + } + + /// + /// 每日估值报告的互换估值导出使用当前用户已保存的列配置, + /// 同时同步业务字段的显示状态和列顺序。 + /// + private void ApplySwapValuationColumnConfig(IEnumerable sheets) + { + var worksheet = sheets.FirstOrDefault(x => x.Name == "互换估值"); + if (worksheet == null) + { + return; + } + + var config = configcolumnBLL.GetData(UserId, configcolumn_data.互换估值V1); + if (string.IsNullOrWhiteSpace(config?.data)) + { + return; + } + + List columns; + try + { + columns = JsonConvert.DeserializeObject>(config.data); + } + catch (JsonException) + { + // 列配置损坏时保留完整的模板字段,不能导致每日估值报告导出失败。 + return; + } + + if (columns == null || !columns.Any()) + { + return; + } + + var columnIndexByName = new Dictionary + { + ["ConfrimNo"] = 1, + ["TradeNumber"] = 2, + ["position.PosiStartDate"] = 3, + ["MaturitySettlementDate"] = 4, + ["position.ValueDate"] = 5, + ["position.UnderlyingCode"] = 6, + ["InterestRate"] = 7, + ["position.PosiNotionalValue"] = 8, + ["position.PosiQuantity"] = 9, + ["PeriodAmount"] = 10, + ["DividendAmount"] = 11, + ["position.PosiGrossPrice"] = 12, + ["InitYtm"] = 13, + ["position.UnderlyingPrice"] = 14, + ["DayCount"] = 15, + ["FloatRateAbs"] = 16, + ["InterestAmount"] = 17, + ["position.PosiProfitSum"] = 18, + ["position.PosiFeePending"] = 19, + ["OpenMarginRate"] = 20, + ["MarginInterestAmount"] = 21, + ["OpenMarginAmount"] = 22, + ["AdditionalMarginAmount"] = 23, + ["NetSettmentAmount"] = 24, + ["TrsValue"] = 25 + }; + // 前端 _.uniqBy 保留首次出现的配置项,导出按相同规则过滤重复字段, + // 并保留该列表的原始顺序作为 Excel 列顺序。 + var visibleColumnIndexes = new List(); + var configuredNames = new HashSet(); + foreach (var column in columns) + { + if (column == null || string.IsNullOrEmpty(column.name) || !configuredNames.Add(column.name)) + { + continue; + } + + if (!column.hidden && columnIndexByName.TryGetValue(column.name, out var index)) + { + visibleColumnIndexes.Add(index); + } + } + + // 与前端一致:旧配置缺少的新增字段默认隐藏;若没有任何业务列可见, + // 前端会回退展示全部默认列,导出保持模板默认顺序。 + if (!visibleColumnIndexes.Any()) + { + return; + } + + ReorderSwapValuationColumns(worksheet, visibleColumnIndexes); + } + + /// + /// 使用临时工作表保存可见列,再按目标顺序复制回原工作表。 + /// 原工作表始终保持在模板的 25 列范围内,避免 EPPlus 扩列时触发 ColumnMax 冲突。 + /// + private static void ReorderSwapValuationColumns(ExcelWorksheet worksheet, IReadOnlyList visibleSourceIndexes) + { + var lastRow = worksheet.Dimension.End.Row; + var originalColumnCount = worksheet.Dimension.End.Column; + var workbook = worksheet.Workbook; + var bufferName = "__swap_cols_" + Guid.NewGuid().ToString("N").Substring(0, 12); + var buffer = workbook.Worksheets.Add(bufferName); + try + { + for (var targetIndex = 0; targetIndex < visibleSourceIndexes.Count; targetIndex++) + { + var sourceColumn = visibleSourceIndexes[targetIndex]; + var bufferColumn = targetIndex + 1; + worksheet.Cells[1, sourceColumn, lastRow, sourceColumn] + .Copy(buffer.Cells[1, bufferColumn, lastRow, bufferColumn]); + } + + for (var targetIndex = 0; targetIndex < visibleSourceIndexes.Count; targetIndex++) + { + var targetColumn = targetIndex + 1; + buffer.Cells[1, targetColumn, lastRow, targetColumn] + .Copy(worksheet.Cells[1, targetColumn, lastRow, targetColumn]); + } + + var columnsToDelete = originalColumnCount - visibleSourceIndexes.Count; + if (columnsToDelete > 0) + { + worksheet.DeleteColumn(visibleSourceIndexes.Count + 1, columnsToDelete); + } + } + finally + { + workbook.Worksheets.Delete(buffer); + } + } + /// /// 财务状况 /// diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 8c557d4b..eb960cc4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -113,6 +113,11 @@ namespace YLErp.Modules.SwapModule new TradeUnwindService(this).CloseReCheck_SetTrade(swapTradeId, isSwap, needCheck); } + protected virtual DateTime GetMaxIncomeValueDate(trade td) + { + return td.ExerciseDate.Value.AddDays(-1); + } + #endregion public SwapDealService(OptUserInfo optUser) : base(optUser) @@ -158,6 +163,8 @@ namespace YLErp.Modules.SwapModule PosiGrossPrice = floatLeg.PosiGrossPrice, // EntryDirtyPrice TradingAmountAvg = floatLeg.TradingAmountAvg, // ExitDirtyPrice(界面×multiplier形态) CloseQty = unwindData.CloseQty, + PositionQty = unwindData.PositionQty, + ContractSize = floatLeg.ContractSize, CloseNotionalValue = unwindData.CloseNotionalValue, PayDirection = floatLeg.PayDirection, PositionType = floatLeg.PositionType, @@ -259,7 +266,10 @@ namespace YLErp.Modules.SwapModule unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.CloseMethod = (int)CloseMethodEnum.全部平仓; - unwindData.ClosePercent = 1; + // 占期初(original)语义(A):默认"平掉剩余全部持仓" = 剩余名义本金/期初名义本金。 + // 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);部分平仓后自动变为剩余比例(如已平10%则默认90%)。 + // 与互换/提前终止 InitIncome(L447) 保持一致。 + unwindData.ClosePercent = CalcDefaultInitClosePercent(unwindData.NotionalValue, unwindData.PosiNotionalValue); unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; unwindData.CloseQty = unwindData.PositionQty; if (position != null) @@ -394,7 +404,8 @@ namespace YLErp.Modules.SwapModule var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); List eventTypes = new List() { (int)SwapFlowEventTypeEnum.互换, (int)SwapFlowEventTypeEnum.自动互换 }; - var dealDate = valuedateBLL.ValueDate < td.ExerciseDate.Value ? valuedateBLL.ValueDate : td.ExerciseDate.Value; + var maxIncomeValueDate = GetMaxIncomeValueDate(td); + var dealDate = valuedateBLL.ValueDate.Date > maxIncomeValueDate.Date ? maxIncomeValueDate : valuedateBLL.ValueDate; // 收益结算不检查收盘限制 var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); td.trade_extend = tradeExtend; @@ -425,7 +436,7 @@ namespace YLErp.Modules.SwapModule unwindData.UnwindDate = dealDate; floatEvent.UnwindDate = unwindData.UnwindDate; floatEvent.EventDate = dealDate; - unwindData.PayDate = QdpCalendarHelper.GetNonHoliday(unwindData.UnwindDate.Value.AddDays(td.trade_extend.ExtendObj.SettlementRules)); + unwindData.PayDate = valuedateBLL.ValueDate; floatEvent.PayDate = unwindData.PayDate; floatEvent.SwapTradeId = tradeId; floatEvent.SwapTradeNo = td.TradeNumber; @@ -436,7 +447,7 @@ namespace YLErp.Modules.SwapModule unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); - unwindData.PositionQty = Convert.ToDecimal(td.TradeAmount); + unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.ClosePercent = unwindData.PosiNotionalValue / unwindData.NotionalValue; unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; @@ -464,6 +475,7 @@ namespace YLErp.Modules.SwapModule } unwindData.FlowEvents.Add(floatEvent); } + unwindData.MaxIncomeValueDate = maxIncomeValueDate; return unwindData; } /// @@ -493,7 +505,10 @@ namespace YLErp.Modules.SwapModule var allpositions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid).ToList(); var origPositions = allpositions.Where(x => x.IsInitial).ToList(); var realPostitions = allpositions.Where(x => !x.IsInitial).ToList(); - var positions = origPositions.Where(x => x.PosiDirection == 0).ToList(); + // 根因修复(多次部分平仓预付金返还错误):见 ResolveInterestLegPositions 注释。 + // 迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配), + // 仅对预付金腿以实时腿的剩余本金克隆覆盖,故此处不改任何日终匹配行为。 + var positions = ResolveInterestLegPositions(origPositions, realPostitions); var fpositions = origPositions.Where(x => x.PosiDirection > 0).ToList(); var longPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).ToList(); var shortPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).ToList(); @@ -514,6 +529,40 @@ namespace YLErp.Modules.SwapModule return interests; } + /// + /// 解析利息腿(PosiDirection==0)持仓,供 GetUnwindInterests 使用。抽为纯函数以便无库单测。 + /// 根因(多次部分平仓预付金返还错误):预付金腿(初始/追加)的"当前剩余本金"存于实时持仓 + /// realPositions.InterestPrincipalFix,每次平仓由 UpdateInitalPosition 递减;而原始腿 + /// origPositions(IsInitial=1)的 InterestPrincipalFix 恒为初始值。GetInterests 算 + /// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv(InitSwapDealInterest) 时都读 + /// position.InterestPrincipalFix,若沿用原始腿,会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。 + /// 修复:迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配, + /// 全库实测 eod 均按 orig.id 归档;若换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿 + /// Clone 覆盖其本金值为实时腿的剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。 + /// 首次平仓时 orig==real 行为不变;仅在发生过部分平仓后 real≠orig 时用实时腿本金纠正。 + /// + /// 原始腿(IsInitial=1)全集 + /// 实时腿(IsInitial=0)全集,其 PositionId 指向对应 orig 的 id + /// 利息腿(PosiDirection==0)列表:预付金腿本金已对齐实时剩余本金,其余保持原始腿 + public static List ResolveInterestLegPositions(List origPositions, List realPositions) + { + realPositions ??= new List(); + return origPositions.Where(x => x.PosiDirection == 0).Select(p => + { + if (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金) + { + var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); + if (realLeg != null && realLeg.InterestPrincipalFix != p.InterestPrincipalFix) + { + var clone = p.Clone(); + clone.InterestPrincipalFix = realLeg.InterestPrincipalFix; + return clone; + } + } + return p; + }).ToList(); + } + /// /// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。 /// 数据源为事件级 swap_flow_event.InterestAmount(互换/自动互换 完成态事件,互换当时即落库,不依赖日终归档)。 @@ -682,6 +731,42 @@ namespace YLErp.Modules.SwapModule return (closePrincipal, posiPrincipal, newClosePercent); } + /// + /// 平仓比例口径转换(解决"显示占期初 / 计算占剩余"双语义问题)。 + /// 前端与事件列表展示用"占期初(original)"语义(A);后端 CalcNotionalByMode / 费用递减 / + /// 全平判定均按"占剩余(remaining)"语义(B)消费。 + /// A → B:B = A × 期初名义本金(NotionalValue) / 剩余名义本金(PosiNotionalValue),并 cap 到 1。 + /// B → A:A = B × 剩余名义本金 / 期初名义本金。 + /// 分母为 0(无持仓等异常场景)时原样返回,避免除零。 + /// + public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (posiNotionalValue <= 0) return originalClosePercent; + var remaining = originalClosePercent * notionalValue / posiNotionalValue; + return remaining > 1 ? 1 : remaining; + } + + /// + /// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。 + /// + public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (notionalValue <= 0) return remainingClosePercent; + return remainingClosePercent * posiNotionalValue / notionalValue; + } + + /// + /// 计算 InitUnwind 默认占期初(A)平仓比例 = "平掉剩余全部持仓"对应的占期初比例。 + /// 即:ClosePercent(A) = PosiNotionalValue / NotionalValue。 + /// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%); + /// 部分平仓后自动变为剩余比例(如已平 30% 则默认 0.7)。 + /// 与互换/提前终止 InitIncome 保持一致。抽出为纯函数以支持无库单测。 + /// + public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue) + { + return notionalValue > 0 ? posiNotionalValue / notionalValue : 1; + } + /// /// 获取固定利率 /// @@ -860,6 +945,19 @@ namespace YLErp.Modules.SwapModule interest.DataState = (int)SwapFlowDateStateEnum.完成; interest.ClientId = td.ClientId; interest.UnwindDate = endDate; + + // 根因修复:预付金(保证金)腿的计息基数维度应为"保证金本金"自身,而非整笔交易的名义本金(orginPv)。 + // 否则公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv + // 会把交易名义本金(千万~亿级)当减项扣掉,使"应返还本金"(InterestPrincipal)与计息基数变成巨负值。 + // 此处将预付金腿的 orginPv 对齐为其自身保证金(InterestPrincipalFix), + // 与日终路径(SwapEodPositionService 对预付金腿 orginPv=InterestPrincipalFix)保持一致。 + // 仅作用于初始预付金(5)/追加预付金(6);其它计息模式(含债券本金腿 标的期初全价=9)仍用交易名义本金,不受影响。 + if (position.InterestMode == (int)InterestModeEnum.初始预付金 + || position.InterestMode == (int)InterestModeEnum.追加预付金) + { + orginPv = position.InterestPrincipalFix; + } + if (swap) { interest.InterestAmount = 0; @@ -994,30 +1092,28 @@ namespace YLErp.Modules.SwapModule if (!calcLast && accrueDate == endDate) continue; // 到期日不算尾 if (accrueDate > preEodPosition.ValueDate) { - if (i % interestPeriod == 0) + // 重置日重新获取该段浮动利率;非重置日沿用上一段利率。 + // 两分支唯一差异即"是否重取利率",本金口径(只缩放一次)完全一致, + // 合并后消除复制粘贴导致的 closePercent^N 类 bug(原非重置日分支多了一行 + // tdDynomicPrincipal = flowEvent.InterestPrincipal 使本金累积乘 closePercent^N)。 + if (i % interestPeriod == 0 && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) { - // 获取新的浮动利率 - if (!string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) + var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0)); + if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1)) { - var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0)); - if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1)) - { - if (floatRate1 != 0) floatRate = floatRate1; - } - else - { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); - } + if (floatRate1 != 0) floatRate = floatRate1; + } + else + { + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); } - flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent; - TdInterestPrincipal = tdDynomicPrincipal; - } - else - { - flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent; - tdDynomicPrincipal = flowEvent.InterestPrincipal; - TdInterestPrincipal = tdDynomicPrincipal; } + + // 显示本金 = 计息基数 × closePercent(只缩放一次,与日终 ByEod 口径一致); + // 计息基数(tdDynomicPrincipal)逐日恒定、不缩放(单利特征)。 + flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent; + TdInterestPrincipal = tdDynomicPrincipal; + flowEvent.FloatRate = Convert.ToDecimal(floatRate); var interest1 = flowEvent.InterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate)); var tdinterest1 = TdInterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate)); @@ -1171,6 +1267,9 @@ namespace YLErp.Modules.SwapModule } //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 + // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 + // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 + unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); bool cofirm = false; ExecuteInTransaction(() => { @@ -1645,6 +1744,8 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + NormalizeIncomeUnwindDate(unwindData); + ValidateIncomeValueDate(unwindData, td); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: true); // 只读校验告警,不阻断交易 ExecuteInTransaction(() => @@ -1683,6 +1784,11 @@ namespace YLErp.Modules.SwapModule throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); + if (eventType == (int)SwapEventTypeEnum.互换) + { + NormalizeIncomeUnwindDate(swapEvent.unwindData); + ValidateIncomeValueDate(swapEvent.unwindData, td); + } var flowList = FindFlowEventsByEventId(swapEvent.id); string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; int clientCashId = AddClientCash(td, Convert.ToDouble(-swapEvent.unwindData.SwapRealizedPnL), action, swapEvent.unwindData.ValueDate); @@ -1726,15 +1832,52 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + if (eventType == (int)SwapEventTypeEnum.互换) + { + NormalizeIncomeUnwindDate(unwindData); + ValidateIncomeValueDate(unwindData, td); + } unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; + // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 + // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 + // 与 SwapUnwind(L1270) 保持一致——缺少此转换会导致 SaveSwapDealInternal 的 B→A 还原出错 + // (例如第二次部分平仓 50%(A) → 错误还原为 0.325 而非 0.50)。 + unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; ExecuteInTransaction(() => { CloseReCheckSetTrade(unwindData.SwapTradeId, eventType == (int)SwapEventTypeEnum.互换, true); SaveSwapDeal(unwindData, eventType, 0, action, true); SaveAllChanges(); + // 需求①:若触发条件判定无需审批(CloseReCheck_SetTrade 已将 ProcessOrderId 设为审批通过), + // 在 swap_event 记录创建完成后再执行审批通过流程。 + td = FindTrade(unwindData.SwapTradeId); + if (td.ProcessOrderId == ProcessTradeLog.审批通过) + { + new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel + { + tradeId = td.id, + status = "pass", + comments = "触发条件未满足,自动跳过审批", + notNeedOperationHistory = false + }); + } }); } + private void ValidateIncomeValueDate(UnwindData unwindData, trade td) + { + var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date; + if (unwindData.ValueDate.Date > maxIncomeValueDate) + { + throw new ServiceException($"手动互换结算日期不能晚于当前交易结束日期T-1:{maxIncomeValueDate:yyyy-MM-dd}"); + } + } + + private void NormalizeIncomeUnwindDate(UnwindData unwindData) + { + unwindData.UnwindDate = unwindData.ValueDate; + } + /// /// 保存平仓/互换事件 /// @@ -1749,7 +1892,13 @@ namespace YLErp.Modules.SwapModule } var flowList = new List(unwindData.FlowEvents); unwindData.FlowEvents.Clear(); + // 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。 + // 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。 + var storedClosePercent = ToOriginalClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); + var incomingClosePercent = unwindData.ClosePercent; + unwindData.ClosePercent = storedClosePercent; string data = JsonConvert.SerializeObject(unwindData); + unwindData.ClosePercent = incomingClosePercent; var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, eventType, data, clientCashId, true, eventResason);//将平仓、互换总额存入事件 foreach (var item in flowList) { diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 9007286c..6865b195 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1,6 +1,7 @@ using BaseOUDAL; using Newtonsoft.Json; using NPOI.POIFS.Properties; +using System; using System.Linq.Expressions; using YLErp.DBModels; using YLErp.DBModels.Consts; @@ -155,6 +156,36 @@ namespace YLErp.Modules.SwapModule return UnderlyingCodePrice(code, settleDate, out vobp); } + /// + /// 获取用于互换浮动腿盯市的标的价格。 + /// + /// 普通债券类收益互换的新录入页面将全价按小数保存,例如页面录入 20% 后 + /// PosiGrossPrice 为 0.2;而历史交易中仍可能存在直接保存为 20 的展示态价格。 + /// 中债估值正常经 EodPriceQueryService 转换后应为小数价格,但手工维护的历史 + /// 行情可能仍以展示态进入该服务,例如 2000 经一次转换后得到 20。若将 20 + /// 与 0.2 直接相减,会把 20% 的价格差误算成 1,980,000 的浮动损益。 + /// + /// 因此仅当交易期初价已经是小数口径、且当前债券价明显仍处于展示态时,再做 + /// 一次展示态到存储态转换。期初价本身是历史展示态口径的存量交易保持原价格, + /// 避免修改日终估值链路后改变其既有损益。 + /// + private decimal GetSwapValuationPrice(string code, decimal posiGrossPrice, DateTime settleDate, out decimal vobp) + { + var price = GetUnderlyingPrice(code, settleDate, out vobp); + var underlying = GetUnderlyingData(code); + var usesStoragePrice = Math.Abs(posiGrossPrice) < 2m; + var usesDisplayPrice = Math.Abs(price) >= 10m; + + if (underlying?.IsBond() == true && usesStoragePrice && usesDisplayPrice) + { + var normalizedPrice = BondPriceConverter.ToStorage(price); + Log.Error($"互换债券日终价格按展示态返回,已转换为存储态: UnderlyingCode={code}, ValueDate={settleDate:yyyy-MM-dd}, PosiGrossPrice={posiGrossPrice}, SourcePrice={price}, NormalizedPrice={normalizedPrice}"); + return normalizedPrice; + } + + return price; + } + /// 获取标的缓存数据(生产: DataCacheProvider;测试: 返回内存对象) protected virtual underlying_manager GetUnderlyingData(string underlyingCode) { @@ -167,6 +198,51 @@ namespace YLErp.Modules.SwapModule return new BondPaymentService(UserInfo).CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio); } + // ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)---- + + /// 查找收盘所需的活跃互换交易(生产: DbContext.trade.Where;测试: 内存列表) + protected virtual List FindActiveSwapTrades(DateTime settleDate, IEnumerable clientIds) + { + var tradePredicate = PredicateBuilder.Create(n => n.ValidState != ConsGlobal.InValid + && n.TradeType == "收益互换" + && n.TradeDate <= settleDate + && n.ExerciseDate >= settleDate + && (n.TradeStatus == ConsTrade.确认成交 || n.UnWindDate >= settleDate) + ); + if (clientIds != null && clientIds.Any()) + { + tradePredicate = tradePredicate.And(x => clientIds.Contains(x.ClientId)); + } + return DbContext.trade.Where(tradePredicate).ToList(); + } + + /// 查找交易的所有持仓(含初始+实际,生产: DbContext.swap_position;测试: 内存列表) + protected virtual List FindAllSwapPositions(List tradeIds) + { + return DbContext.swap_position.Where(t => tradeIds.Contains(t.SwapTradeId) && !t.Invalid).ToList(); + } + + /// 批量查找交易扩展(生产: DbContext.trade_extend;测试: 内存列表) + protected virtual List FindTradeExtends(List tradeIds) + { + return DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); + } + + /// 查找指定日期的日终汇总(生产: DbContext.eod_swap;测试: 内存列表) + protected virtual List FindEodSwapsByDate(DateTime valueDate) + { + return DbContext.eod_swap.Where(x => x.ValueDate == valueDate).ToList(); + } + + /// 查找交易在指定日期的完成流水事件(生产: DbContext.swap_flow_event;测试: 内存列表) + protected virtual List FindFlowEvents(int swapTradeId, DateTime settleDate) + { + Expression> eventExpression = x => x.SwapTradeId == swapTradeId + && x.DataState == (int)SwapFlowDateStateEnum.完成 + && x.EventDate == settleDate; + return DbContext.swap_flow_event.Where(eventExpression).ToList(); + } + #endregion /// @@ -205,28 +281,17 @@ namespace YLErp.Modules.SwapModule { var dateStr = settleDate.ToString("yyyy-MM-dd"); Log.Info("SwapPositionCompose:" + "settleDate:" + settleDate + " preSettleDate:" + preSettleDate + " ClientIds:" + JsonHelper.Serialize(ClientIds)); - var tradePredicate = PredicateBuilder.Create(n => n.ValidState != ConsGlobal.InValid - && n.TradeType == "收益互换" - && n.TradeDate <= settleDate - && n.ExerciseDate >= settleDate - && (n.TradeStatus == ConsTrade.确认成交 || n.UnWindDate >= settleDate) - ); - if (ClientIds != null && ClientIds.Any()) - { - tradePredicate = tradePredicate.And(x => ClientIds.Contains(x.ClientId)); - } - var tradeQueryList = DbContext.trade.Where(tradePredicate).ToList(); + var tradeQueryList = FindActiveSwapTrades(settleDate, ClientIds); var tradeIds = tradeQueryList.Select(s => s.id).ToList(); - var allTradePositionList = DbContext.swap_position.Where(t => tradeIds.Contains(t.SwapTradeId) && !t.Invalid).ToList(); + var allTradePositionList = FindAllSwapPositions(tradeIds); var tradePositionList = allTradePositionList.Where(t => t.IsInitial).ToList(); var tradeRealPositionList = allTradePositionList.Where(t => !t.IsInitial).ToList(); - var tradeExtendList = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); - var eodSwapList = DbContext.eod_swap.Where(x => x.ValueDate == preSettleDate).ToList(); + var tradeExtendList = FindTradeExtends(tradeIds); + var eodSwapList = FindEodSwapsByDate(preSettleDate); List eventTyps = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; foreach (var td in tradeQueryList) { - var trans = DbContext.Database.BeginTransaction(); - try + ExecuteInTransaction(() => { List removeEventTyps = new List() { (int)SwapEventTypeEnum.自动互换 }; bool longShort = td.StructureType == ClientMarginTypeEnum.多空组合.ToString(); @@ -243,7 +308,7 @@ namespace YLErp.Modules.SwapModule { throw new Exception($"交易{td.TradeNumber}在上一交易日【{preSettleDate:yyyy-MM-dd}】未收盘"); } - var allEodPositions = DbContext.eod_swap_position.Where(x => x.ValueDate >= preSettleDate && x.SwapTradeId == td.id && !x.Invalid); + var allEodPositions = FindEodSwapPositions(td.id, preSettleDate); var eodPositions = allEodPositions.Where(x => x.ValueDate == preSettleDate).ToList();//上一日终持仓信息 @@ -256,18 +321,7 @@ namespace YLErp.Modules.SwapModule { throw new Exception($"交易【{td.TradeNumber}】到期扔有持仓信息"); } - var flowEvents = new List(); - Expression> eventExpression = x => x.SwapTradeId == td.id && x.DataState == (int)SwapFlowDateStateEnum.完成; - eventExpression = eventExpression.And(x => x.EventDate == settleDate); - //if (settleDate == td.TradeDate) - //{ - // eventExpression = eventExpression.And(x => x.EventDate == settleDate); - //} - //else - //{ - // eventExpression = eventExpression.And(x => x.UnwindDate == settleDate); - //} - flowEvents = DbContext.swap_flow_event.Where(eventExpression).ToList(); + var flowEvents = FindFlowEvents(td.id, settleDate); var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期 List autoInterests = new List();//自动互换利息腿信息 //处理浮动腿 @@ -296,18 +350,8 @@ namespace YLErp.Modules.SwapModule td.TradeStatus = "已到期"; td.UnWindDate = settleDate; } - DbContext.SaveChanges(); - trans.Commit(); - } - catch (Exception ex) - { - trans.Rollback(); - throw new Exception(ex.Message, ex); - } - finally - { - trans.Dispose(); - } + SaveAllChanges(); + }); } } @@ -1145,7 +1189,8 @@ namespace YLErp.Modules.SwapModule { ratio = -ratio; } - var lastInterestIncomeSum = eodPayPosition.InterestIncomeSum; + // 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。 + var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m; eodPayPosition = new eod_swap_position(); eodPayPosition.ClientId = td.ClientId; eodPayPosition.SwapTradeId = td.id; @@ -1465,7 +1510,7 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.RealizedFee = closeFee; newEodPayPosition.RealizedMtmPnL = newEodPayPosition.TdCloseMtmPnl; newEodPayPosition.RealizedDividend = newEodPayPosition.TdCloseDividend; - newEodPayPosition.RealizedPnl = newEodPayPosition.TdCloseMtmPnl; + SetFloatingRealizedPnl(newEodPayPosition); newEodPayPosition.PosiStatus = payQty == 0 ? 1 : 0; UpdateDbOption(newEodPayPosition); @@ -1511,7 +1556,7 @@ namespace YLErp.Modules.SwapModule int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; - var price = GetUnderlyingPrice(eod.UnderlyingCode, dealDate, out decimal vobp); + var price = GetSwapValuationPrice(eod.UnderlyingCode, eod.PosiGrossPrice, dealDate, out decimal vobp); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); decimal tax = um.ValueAddedTax ?? 0; if (valueDate > td.StartDate.Value && curretEod.PosiQuantity > 0) @@ -1540,7 +1585,7 @@ namespace YLErp.Modules.SwapModule curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl; curretEod.RealizedDividend = eod.RealizedDividend + curretEod.TdCloseDividend; curretEod.RealizedFee = eod.RealizedFee + curretEod.TdCloseFee; - curretEod.RealizedPnl = eod.RealizedPnl + curretEod.TdCloseMtmPnl; + SetFloatingRealizedPnl(curretEod); var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, td.StartDate.Value , seekPreday: true, currencyRateType: curretEod.PosiDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); curretEod.TdCurrency = Convert.ToDecimal(currencyRate); @@ -1550,10 +1595,22 @@ namespace YLErp.Modules.SwapModule curretEod.Invalid = false; if (curretEod.id == 0) { - DbContext.eod_swap_position.Add(curretEod); + PersistEodSwapPosition(curretEod); } return curretEod; } + + /// + /// 浮动腿累计已实现盈亏由盯市、分红和费用三个已实现组成项汇总。 + /// 各组成项已经按本方视角落库,此处不再额外转换方向。 + /// + private static void SetFloatingRealizedPnl(eod_swap_position position) + { + position.RealizedPnl = position.RealizedMtmPnL + + position.RealizedDividend + + position.RealizedFee; + } + /// /// 更新虚拟交易费用 /// @@ -1591,7 +1648,7 @@ namespace YLErp.Modules.SwapModule var dealDate = curretEod.ValueDate; int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; - var price = GetUnderlyingPrice(eod.UnderlyingCode, dealDate, out decimal vobp); + var price = GetSwapValuationPrice(eod.UnderlyingCode, eod.PosiGrossPrice, dealDate, out decimal vobp); var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents); var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice; decimal totalPayment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio); @@ -1614,7 +1671,6 @@ namespace YLErp.Modules.SwapModule // 当日浮动端平仓盈亏·分红(仅来自平仓事件 和 互换 中已实现的分红) curretEod.TdCloseDividend = unwindEvents.Sum(e => e.DividendIn); curretEod.RealizedFee = eod.RealizedFee + curretEod.TdCloseFee; - curretEod.RealizedPnl = eod.RealizedPnl + curretEod.TdCloseMtmPnl; curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; var closeQty = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.平仓).ToList().Sum(s => s.Quantity); @@ -1630,7 +1686,7 @@ namespace YLErp.Modules.SwapModule { curretEod.PosiDividendSum = 0; } - curretEod.RealizedPnl += curretEod.TdCloseDividend; + SetFloatingRealizedPnl(curretEod); curretEod.SwapPositionValue -= curretEod.TdCloseDividend; curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending; @@ -1647,7 +1703,7 @@ namespace YLErp.Modules.SwapModule curretEod.Invalid = false; if (curretEod.id == 0) { - DbContext.eod_swap_position.Add(curretEod); + PersistEodSwapPosition(curretEod); } return curretEod; } @@ -1778,7 +1834,7 @@ namespace YLErp.Modules.SwapModule curretEod.ContractSize = position.ContractSize; curretEod.CountRatio = position.CountRatio; curretEod.PosiTradingFee = position.PosiTradingFee; - curretEod.UnderlyingPrice = GetUnderlyingPrice(position.UnderlyingCode, dealDate, out decimal vobp); + curretEod.UnderlyingPrice = GetSwapValuationPrice(position.UnderlyingCode, position.PosiGrossPrice, dealDate, out decimal vobp); SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, position); curretEod.dv01 = Dv01Helper.CalcDv01(curretEod.UnderlyingCode, curretEod.PosiQuantity, curretEod.PosiDirection, curretEod.PositionType, vobp); //if (settleDate == td.TradeDate) @@ -1808,7 +1864,7 @@ namespace YLErp.Modules.SwapModule curretEod.RealizedMtmPnL = curretEod.TdCloseMtmPnl; curretEod.RealizedDividend = curretEod.TdCloseDividend; curretEod.RealizedFee = curretEod.TdCloseFee; - curretEod.RealizedPnl = curretEod.TdCloseMtmPnl; + SetFloatingRealizedPnl(curretEod); curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; if (curretEod.PosiStatus == 1) { @@ -1821,7 +1877,7 @@ namespace YLErp.Modules.SwapModule curretEod.TdCurrency = Convert.ToDecimal(currencyRate); UpdateDbOption(curretEod); curretEod.Invalid = false; - DbContext.eod_swap_position.Add(curretEod); + PersistEodSwapPosition(curretEod); return curretEod; } /// @@ -1885,12 +1941,16 @@ namespace YLErp.Modules.SwapModule eod_Swap = new eod_swap(); } var tradeSpan = DbContext.trade_span.FirstOrDefault(x => x.TradeId == td.id && x.ValueDate == settleDate); + // eod_swap 是交易级汇总;eod_swap_position 是浮动腿、利息腿和保证金腿的明细。 + // 以下先按日终明细拆腿,再按框架合约展示口径汇总。 var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList(); var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿 var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 + // 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模, + // 不能直接用多空腿相加,否则会把对冲方向误当成合约规模变化。 eod_Swap.NotionalValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); - eod_Swap.NotionalValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); - eod_Swap.NotionalValue = eod_Swap.NotionalValueLong + eod_Swap.NotionalValueShort; + eod_Swap.NotionalValueShort = -Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)); + eod_Swap.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional); eod_Swap.SwapTradeId = td.id; eod_Swap.SwapTradeNo = td.TradeNumber; eod_Swap.ClientId = td.ClientId; @@ -1902,6 +1962,8 @@ namespace YLErp.Modules.SwapModule eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum); eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0); decimal interestPnL = 0; + // 利息腿按我方视角归集。保证金腿的利息现金流方向与普通利息腿相反, + // 因此保证金腿需要额外反转符号,确保 InterestPnL 表示我方的合约利率端收益。 interestPositions.ForEach(x => { decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 @@ -1913,7 +1975,10 @@ namespace YLErp.Modules.SwapModule }); eod_Swap.InterestPnL = interestPnL; eod_Swap.PostionValue = eodSwapPositions.Sum(s => s.SwapPositionValue); - eod_Swap.RealizedPnL = eodSwapPositions.Sum(s => s.RealizedPnl); + // 保证金腿的利息现金流方向与保证金本金方向相反。 + // 不能直接汇总 RealizedPnl,否则“收取客户保证金”的腿会把应支付给客户的 + // 利息作为收益相加。逐腿按利息方向转换后再生成框架合约已实现收益。 + eod_Swap.RealizedPnL = eodSwapPositions.Sum(CalculateSwapRealizedPnl); eod_Swap.TdRealizedPnL = eod_Swap.RealizedPnL - (preEodSwap?.RealizedPnL ?? 0); eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty); var initMargin = Convert.ToDecimal(tradeSpan?.InitialMargin ?? 0); @@ -1956,12 +2021,13 @@ namespace YLErp.Modules.SwapModule eod_Swap.ValueDate = settleDate; DbContext.eod_swap.Add(eod_Swap); } + // 单标的调整与首次归档使用同一套框架合约汇总口径,避免重算后多空和名义本金展示不一致。 var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList(); var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿 var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 - eod_Swap.NotionalValue = Convert.ToDecimal(td.StockEqvNotional); + eod_Swap.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional); eod_Swap.NotionalValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); - eod_Swap.NotionalValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); + eod_Swap.NotionalValueShort = -Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)); eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue); eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue); eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum); @@ -1985,7 +2051,7 @@ namespace YLErp.Modules.SwapModule eod_Swap.TdRealizedPnL += x.TdCloseMtmPnl + x.TdCloseDividend + x.TdCloseFee + x.TdCloseInterest * ratio + x.TdCloseInterestFee; }); eod_Swap.PostionValue = eodSwapPositions.Sum(s => s.SwapPositionValue); - eod_Swap.RealizedPnL = eodSwapPositions.Sum(s => s.RealizedMtmPnL + s.RealizedDividend + s.RealizedFee + s.RealizedInterest + s.RealizedInterestFee); + eod_Swap.RealizedPnL = eodSwapPositions.Sum(CalculateSwapRealizedPnl); eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty); var tradeInitMarginObj = DbContext.trade_initial_margin.FirstOrDefault(x => x.TradeId == td.id); var initMarginList = interestPositions.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 && x.HappenDate == settleDate).ToList(); @@ -1998,6 +2064,28 @@ namespace YLErp.Modules.SwapModule DbContext.SaveChanges(); } + /// + /// 汇总单条日终腿的我方已实现收益。 + /// 浮动腿及普通利息腿维持数据库记录的方向;初始/追加预付金腿的利息 + /// 则与保证金本金方向相反。这样“收取对手方保证金”产生的利息会作为 + /// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。 + /// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 + /// + public static decimal CalculateSwapRealizedPnl(eod_swap_position position) + { + var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; + if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) + { + interestRatio = -interestRatio; + } + + return position.RealizedMtmPnL + + position.RealizedDividend + + position.RealizedFee + + position.RealizedInterest * interestRatio + + position.RealizedInterestFee; + } + /// /// 获取多空组合 平仓详细 /// @@ -2149,10 +2237,88 @@ namespace YLErp.Modules.SwapModule } DbContext.SetDebugLog(); var retListResult = query.ToSearchList(req); + var tradeIds = retListResult.rows.Select(x => x.position.SwapTradeId).Distinct().ToList(); + var valueDates = retListResult.rows.Select(x => x.position.ValueDate).Distinct().ToList(); + var tradeNotionals = DbContext.trade + .Where(x => tradeIds.Contains(x.id)) + .Select(x => new { x.id, x.OriginalStockEqvNotional, x.StockEqvNotional }) + .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 underlyingDataSource = DataCacheProvider.GetUnderlyingDataSource(); + var varietyDataSource = DataCacheProvider.GetVarietyDataSource(); foreach (var item in retListResult.rows) { + item.position.NotionalValueShort = -Math.Abs(item.position.NotionalValueShort); + if (tradeNotionals.TryGetValue(item.position.SwapTradeId, out var tradeNotional)) + { + item.position.NotionalValue = Convert.ToDecimal(tradeNotional.OriginalStockEqvNotional ?? tradeNotional.StockEqvNotional); + } var client = DataCacheProvider.GetClientDataSource().GetData(item.ClientId); item.SwapTradeTypeStr = client?.SwapTradeTypeStr; + 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 marginLegs = details.Where(x => marginTypes.Contains(x.InterestMode)).ToList(); + var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId); + var dividendPayDate = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; + + item.UnderlyingType = string.Join(",", floatingLegs + .Select(x => + { + var underlying = underlyingDataSource.GetData(x.UnderlyingCode); + return varietyDataSource.GetData(underlying?.UnderlyingTypeId ?? 0)?.AssetType + ?? underlying?.UnderlyingInstrumentTypeCn + ?? underlying?.UnderlyingType; + }) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct()); + item.PeriodAmount = floatingLegs.Sum(x => x.RealizedDividend + x.PosiDividendSum); + // PosiProfitSum = 标的盯市收益 + 未结交易费用 + 待实现付息/分红。 + // 风险页的“合约浮动端待实现收益”需要保留未结交易费用, + // 但期间付息/分红由 PeriodAmount 单列展示并参与对应估值口径, + // 因此仅扣除 PosiDividendSum,不能直接使用 PosiMtmPnL。 + item.FloatingUnrealizedPnl = floatingLegs.Sum(x => x.PosiProfitSum - x.PosiDividendSum); + item.InterestPaymentMethod = dividendPayDate == 0 ? "到期轧差" : "派息日支付"; + if (dividendPayDate == 0) + { + item.MaturityNettingValuation = item.FloatingUnrealizedPnl + item.position.InterestPnL + item.PeriodAmount; + } + else + { + item.PeriodPaymentValuation = item.FloatingUnrealizedPnl + item.position.InterestPnL; + } + // eod_swap 的保证金本金来自 trade_span;缺少 span 数据时会被保存为 0。 + // 本风险页改按日终保证金腿展示,且该页面保证金本金采用原始本金的 1/10 口径。 + // 利息仍使用原始本金累积值,不能同步缩放,否则会破坏保证金利息金额。 + item.position.InitMarginGain = marginLegs + .Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 + && x.InterestDirection == (int)SwapDirectionEnum.收取) + .Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m; + item.position.InitMarginLoss = marginLegs + .Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 + && x.InterestDirection == (int)SwapDirectionEnum.支付) + .Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m; + item.position.PostionMarginGain = marginLegs + .Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金 + && x.InterestDirection == (int)SwapDirectionEnum.收取) + .Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m; + item.position.PostionMarginLoss = marginLegs + .Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金 + && x.InterestDirection == (int)SwapDirectionEnum.支付) + .Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m; + + // 保证金本金方向与我方的利息现金流方向相反:原始“收取”保证金 + // 表示我方占用客户资金,应向客户支付利息;支付金额按负数展示。 + item.MarginInterestGain = marginLegs + .Where(x => x.InterestDirection == (int)SwapDirectionEnum.支付) + .Sum(x => Math.Abs(x.InterestIncomeSum)); + item.MarginInterestLoss = marginLegs + .Where(x => x.InterestDirection == (int)SwapDirectionEnum.收取) + .Sum(x => -Math.Abs(x.InterestIncomeSum)); } var dv01 = query.Sum(O => O.position.dv01??0); @@ -2287,6 +2453,7 @@ namespace YLErp.Modules.SwapModule private SearchListResult GetSearchEodPositionList(ClientSwapPositionRequest req) { + // 每日估值报告以有数量的浮动腿为主记录;利息腿和保证金腿仅作为同交易、同估值日的辅助数据参与汇总。 var predicate = PredicateBuilder.Create(n => !n.Invalid && n.PosiQuantity > 0); var interestPredicate = PredicateBuilder.Create(n => !n.Invalid && n.InterestDirection > 0); var tradePredicate = PredicateBuilder.Create(n => n.ValidState != "InValid"); @@ -2297,10 +2464,15 @@ namespace YLErp.Modules.SwapModule { predicate = predicate.And(x => x.ClientId == req.ClientId); } - if (req.ValueDateFrom != null) + if (req.BookId > 0) { - predicate = predicate.And(x => x.ValueDate >= req.ValueDateFrom); + tradePredicate = tradePredicate.And(x => x.AssetId == req.BookId.Value); } + // ValueDateFrom 保留在请求模型中,但当前互换估值查询按 ValueDate 单日取数。 + // if (req.ValueDateFrom != null) + // { + // predicate = predicate.And(x => x.ValueDate >= req.ValueDateFrom); + // } if (req.ValueDate != null) { predicate = predicate.And(x => x.ValueDate == req.ValueDate); @@ -2330,53 +2502,132 @@ namespace YLErp.Modules.SwapModule } var retListResult = query.ToSearchList(req); var tradeIds = retListResult.rows.Select(s => s.position.SwapTradeId).ToList(); + if (!tradeIds.Any()) + { + return retListResult; + } interestPredicate = interestPredicate.And(x => tradeIds.Contains(x.SwapTradeId)); var valueDates = retListResult.rows.Select(s => s.position.ValueDate).Distinct().ToList(); + interestPredicate = interestPredicate.And(x => valueDates.Contains(x.ValueDate)); + // 主查询分页后再取同交易、同估值日的全部辅助腿,避免利息/保证金归集跨估值日串数据。 var eodPositions = DbContext.eod_swap_position.Where(interestPredicate).ToList(); - var positions = DbContext.swap_position.Where(x => tradeIds.Contains(x.SwapTradeId) && x.InterestMode == (int)InterestModeEnum.初始预付金 && x.IsInitial && !x.Invalid).ToList(); + var marginPositions = DbContext.swap_position + .Where(x => tradeIds.Contains(x.SwapTradeId) && marginTypes.Contains(x.InterestMode) && x.IsInitial && !x.Invalid) + .ToList(); var tradeExtends = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); Dictionary tradeDic = new Dictionary(); foreach (var item in retListResult.rows) { var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId); - var eventDate = item.position.ValueDate; - if (tradeExtend != null) - { - eventDate = QdpCalendarHelper.GetNonHoliday(eventDate.AddDays(tradeExtend.ExtendObj.SettlementRules)); - } - item.DayCount = Math.Max(0, (eventDate - item.position.PosiStartDate).Days + 1); + // 到期结算日按合同到期日展示;实际期限按自然日且包含起始日,二者均不使用结算规则偏移。 + item.MaturitySettlementDate = item.position.PosiMatuirityDate; + item.DayCount = Math.Max(0, (item.position.ValueDate - item.position.PosiStartDate).Days + 1); //item.position.PosiProfitSum += item.position.VTradingFee-item.position.PosiFeePending; SetClientEodPosition(item.position); //item.position.PosiProfitSum += item.TradingFee; var posiProfitSum = item.position.PosiProfitSum; //item.position.PosiProfitSum 不需要加交易费用 - item.position.PosiProfitSum = item.position.PosiProfitSum - item.position.PosiFeePending - item.position.PosiDividendSum; - item.NetSettmentAmount = item.position.PosiProfitSum + item.position.PosiDividendSum + item.position.PosiFeePending; - item.PeriodAmount = item.position.PosiDividendSum; - var margins = positions.Where(x => x.SwapTradeId == item.position.SwapTradeId); + // PosiProfitSum 原值包含交易费用和期间付息/分红。先拆出这两部分, + // 使“浮动收益金额”仅反映标的盯市收益,后续净额公式再按支付方式决定是否加回期间金额。 + var pendingDividend = item.position.PosiDividendSum; + item.position.PosiProfitSum = item.position.PosiProfitSum - item.position.PosiFeePending - pendingDividend; + // 现券仅展示期间付息和期初成交收益率;ETF(标的主数据类型 Fund)仅展示期间分红。 + // 其余标的的三列均不适用,返回 null 使页面和 Excel 模板保持空白,而不是展示 0。 + var isCashBond = ConsGlobal.InstrumentType.IsBond(item.position.UnderlyingInstrumentType); + var isEtf = ConsGlobal.InstrumentType.Fund.Equals( + item.position.UnderlyingInstrumentType, + StringComparison.OrdinalIgnoreCase); + if (isCashBond) + { + item.PeriodAmount = pendingDividend; + item.DividendAmount = null; + // 期初标的成交收益率是债券现券成交口径,非现券不展示该交易录入值。 + } + else if (isEtf) + { + item.PeriodAmount = null; + item.DividendAmount = pendingDividend; + } + else + { + item.PeriodAmount = null; + item.DividendAmount = null; + } + if (!isCashBond) + { + item.InitYtm = null; + } + // 预付金本金和利率来自交易腿,并以发生日判断在估值日是否已生效; + // 预付金利息则来自当日日终腿,以获得截至估值日的 InterestIncomeSum。 + var tradeMargins = marginPositions + .Where(x => x.SwapTradeId == item.position.SwapTradeId + && (!x.HappenDate.HasValue || x.HappenDate.Value <= item.position.ValueDate)) + .ToList(); var interests = eodPositions.Where(x => x.SwapTradeId == item.position.SwapTradeId && x.ValueDate == item.position.ValueDate); - var eodMargins = interests.Where(x => marginTypes.Contains(x.InterestMode)); - var eodInterests = interests.Where(x => !marginTypes.Contains(x.InterestMode)); + var eodMargins = interests.Where(x => marginTypes.Contains(x.InterestMode)).ToList(); + var eodInterests = interests.Where(x => !marginTypes.Contains(x.InterestMode)).ToList(); + var initialMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金).ToList(); + var additionalMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金).ToList(); var floatRateInterest = eodInterests.Where(x => !string.IsNullOrEmpty(x.FloatRateUnderlyingCode)).FirstOrDefault(); item.position.FloatRateUnderlyingCode = floatRateInterest?.FloatRateUnderlyingCode; item.position.FloatRate = floatRateInterest?.FloatRate ?? 0; - item.OpenMarginAmount = margins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); - item.OpenMarginRate = margins.Sum(s => s.InterestRateDefault * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); - item.MarginInterestAmount = eodMargins.Sum(s => s.InterestIncomeSum * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); + item.OpenMarginAmount = initialMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); + item.OpenMarginRate = CalculateWeightedMarginRate(tradeMargins); + item.AdditionalMarginAmount = additionalMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); + item.MarginInterestAmount = CalculateWeightedMarginInterest(eodMargins); item.InterestAmount = eodInterests.Sum(s => s.InterestIncomeSum * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? -1 : 1)); item.InterestRate = eodInterests.Sum(s => s.InterestRateDefault); - item.NetSettmentAmount += item.InterestAmount + item.MarginInterestAmount + eodMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); + // 到期轧差才把期间付息/分红并入净额结算;派息日支付已在现金流层独立结算,不能重复计入估值。 + var nettingDividend = (tradeExtend?.ExtendObj?.DividendPayDate ?? 1) == 0 ? pendingDividend : 0m; + item.NetSettmentAmount = item.InterestAmount + + item.position.PosiProfitSum + + item.position.PosiFeePending + + item.MarginInterestAmount + + nettingDividend; item.NetSettmentAmount = Math.Round(item.NetSettmentAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + item.TrsValue = Math.Round(item.NetSettmentAmount + item.OpenMarginAmount + item.AdditionalMarginAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); if (item.position.PosiNotionalValue != 0 && item.position.PosiNetPrice != 0) { item.FloatRateAbs = item.position.PosiNotionalValue == 0 ? 0 : item.InterestAmount / item.position.PosiNotionalValue; } - SetPosiPrice(item.position); + // 交易录入的债券类收益互换价格以小数保存,展示时转为百分比价格; + // 普通收益互换录入的是数量/原始数值,不做乘 100 转换。 + SetPosiPrice(item.position, item.StructureType == "普通债券类收益互换"); } return retListResult; } + /// - /// 设置客户视角 + /// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权, + /// 不按收付方向轧差,避免相反方向本金抵消后放大利率。 + /// + private static decimal CalculateWeightedMarginRate(IEnumerable margins) + { + var marginList = margins.ToList(); + var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix)); + return totalWeight == 0 + ? 0 + : marginList.Sum(x => x.InterestRateDefault * Math.Abs(x.InterestPrincipalFix)) / totalWeight; + } + + /// + /// 计算预付金利息。先按收取为正、支付为负转换为我方视角, + /// 再按日终本金绝对值加权平均;本金合计为零时返回零。 + /// + private static decimal CalculateWeightedMarginInterest(IEnumerable margins) + { + var marginList = margins.ToList(); + var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix)); + return totalWeight == 0 + ? 0 + : marginList.Sum(x => x.InterestIncomeSum + * (x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1) + * Math.Abs(x.InterestPrincipalFix)) / totalWeight; + } + /// + /// 将数据库中以公司/交易簿记方向保存的日终字段转换为客户视角。 + /// 该转换必须在拆分浮动收益、费用和期间付息/分红之前完成, + /// 否则页面、Excel 和净额结算金额会出现相反符号。 /// /// private void SetClientEodPosition(eod_swap_position position) @@ -2399,10 +2650,10 @@ namespace YLErp.Modules.SwapModule position.SwapPositionValue = -position.SwapPositionValue; position.PosiDividendSum = -position.PosiDividendSum; } - private void SetPosiPrice(eod_swap_position position) + private void SetPosiPrice(eod_swap_position position, bool? useBondPriceScale = null) { var um = DataCacheProvider.GetUnderlyingDataSource().GetData(position.UnderlyingCode); - if (um != null && um.IsBond()) + if (useBondPriceScale ?? (um != null && um.IsBond())) { position.PosiNetPrice *= 100; position.UnderlyingPrice *= 100; diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs index 2641648a..17b17901 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs @@ -110,7 +110,7 @@ namespace YLErp.Modules.SwapModule frdata.ValueDate = dateTime; frdata.UnderlyingCode = "FR007"; frdata.UnderlyingId = newestdata.UnderlyingId; - frdata.DataSource = "人工"; + frdata.DataSource = EodPriceBase.人工; DbContext.Add(frdata); } frdata.ClosePrice = Math.Round(price, 4); diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs index d1a54850..90a018be 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs @@ -411,7 +411,7 @@ namespace YLErp.Modules.SwapModule /// 合成持仓/日终归档 清除互换持仓所有信息 /// /// - public void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) + public virtual void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { var swapEvents = DbContext.swap_event.Where(x => x.SwapTradeId == td.id && x.ValueDate >= valueDate && eventTypes.Contains(x.EventType)); var eventIds = swapEvents.Select(s => s.id).ToList(); diff --git a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs index 83a9b973..ae140dd0 100644 --- a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs +++ b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs @@ -6,7 +6,9 @@ using System.Linq; using System.Linq.Expressions; using YLErp.BLL.Eod; using YLErp.DBModels; +using YLErp.Helpers; using YLErp.Model.Enum; +using YLErp.Modules.TradeModule; namespace YLErp.Modules.SystemModule { @@ -43,7 +45,9 @@ namespace YLErp.Modules.SystemModule approvalGroupId = item.approvalGroupId, node = item.node, parentNode = item.parentNode, - approvalCondition = item.approvalCondition + approvalCondition = item.approvalCondition, + conditionConfig = item.conditionConfig, + triggerCondition = item.triggerCondition }).ToList(); DbContext.approvalprocess.AddRange(list); @@ -81,6 +85,21 @@ namespace YLErp.Modules.SystemModule } } } + else if (type == ProcessCategoryConst.Close) // 需求②:了结/平仓/行权审批流程 + { + if (data != null && data.Count > 0) + { + ChangeTradeProcess(data, delList); + } + else + { + var trade = DbContext.trade.Where(x => x.ValidState == "Valid" && (x.TradeStatus == ConsTrade.平仓待复核 || x.TradeStatus == ConsTrade.行权待复核 || x.TradeStatus == ConsTrade.互换待复核)).ToList(); + if (trade != null && trade.Count > 0) + { + throw new ServiceException("有交易在审批中,不能删除审批流程!"); + } + } + } else if (type == "CreditProcess") { if (data != null && data.Count == 0) @@ -578,7 +597,8 @@ namespace YLErp.Modules.SystemModule { var roles = UserBLL.GetRolesByUserId(userId).Select(o => o.Id); var groupId = UserBLL.GetApprovalProcessGroup(userId); - var tradeProcess = TradeProcess(); + // 需求②:按交易状态推断开仓/了结流程。 + var tradeProcess = TradeProcessByCategory(trade); bool approvalBranch = tradeProcess.Any(x => x.approvalGroupId != 0);//审批流程有分支情况 trade.ProcessOrderBranch = 0; if (trade.ProcessOrderId <= 0) @@ -612,6 +632,16 @@ namespace YLErp.Modules.SystemModule trade.ProcessOrderId = ProcessTradeLog.审批中; } } + // 需求①:开仓交易首次进入审批时,应用触发条件——跳过起始就不满足触发条件的节点。 + // 若所有节点均不满足 → 直接审批通过,无需审核。 + if (tradeProcess.Count > 0) + { + ApplyTriggerOnStart(tradeProcess, trade, userId); + if (trade.ProcessOrderId == ProcessTradeLog.审批通过) + { + return 0; + } + } // 如果是审批组 var orderIdCount = tradeProcess.Where(x => x.order == trade.ProcessOrderId);//判断是否有分支 int processOrderNode = orderIdCount.Count() > 1 ? trade.ProcessOrderBranch : 0; @@ -658,6 +688,47 @@ namespace YLErp.Modules.SystemModule return -2; } + + /// + /// 需求①:开仓交易首次进入审批流程时,从起始节点应用触发条件。 + /// 跳过起始就不满足触发条件的节点;若所有节点均不满足 → 直接审批通过。 + /// + private void ApplyTriggerOnStart(List tradeProcess, trade trade, int userId) + { + // 取当前起点节点(主干 node=0) + var start = tradeProcess.FirstOrDefault(x => x.order == trade.ProcessOrderId && x.node == 0); + if (start == null) + { + start = tradeProcess.FirstOrDefault(x => x.order >= trade.ProcessOrderId && x.node == 0); + } + if (start == null) return; + + // 构建求值上下文(开仓场景无 trade_cash,CurrentNotional 不设) + var ctx = new ConditionContext + { + UserId = userId, + Trade = trade, + ProcessCategory = ProcessCategoryConst.Resolve(trade), + InitGroupId = UserBLL.GetApprovalProcessGroup(userId) + }; + + var target = ConditionEvaluator.FindFirstTriggeredNode(tradeProcess, start, ctx); + + if (target == null) + { + // 所有节点都不满足触发条件 → 直接审批通过 + trade.ProcessOrderId = ProcessTradeLog.审批通过; + trade.CheckTradeUpdate = Convert.ToInt32(TradeCheckEnum.StatusOfNew); + trade.ProcessOptDate = DateTime.Now; + trade.ProcessStatus = ProcessTradeStatus.通过审批.ToString(); + return; + } + if (target.order != trade.ProcessOrderId) + { + trade.ProcessOrderId = target.order; + } + } + /// /// 获取所有交易审批节点 /// @@ -668,6 +739,21 @@ namespace YLErp.Modules.SystemModule return tradeOrders; } + /// + /// 按交易推断流程类别(开仓/了结)获取审批节点(需求②)。 + /// 了结类优先取 CloseProcess;未配置则回退 TradeProcess。 + /// + public List TradeProcessByCategory(trade td) + { + var category = ProcessCategoryConst.Resolve(td); + var orders = DbContext.approvalprocess.Where(t => t.processType == category).OrderBy(o => o.order).ToList(); + if (category == ProcessCategoryConst.Close && orders.Count == 0) + { + return TradeProcess(); + } + return orders; + } + } /// @@ -686,6 +772,12 @@ namespace YLErp.Modules.SystemModule public int node { get; set; } public int parentNode { get; set; } public int approvalCondition { get; set; } + + /// 分支网关条件(JSON),需求①③共用。为空则回退旧 approvalGroupId/approvalCondition 二元判断。 + public string conditionConfig { get; set; } + + /// 节点触发条件(JSON),需求①:满足才进入该审批节点,不满足则跳过。 + public string triggerCondition { get; set; } } /// /// 审批流程修改节点 diff --git a/YLErpDAL/Modules/TradeModule/DealModule/OtcTradeCloseService.cs b/YLErpDAL/Modules/TradeModule/DealModule/OtcTradeCloseService.cs index 5a82fe92..6bbad4f1 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/OtcTradeCloseService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/OtcTradeCloseService.cs @@ -224,7 +224,7 @@ namespace YLErp.Modules.TradeModule.DealModule #endregion - //审批流程 + //审批流程(需求②:按交易状态查对应流程) if (!req.SkipWorkflow && HasTradeProcess()) { var isUnwind = tradeCash.Action.Contains("平仓"); @@ -235,14 +235,33 @@ namespace YLErp.Modules.TradeModule.DealModule dbTrade.CheckStatus = Convert.ToInt32(TradeCheckEnum.StatusOfOld); - //检查是否有交易审批流程 - if (valuedateBLL.SystemDate.CloseReApprove == 1) + //检查是否有交易审批流程(需求②:按交易状态查对应流程,而非只查开仓流程) + var needApproval = valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(dbTrade) > 0; + if (needApproval) { - // 如果有审批组 - InitTradeProcessOrder(dbTrade, UserId); + // 需求①:进入审批前先判断触发条件——若所有节点都不满足触发条件,跳过审批。 + needApproval = NeedApprovalByTrigger(dbTrade); } - AddTradeOperationHistoryAndSetParentTradeInfo(false, dbTrade, optType: isUnwind ? "平仓审核提交" : "行权审核提交", comments: req.ImportFlag); + if (needApproval) + { + // 需要审批:进入审批流程等待人工审批 + InitTradeProcessOrder(dbTrade, UserId); + AddTradeOperationHistoryAndSetParentTradeInfo(false, dbTrade, optType: isUnwind ? "平仓审核提交" : "行权审核提交", comments: req.ImportFlag); + } + else + { + // 不需要审批:设 ProcessOrderId=审批通过,再调 UpdateTradeProcessLog(pass) 执行审批通过流程 + dbTrade.ProcessOrderId = ProcessTradeLog.审批通过; + DbContext.SaveChanges(); + new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel + { + tradeId = dbTrade.id, + status = "pass", + comments = "触发条件未满足,自动跳过审批", + notNeedOperationHistory = false + }); + } } else { diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeCloseService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeCloseService.cs index 0729bb42..96085b78 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeCloseService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeCloseService.cs @@ -1,4 +1,5 @@ using YLErp.BLL; +using YLErp.Helpers; using YLErp.Model; using YLErp.Model.Enum; using YLErp.Modules.TradeDalModule; @@ -115,25 +116,40 @@ namespace YLErp.Modules.TradeModule.DealModule td.OptId = UserId; td.OptName = UserName; td.OptDate = OptDate; - if (isFromExercise) - { - td.TradeStatus = ConsTrade.行权待复核; - } - else - { - td.TradeStatus = ConsTrade.平仓待复核; - } td.CheckStatus = Convert.ToInt32(TradeCheckEnum.StatusOfOld); - //检查是否有交易审批流程 - if (valuedateBLL.SystemDate.CloseReApprove == 1 && hasTradeProcess) + // 先设状态,使流程类别能正确推断为 CloseProcess(需求②按交易状态推断开仓/了结) + td.TradeStatus = isFromExercise ? ConsTrade.行权待复核 : ConsTrade.平仓待复核; + + //检查是否有交易审批流程(需求②:平仓/行权按交易状态查对应流程,而非只查开仓流程) + var needApproval = valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(td) > 0; + if (needApproval) { - // 如果有审批组 - InitTradeProcessOrder(td, UserId); + // 需求①:进入审批前先判断触发条件——若所有节点都不满足触发条件,跳过审批。 + needApproval = NeedApprovalByTrigger(td); } - AddTradeOperationHistoryAndSetParentTradeInfo(false, td, isFromExercise ? "部分行权审核提交" : "平仓审核提交"); + if (needApproval) + { + // 需要审批:保持"平仓待复核/行权待复核",进入审批流程等待人工审批 + InitTradeProcessOrder(td, UserId); + AddTradeOperationHistoryAndSetParentTradeInfo(false, td, isFromExercise ? "部分行权审核提交" : "平仓审核提交"); + } + else + { + // 不需要审批:设 ProcessOrderId=审批通过,再调 UpdateTradeProcessLog(pass) 执行审批通过流程 + // (SetTradeOpen 会完成平仓、改状态、写日志,与人工审批通过完全一致) + td.ProcessOrderId = ProcessTradeLog.审批通过; + DbContext.SaveChanges(); + new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel + { + tradeId = td.id, + status = "pass", + comments = "触发条件未满足,自动跳过审批", + notNeedOperationHistory = false + }); + } } } diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeExerciseService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeExerciseService.cs index 28e318c7..69e7bbf9 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeExerciseService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeExerciseService.cs @@ -1,4 +1,5 @@ using YLErp.BLL; +using YLErp.Helpers; using YLErp.Model; using YLErp.Model.Enum; @@ -105,12 +106,37 @@ namespace YLErp.Modules.TradeModule.DealModule { var td = DbContext.trade.Find(tradeId); td.OptDate = DateTime.Now; - td.TradeStatus = ConsTrade.行权待复核; td.CheckStatus = Convert.ToInt32(TradeCheckEnum.StatusOfOld); - if (valuedateBLL.SystemDate.CloseReApprove == 1 && hasTradeProcess) + + // 先设状态,使流程类别能正确推断为 CloseProcess + td.TradeStatus = ConsTrade.行权待复核; + + // 需求②:行权按交易状态查对应流程 + var needApproval = valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(td) > 0; + if (needApproval) { - //如果有审批组 - InitTradeProcessOrder(td,UserId); + // 需求①:进入审批前先判断触发条件——若所有节点都不满足触发条件,跳过审批。 + needApproval = NeedApprovalByTrigger(td); + } + + if (needApproval) + { + // 需要审批:保持"行权待复核",进入审批流程等待人工审批 + InitTradeProcessOrder(td, UserId); + } + else + { + // 不需要审批:设 ProcessOrderId=审批通过,再调 UpdateTradeProcessLog(pass) 执行审批通过流程 + td.ProcessOrderId = ProcessTradeLog.审批通过; + DbContext.SaveChanges(); + new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel + { + tradeId = td.id, + status = "pass", + comments = "触发条件未满足,自动跳过审批", + notNeedOperationHistory = false + }); + return; } DbContext.SaveChanges(); diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeExpireConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeExpireConfirmService.cs index a523d7ca..ab9624f9 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeExpireConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeExpireConfirmService.cs @@ -140,7 +140,9 @@ namespace YLErp.Modules.TradeModule.DealModule #region 设置期末价格和执行价格 - var finalPrice = EodPriceQueryService.TryGetEodPrice(exerciseDate, td.UnderlyingCode, out var eodPrice) + // 债券标的需走中债估值表取价,原 TryGetEodPrice 只查期货/股票两表会漏掉债券,导致"结算价未找到"。 + // 统一改用债券感知的 TryGetSettlementEodPrice(见 EodPriceQueryService)。 + var finalPrice = EodPriceQueryService.TryGetSettlementEodPrice(exerciseDate, td.UnderlyingCode, out var eodPrice) ? eodPrice.GetPrice(td.SettlementType) : 0; if (finalPrice <= 0) @@ -280,8 +282,6 @@ namespace YLErp.Modules.TradeModule.DealModule trade_cash tradeCash = null; //日终价格 - var underlyingIds = tradeUnwindTrades.Select(t => t.UnderlyingId).ToList(); - var EodPriceProvider = new EodPriceProvider(valueDate); //批量结算的全是现金流交易就不用结算价 if (!EodPriceQueryService.CheckDbExists(valueDate) && tradeQuery.Any(t => t.TradeType != "现金流交易")) { @@ -308,8 +308,8 @@ namespace YLErp.Modules.TradeModule.DealModule var CountRatio = 1; if (t.TradeType != "现金流交易") { - //结算价 - if (EodPriceProvider.TryGetEodPrice(t.UnderlyingCode, out var eodPrice)) + //结算价(债券感知统一取价:债券走中债估值,期货/股票走原路径,见 EodPriceQueryService.TryGetSettlementEodPrice) + if (EodPriceQueryService.TryGetSettlementEodPrice(valueDate, t.UnderlyingCode, out var eodPrice)) { settlePrice = eodPrice.GetPrice(t.SettlementType); } diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 0915baff..8bfd988e 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -1,6 +1,7 @@ using BaseOUDAL; using YLErp.BLL; using YLErp.BLL.Eod; +using YLErp.Helpers; using YLErp.Model; using YLErp.Model.Enum; using YLErp.Modules.ClientModule; @@ -140,7 +141,8 @@ namespace YLErp.Modules.TradeModule.DealModule return result; } //TODO 如果是审批组 - var tradeProessQuery = TradeProcess(); + // 需求②:按交易状态推断开仓/了结流程。了结类(平仓/行权/互换待复核)走 CloseProcess。 + var tradeProessQuery = TradeProcessByCategory(td); var count = tradeProessQuery.Count(); if (count == 0 || td.ProcessOrderId == ProcessTradeLog.审批通过)//投资规模校验已经将数据设置为已通过 { @@ -166,6 +168,8 @@ namespace YLErp.Modules.TradeModule.DealModule //{ // td.ProcessOrderBranch = 0; //} + // 节点触发条件(需求①):下一节点配置了 triggerCondition 时,满足才进入审批,不满足则跳过。 + nextOrder = AdvanceThroughTriggerNodes(tradeProessQuery, nextOrder, BuildTriggerContext(td, UserId)); if (nextOrder==null) { @@ -225,7 +229,8 @@ namespace YLErp.Modules.TradeModule.DealModule var childrenTrades = DbContext.trade.Where(x => childrenTradeIds.Contains(x.id)).ToList(); var result = new TradeOpenResult(td); // 如果是审批组 - var tradeProessQuery = TradeProcess(); + // 需求②:分组了结按交易状态推断开仓/了结流程。 + var tradeProessQuery = TradeProcessByCategory(td); var count = tradeProessQuery.Count(); if (count == 0) { @@ -246,6 +251,8 @@ namespace YLErp.Modules.TradeModule.DealModule { nextOrder = tradeProessQuery.FirstOrDefault(x => x.order > td.ProcessOrderId && x.node == td.ProcessOrderBranch && x.approvalGroupId == 0); } + // 节点触发条件(需求①):分组了结推进同样支持触发条件。 + nextOrder = AdvanceThroughTriggerNodes(tradeProessQuery, nextOrder, BuildTriggerContext(td, UserId)); if (nextOrder == null) { SetParentTradeOpen(req, td, childrenTrades, parentTradeCash); diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeUnwindService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeUnwindService.cs index 94fe5d3e..06ef40c6 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeUnwindService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeUnwindService.cs @@ -294,8 +294,8 @@ namespace YLErp.Modules.TradeModule.DealModule } var result = new TradeUnwindResultModel(td); - //存在审批流程时,走审批成功流程 - if (valuedateBLL.SystemDate.CloseReApprove == 1 && HasTradeProcess()) + //存在审批流程时,走审批成功流程(需求②:按交易状态查对应流程) + if (valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(td) > 0) { result.ApprovalProcess = true; @@ -462,8 +462,8 @@ namespace YLErp.Modules.TradeModule.DealModule throw new ServiceException("平仓费不一致!复核不通过"); } } - //存在审批流程时,走审批成功流程 - if (valuedateBLL.SystemDate.CloseReApprove == 1 && HasTradeProcess()) + //存在审批流程时,走审批成功流程(需求②:按交易状态查对应流程) + if (valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(td) > 0) { var reqModel = new TradeOpenReqModel { @@ -1848,14 +1848,28 @@ namespace YLErp.Modules.TradeModule.DealModule td.CheckStatus = Convert.ToInt32(TradeCheckEnum.StatusOfOld); - //检查是否有交易审批流程 - if ((valuedateBLL.SystemDate.CloseReApprove == 1 || isSwap) && hasTradeProcess) + //检查是否有交易审批流程(需求②:按交易状态查对应流程,而非只查开仓流程) + var needApproval = (valuedateBLL.SystemDate.CloseReApprove == 1 || isSwap) && TradeProcessCountByCategory(td) > 0; + if (needApproval) { - //如果有审批组 - InitTradeProcessOrder(td, UserId); + // 需求①:进入审批前先判断触发条件——若所有节点都不满足触发条件,跳过审批。 + needApproval = NeedApprovalByTrigger(td); } - AddTradeOperationHistoryAndSetParentTradeInfo(false, td, isSwap ? "互换审核提交" : "平仓审核提交"); + if (needApproval) + { + // 需要审批:进入审批流程等待人工审批 + InitTradeProcessOrder(td, UserId); + AddTradeOperationHistoryAndSetParentTradeInfo(false, td, isSwap ? "互换审核提交" : "平仓审核提交"); + } + else + { + // 不需要审批:只标记 ProcessOrderId=审批通过。 + // 互换的 swap_event 记录要等调用方 ApplySwapTrade 的 SaveSwapDeal 执行后才创建, + // 所以互换不在这里调 UpdateTradeProcessLog(否则 FindSwapEvent 找不到记录), + // 由 ApplySwapTrade 在 SaveSwapDeal 之后判断并触发审批通过流程。 + td.ProcessOrderId = ProcessTradeLog.审批通过; + } } } diff --git a/YLErpDAL/Modules/TradeModule/QueryModule/TradeConfirmationDocumentQuery.cs b/YLErpDAL/Modules/TradeModule/QueryModule/TradeConfirmationDocumentQuery.cs new file mode 100644 index 00000000..d43a4553 --- /dev/null +++ b/YLErpDAL/Modules/TradeModule/QueryModule/TradeConfirmationDocumentQuery.cs @@ -0,0 +1,42 @@ +namespace YLErp.Modules.TradeModule.QueryModule +{ + public sealed class TradeConfirmationDocumentQueryItem + { + public trade_contract_document Document { get; set; } + + public trade_contract_r Relation { get; set; } + } + + public static class TradeConfirmationDocumentQuery + { + public static IQueryable Create( + IQueryable documents, + IQueryable relations, + IQueryable trades, + DateTime? tradeDateStart, + DateTime? tradeDateEnd) + { + if (tradeDateStart.HasValue) + { + trades = trades.Where(t => t.TradeDate >= tradeDateStart.Value); + } + + if (tradeDateEnd.HasValue) + { + var tradeDateEndExclusive = tradeDateEnd.Value.Date.AddDays(1); + trades = trades.Where(t => t.TradeDate < tradeDateEndExclusive); + } + + return from document in documents + join relation in relations.Where(r => r.IsValid) + on document.Code equals relation.ContractCode + join trade in trades + on relation.TradeId equals trade.id + select new TradeConfirmationDocumentQueryItem + { + Document = document, + Relation = relation + }; + } + } +} diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index 993be3a9..e91abbae 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -276,8 +276,25 @@ namespace YLErp.BLL var logger = LogFactory.GetLogger(); var posDict = swapPositions.GroupBy(sp => sp.SwapTradeId).ToDictionary(g => g.Key, g => g.First().PosiNetPrice); var umProvider = DataCacheProvider.GetUnderlyingDataSource(); + // 需求②:平仓/行权/互换交易,审批角色应取 CloseProcess 流程的节点角色,而非 TradeProcess + var closeProcessRoles = db.approvalprocess + .Where(a => a.processType == "CloseProcess") + .Select(a => new { a.order, a.roleId }) + .ToList() + .ToDictionary(a => a.order, a => a.roleId); + foreach (var tradeLinq in retListResult.rows) { + // 了结类交易:用 CloseProcess 的角色覆盖 + if (tradeLinq.TradeStatus == "平仓待复核" || tradeLinq.TradeStatus == "行权待复核" || tradeLinq.TradeStatus == "互换待复核" + && closeProcessRoles.Count > 0) + { + if (closeProcessRoles.TryGetValue(tradeLinq.ProcessOrderId, out var closeRoleId)) + { + tradeLinq.ProcessRoleId = closeRoleId; + } + } + if (tradeLinq.ProcessRoleId != null && listRoles.TryGetValue(tradeLinq.ProcessRoleId.Value, out var name)) { tradeLinq.ProcessRoleName = name; @@ -3081,6 +3098,8 @@ namespace YLErp.BLL //如果是审批组 var approvalprocessQuery = db.approvalprocess.Where(a => a.processType == "TradeProcess"); var tradeOpenProcessOrder = approvalprocessQuery.Count(); + // 需求②:了结流程节点数(用于平仓/行权/互换交易展示正确的审批进度) + var closeProcessOrder = db.approvalprocess.Count(a => a.processType == "CloseProcess"); var branch = approvalprocessQuery.FirstOrDefault(x => x.approvalGroupId != 0);//审批流程有分支情况 var firstBranch = approvalprocessQuery.Where(x => (x.node == 1 && x.approvalGroupId == 0) || x.node == 0);//分支一总流程 var secondBranch = approvalprocessQuery.Where(x => (x.node == 2 && x.approvalGroupId == 0) || x.node == 0);//分支二总流程 @@ -3288,7 +3307,7 @@ namespace YLErp.BLL IQueryable query = null; if (branch == null) { - query = GetTradeLinqQuery(predicate, approvalprocessQuery, tradeOpenProcessOrder, 0); + query = GetTradeLinqQuery(predicate, approvalprocessQuery, tradeOpenProcessOrder, 0, closeProcessOrder); } else { @@ -3304,8 +3323,8 @@ namespace YLErp.BLL { nodeArr.Add(approvalConditionSecend.node); } - query = GetTradeLinqQuery(predicate, firstBranch, firstCount, 1, branchIndex, nodeArr); - var secondQuery = GetTradeLinqQuery(predicate, secondBranch, secondCount, 2, branchIndex, nodeArr); + query = GetTradeLinqQuery(predicate, firstBranch, firstCount, 1, branchIndex, nodeArr, closeProcessOrder); + var secondQuery = GetTradeLinqQuery(predicate, secondBranch, secondCount, 2, branchIndex, nodeArr, closeProcessOrder); query = query.Union(secondQuery); } @@ -3319,7 +3338,7 @@ namespace YLErp.BLL query = query.OrderByDescending(s => s.OptDate); return query; } - private IQueryable GetTradeLinqQuery(Expression> predicate, IQueryable approvalprocessQuery, int tradeOpenProcessOrder, int branchOrder) + private IQueryable GetTradeLinqQuery(Expression> predicate, IQueryable approvalprocessQuery, int tradeOpenProcessOrder, int branchOrder, int closeProcessOrder = 0) { var query = from source in db.trade.Where(predicate) join process in approvalprocessQuery on source.ProcessOrderId equals process.order into pro @@ -3371,7 +3390,7 @@ namespace YLErp.BLL OptId = source.OptId, OptName = source.OptName, OptDate = source.OptDate, - ProcessStatus = source.ProcessStatus == "审批中" ? source.ProcessStatus + " 流程" + (branchOrder != 0 && source.ProcessOrderId > branchOrder ? source.ProcessOrderId - 2 : source.ProcessOrderId - 1) + "/" + tradeOpenProcessOrder : source.ProcessStatus, + ProcessStatus = source.ProcessStatus == "审批中" ? source.ProcessStatus + " 流程" + (branchOrder != 0 && source.ProcessOrderId > branchOrder ? source.ProcessOrderId - 2 : source.ProcessOrderId - 1) + "/" + (source.TradeStatus == "平仓待复核" || source.TradeStatus == "行权待复核" || source.TradeStatus == "互换待复核" ? (closeProcessOrder > 0 ? closeProcessOrder : tradeOpenProcessOrder) : tradeOpenProcessOrder) : source.ProcessStatus, ProcessOrderId = source.ProcessOrderId, ProcessOrderBranch = source.ProcessOrderBranch, ProcessRoleId = proce.roleId, @@ -3393,7 +3412,7 @@ namespace YLErp.BLL }; return query; } - private IQueryable GetTradeLinqQuery(Expression> predicate, IQueryable approvalprocessQuery, int tradeOpenProcessOrder, int node, int branchOrder, List nodeArr) + private IQueryable GetTradeLinqQuery(Expression> predicate, IQueryable approvalprocessQuery, int tradeOpenProcessOrder, int node, int branchOrder, List nodeArr, int closeProcessOrder = 0) { var tradeStatus = new string[] { "平仓待复核", "行权待复核", "互换待复核" }; var query = from source in db.trade.Where(predicate) @@ -3447,7 +3466,7 @@ namespace YLErp.BLL OptId = source.OptId, OptName = source.OptName, OptDate = source.OptDate, - ProcessStatus = source.ProcessStatus == "审批中" ? source.ProcessStatus + " 流程" + (branchOrder != 0 && source.ProcessOrderId > branchOrder ? source.ProcessOrderId - 2 : source.ProcessOrderId - 1) + "/" + tradeOpenProcessOrder : source.ProcessStatus, + ProcessStatus = source.ProcessStatus == "审批中" ? source.ProcessStatus + " 流程" + (branchOrder != 0 && source.ProcessOrderId > branchOrder ? source.ProcessOrderId - 2 : source.ProcessOrderId - 1) + "/" + (source.TradeStatus == "平仓待复核" || source.TradeStatus == "行权待复核" || source.TradeStatus == "互换待复核" ? (closeProcessOrder > 0 ? closeProcessOrder : tradeOpenProcessOrder) : tradeOpenProcessOrder) : source.ProcessStatus, ProcessOrderId = source.ProcessOrderId, ProcessOrderBranch = source.ProcessOrderBranch, ProcessRoleId = proce.roleId, diff --git a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs index aab9e45d..7d45a901 100644 --- a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs +++ b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs @@ -1,7 +1,8 @@ -using BaseOUDAL; +using BaseOUDAL; using YLErp.BLL; using YLErp.DBModels.Consts; using YLErp.DBModels.Enums; +using YLErp.Helpers; namespace YLErp.Modules.TradeModule { @@ -46,23 +47,50 @@ namespace YLErp.Modules.TradeModule return _tradeProcessCount.Value; } /// - /// 获取所有交易审批节点 + /// 获取所有交易审批节点(开仓流程 TradeProcess)。 /// - /// public List TradeProcess() { var tradeOrders = DbContext.approvalprocess.Where(t => t.processType == "TradeProcess").OrderBy(o => o.order).ToList(); return tradeOrders; } + + /// + /// 按交易推断流程类别(开仓/了结)获取审批节点(需求②)。 + /// 了结类(平仓待复核/行权待复核/互换待复核)取 CloseProcess;开仓类取 TradeProcess。 + /// 未配置对应流程时返回空列表(由调用方决定是否直接通过)。 + /// + public List TradeProcessByCategory(trade td) + { + var category = ResolveProcessCategory(td); + return DbContext.approvalprocess.Where(t => t.processType == category).OrderBy(o => o.order).ToList(); + } + + /// + /// 按类别统计审批节点数(需求②)。 + /// + public int TradeProcessCountByCategory(trade td) + { + return TradeProcessByCategory(td).Count; + } + /// /// 初始化交易 审批点 /// /// public void InitTradeProcessOrder(trade td,int userId) { + var tradeProcess = TradeProcessByCategory(td); + // 该交易类别未配置审批流程 → 直接审批通过,无需审核 + if (tradeProcess == null || tradeProcess.Count == 0) + { + td.ProcessOrderId = ProcessTradeLog.审批通过; + td.ProcessStatus = ProcessTradeStatus.通过审批.ToString(); + td.ProcessOptDate = DateTime.Now; + return; + } td.ProcessOrderId = ProcessTradeLog.审批中; td.ProcessStatus = ProcessTradeStatus.审批中.ToString(); - var tradeProcess = TradeProcess(); var groupId = UserBLL.GetApprovalProcessGroup(userId); bool approvalBranch = tradeProcess.Any(x => x.approvalGroupId != 0);//审批流程有分支情况 if (td.ProcessOrderId == 1) @@ -88,6 +116,169 @@ namespace YLErp.Modules.TradeModule { td.ProcessOrderId = 2; } + + // 需求①:进入审批流程时即应用触发条件——从起始节点开始,跳过所有不满足触发条件的节点。 + // 若所有节点均不满足 → 直接审批通过(无需任何人审核)。 + ApplyTriggerFromStart(tradeProcess, td, userId); + } + + /// + /// 从当前 ProcessOrderId 起点开始,按触发条件跳过无需审批的节点(需求①)。 + /// 场景:交易提交进入审批流程时,若节点1、2配置的触发条件均不满足,则直接跳到节点3; + /// 若全部节点都不满足,则直接审批通过。 + /// + private void ApplyTriggerFromStart(List tradeProcess, trade td, int userId) + { + if (tradeProcess == null || tradeProcess.Count == 0) return; + + // 取当前起点节点(主干 node=0) + var current = tradeProcess.FirstOrDefault(x => x.order == td.ProcessOrderId && x.node == 0); + // 若起点不在主干(如分支调整后 ProcessOrderId=2),取该 order 的主干节点 + if (current == null) + { + current = tradeProcess.FirstOrDefault(x => x.order >= td.ProcessOrderId && x.node == 0); + } + if (current == null) return; + + var ctx = BuildTriggerContext(td, userId); + var target = ConditionEvaluator.FindFirstTriggeredNode(tradeProcess, current, ctx); + if (target == null) + { + // 所有节点都不满足触发条件 → 直接审批通过,无需审核 + td.ProcessOrderId = ProcessTradeLog.审批通过; + td.ProcessStatus = ProcessTradeStatus.通过审批.ToString(); + td.ProcessOptDate = DateTime.Now; + return; + } + // target 即为第一个满足触发条件、需实际审批的节点 + if (target.order != td.ProcessOrderId) + { + td.ProcessOrderId = target.order; + } + } + + /// + /// 节点触发条件(需求①):在已确定下一审批节点 nextOrder 后,若该节点配置了 triggerCondition, + /// 则只有「满足触发条件」才进入该节点审批;不满足则跳过该节点,继续向后寻找,直到找到可进入的节点或抵达流程末尾。 + /// 语义:triggerCondition 为空 → 无条件进入审批;非空 → 满足才进入,不满足则跳过。 + /// 非侵入式:原有分支推进逻辑不变,仅在其结果之上叠加触发判断循环。 + /// + /// 当前流程的全部节点(已按 order 排序) + /// 原逻辑计算出的下一节点(可能为 null) + /// 条件求值业务上下文(已含 trade、本次交易名义本金等) + /// 最终应推进到的节点;若应结束流程则返回 null + protected static approvalprocess AdvanceThroughTriggerNodes( + List tradeProcess, + approvalprocess nextOrder, + ConditionContext ctx) + { + // 不满足触发条件的节点需跳过:循环向后找第一个可进入的节点。 + while (nextOrder != null && !string.IsNullOrWhiteSpace(nextOrder.triggerCondition)) + { + if (ConditionEvaluator.Evaluate(nextOrder.triggerCondition, ctx)) + { + break; // 满足触发条件 → 进入该节点审批,停止跳过。 + } + + // 不满足触发条件 → 跳过该节点,向后取下一个主干节点(node=0),继续判断。 + nextOrder = tradeProcess.FirstOrDefault(x => x.order > nextOrder.order && x.node == 0); + } + return nextOrder; + } + + /// + /// 构建触发条件求值上下文:从 trade 及其关联的 trade_cash 取本次交易名义本金(了结场景)。 + /// 本次交易名义本金 = 本次了结操作的 trade_cash.UnwindStockEqvNotional 绝对值。 + /// + protected ConditionContext BuildTriggerContext(trade td, int userId) + { + var ctx = new ConditionContext + { + UserId = userId, + Trade = td, + ProcessCategory = ResolveProcessCategory(td), + InitGroupId = UserBLL.GetApprovalProcessGroup(userId) + }; + // 了结场景:取本次平仓名义本金 + if (td != null && ctx.ProcessCategory == ProcessCategoryConst.Close) + { + if (td.TradeType == "收益互换") + { + // 互换:从 swap_event.EventData(JSON) 反序列化取 CloseNotionalValue + var swapEvent = DbContext.swap_event + .Where(x => x.SwapTradeId == td.id && !x.Invalid + && (x.EventType == (int)SwapEventTypeEnum.平仓 || x.EventType == (int)SwapEventTypeEnum.互换)) + .OrderByDescending(x => x.id) + .FirstOrDefault(); + ctx.CurrentNotional = CalcSwapCloseNotionalFromEventData(swapEvent?.EventData); + } + else + { + // 期权:当次平仓名义本金 = 期初名义本金 × 平仓比例(UnwindPercentRate),取绝对值 + var tc = DbContext.trade_cash + .Where(t => t.TradeId == td.id && t.ValidState == ConsGlobal.InValid && !t.IsDeleted) + .OrderByDescending(t => t.id) + .FirstOrDefault(); + ctx.CurrentNotional = CalcOptionCloseNotional(td.OriginalStockEqvNotional, tc?.UnwindPercentRate); + } + } + return ctx; + } + + /// + /// 互换:从 swap_event.EventData(JSON) 反序列化取 CloseNotionalValue 绝对值。 + /// 容错:EventData 为 null/空/非法 JSON / unwindData=null 时返回 0(不影响审批阈值判断)。 + /// 抽为静态纯函数以支持无库单测。 + /// + public static double CalcSwapCloseNotionalFromEventData(string eventData) + { + if (string.IsNullOrEmpty(eventData)) return 0; + try + { + var unwindData = Newtonsoft.Json.JsonConvert.DeserializeObject(eventData); + return unwindData != null ? Math.Abs((double)unwindData.CloseNotionalValue) : 0; + } + catch + { + return 0; + } + } + + /// + /// 期权:当次平仓名义本金 = 期初名义本金 × 平仓比例(UnwindPercentRate),取绝对值。 + /// 容错:任一参数为 null 时返回 0。抽为静态纯函数以支持无库单测。 + /// + public static double CalcOptionCloseNotional(double? originalStockEqvNotional, double? unwindPercentRate) + { + if (!originalStockEqvNotional.HasValue || !unwindPercentRate.HasValue) return 0; + return Math.Abs(originalStockEqvNotional.Value * unwindPercentRate.Value); + } + + /// + /// 需求①:判断按触发条件是否需要审批。 + /// 取该交易类别的审批流程,若所有节点配置的触发条件都不满足当前业务,则无需审批(返回 false)。 + /// 若无审批流程或节点无触发条件,返回 true(需要审批)。 + /// + protected bool NeedApprovalByTrigger(trade td) + { + var tradeProcess = TradeProcessByCategory(td); + if (tradeProcess == null || tradeProcess.Count == 0) return true; + + var start = tradeProcess.FirstOrDefault(x => x.node == 0); + if (start == null) start = tradeProcess.OrderBy(x => x.order).First(); + + var ctx = BuildTriggerContext(td, UserId); + var target = ConditionEvaluator.FindFirstTriggeredNode(tradeProcess, start, ctx); + return target != null; + } + + /// + /// 按交易状态推断流程类别:开仓(TradeProcess) / 了结(CloseProcess)。需求②。 + /// 委托给 ProcessCategoryConst.Resolve,供全局复用。 + /// + protected static string ResolveProcessCategory(trade td) + { + return ProcessCategoryConst.Resolve(td); } /// /// 添加交易操作日志 @@ -521,4 +712,31 @@ namespace YLErp.Modules.TradeModule return rateCalcModeValue; } } + + /// + /// 交易流程类别常量(需求②):对应 approvalprocess.processType 的取值。 + /// + public static class ProcessCategoryConst + { + /// 开仓审批流程 + public const string Open = "TradeProcess"; + + /// 了结/平仓/行权审批流程 + public const string Close = "CloseProcess"; + + /// + /// 按交易状态推断流程类别:处于平仓/行权/互换待复核的交易视为「了结」类操作。 + /// + public static string Resolve(trade td) + { + if (td == null) return Open; + if (td.TradeStatus == ConsTrade.平仓待复核 + || td.TradeStatus == ConsTrade.行权待复核 + || td.TradeStatus == ConsTrade.互换待复核) + { + return Close; + } + return Open; + } + } } diff --git a/YLErpWeb/App_Docs/导出模板/结算报告模板.xlsx b/YLErpWeb/App_Docs/导出模板/结算报告模板.xlsx index ba6be62b..ae0f519f 100644 Binary files a/YLErpWeb/App_Docs/导出模板/结算报告模板.xlsx and b/YLErpWeb/App_Docs/导出模板/结算报告模板.xlsx differ diff --git a/YLErpWeb/Controllers/AccountOpeningProcessController.cs b/YLErpWeb/Controllers/AccountOpeningProcessController.cs index db9a39aa..9d45614a 100644 --- a/YLErpWeb/Controllers/AccountOpeningProcessController.cs +++ b/YLErpWeb/Controllers/AccountOpeningProcessController.cs @@ -1,4 +1,6 @@ using Org.BouncyCastle.Ocsp; +using Newtonsoft.Json; +using YLErp.Helpers; using YLErp.DBModels.Enums; using YLErp.Model.Enum; using YLErp.Modules.AppModule; @@ -43,7 +45,7 @@ namespace YLErp.Web.Controllers [HttpPost] public ActionResult AddProcess(string type, List data) { - if (type == "TradeProcess" && data != null && data.Count > 0) + if ((type == "TradeProcess" || type == "CloseProcess") && data != null && data.Count > 0) { foreach (var item in data) { @@ -54,6 +56,25 @@ namespace YLErp.Web.Controllers } } + // 校验节点触发条件 JSON 格式,避免非法数据入库 + if (data != null) + { + foreach (var item in data) + { + if (!string.IsNullOrWhiteSpace(item.triggerCondition)) + { + try + { + JsonConvert.DeserializeObject(item.triggerCondition); + } + catch + { + return JsonError("触发条件格式非法,请检查括号与条件是否完整"); + } + } + } + } + new ApprovalProcessService(CurUser).AddProcess(type, data); return JsonSuccess("设置成功"); } @@ -66,10 +87,13 @@ namespace YLErp.Web.Controllers var tradeProcess = list.Where(s => s.processType == "TradeProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList(); + // 需求②:了结/平仓/行权审批流程 + var closeProcess = list.Where(s => s.processType == "CloseProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList(); + var creditProcess = list.Where(s => s.processType == "CreditProcess").OrderBy(s => s.order).ToList(); var outCashProcess = list.Where(s => s.processType == "OutCashProcess").OrderBy(s => s.order).ToList(); var clientProcess = list.Where(s => s.processType == "ClientProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList(); - return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess }); + return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CloseProcess = closeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess }); } diff --git a/YLErpWeb/Controllers/EodPriceController.cs b/YLErpWeb/Controllers/EodPriceController.cs index 89ac8cbc..62747ff9 100644 --- a/YLErpWeb/Controllers/EodPriceController.cs +++ b/YLErpWeb/Controllers/EodPriceController.cs @@ -1,4 +1,5 @@ -using YLErp.DBModels; +using YLErp.Core; +using YLErp.DBModels; using YLErp.Modules.EodModule; namespace YLErp.Web.Controllers @@ -54,9 +55,13 @@ namespace YLErp.Web.Controllers public ActionResult EodFuturePriceEdit(string enid) { - if (string.IsNullOrEmpty(enid) || enid == "0") + bool isNew = string.IsNullOrEmpty(enid) || enid == "0"; + ViewBag.IsNew = isNew; + if (isNew) { - return View(new eod_commodity_future_price()); + // 新建:手工输入标的代码,失焦时调 LookupUnderlyingForEod 校验并带出市场/UnderlyingId。 + // 默认估值日期=今天:避免未填日期时落库为 0001-01-01、落在列表默认窗口(今天)之外而查不出。 + return View(new eod_commodity_future_price { ValueDate = DateTime.Today }); } var intid = DecryptInt(enid); var dbmodel = yldb.eod_commodity_future_price.Find(intid); @@ -70,9 +75,12 @@ namespace YLErp.Web.Controllers public ActionResult EodStockPriceEdit(string enid) { - if (string.IsNullOrEmpty(enid) || enid == "0") + bool isNew = string.IsNullOrEmpty(enid) || enid == "0"; + ViewBag.IsNew = isNew; + if (isNew) { - return View(new eod_stock_price()); + // 新建:手工输入标的代码,失焦时调 LookupUnderlyingForEod 校验并带出市场。 + return View(new eod_stock_price { ValueDate = DateTime.Today }); } var intid = DecryptInt(enid); var dbmodel = yldb.eod_stock_price.Find(intid); @@ -85,9 +93,13 @@ namespace YLErp.Web.Controllers } public ActionResult EodBondPriceEdit(string enid) { - if (string.IsNullOrEmpty(enid) || enid == "0") + bool isNew = string.IsNullOrEmpty(enid) || enid == "0"; + ViewBag.IsNew = isNew; + if (isNew) { - return View(new ChinaBondValuation()); + // 新建:手工输入债券代码(bond_id),失焦时调 LookupUnderlyingForEod 校验并带出市场。 + // 默认估值日期=今天:避免未填日期时落库为 0001-01-01、落在列表默认窗口(今天)之外而查不出。 + return View(new ChinaBondValuation { valuation_date = DateTime.Today }); } var intid = DecryptLong(enid); var dbmodel = yldb.china_bond_valuation.Find(intid); @@ -98,6 +110,129 @@ namespace YLErp.Web.Controllers ViewData["市场"] = DataCacheProvider.GetUnderlyingDataSource().GetData(dbmodel.bond_id)?.MarketName; return View(dbmodel); } + + /// + /// 新建日终价格时,按用户手工输入的标的代码精确查一条标的,带出名称/市场/Id。 + /// 只返回已上市(LaunchState=="1")且类型匹配的标的——与列表查询 inner join 的过滤对齐, + /// 从源头杜绝"新增能存但查不出"的幽灵记录。前端在输入框失焦时调用,不依赖任何下拉/补全插件。 + /// + /// 标的代码(用户手工输入) + /// bond | future | stock,决定允许的标的类型集合 + [HttpPost] + public JsonResult LookupUnderlyingForEod(string code, string kind) + { + if (string.IsNullOrWhiteSpace(code)) + { + return JsonError("请输入标的代码"); + } + code = code.Trim(); + + string[] types = kind switch + { + "future" => new[] + { + ConsGlobal.InstrumentType.CommodityFutures, + ConsGlobal.InstrumentType.GoldFutures, + ConsGlobal.InstrumentType.TBFutures, + ConsGlobal.InstrumentType.OtherFutures, + ConsGlobal.InstrumentType.AbroadFutures, + }, + "stock" => new[] + { + ConsGlobal.InstrumentType.Stock, + ConsGlobal.InstrumentType.StockIndex, + ConsGlobal.InstrumentType.StockIF, + ConsGlobal.InstrumentType.HKStock, + ConsGlobal.InstrumentType.HKStockIndex, + ConsGlobal.InstrumentType.NewOtcStock, + ConsGlobal.InstrumentType.AbroadStock, + ConsGlobal.InstrumentType.AbroadStockIndex, + }, + _ => new[] + { + ConsGlobal.InstrumentType.Bonds, + ConsGlobal.InstrumentType.TBonds, + ConsGlobal.InstrumentType.CreditBonds, + ConsGlobal.InstrumentType.OtherBonds, + }, + }; + var set = new HashSet(types); + + var u = yldb.underlying_manager + .Where(n => n.UnderlyingCode == code && n.LaunchState == "1" && set.Contains(n.UnderlyingInstrumentType)) + .Select(n => new { n.id, n.UnderlyingCode, n.UnderlyingName, n.MarketName }) + .FirstOrDefault(); + + if (u == null) + { + return JsonError("未找到该标的(不存在、未上市或类型不匹配),无法录入"); + } + + return JsonSuccess("", new + { + Id = u.id, + Code = u.UnderlyingCode, + Name = u.UnderlyingName, + Market = u.MarketName, + }); + } + + /// + /// 新建日终价格时,按手工输入的片段做服务端模糊联想(代码或名称包含匹配),只回前 20 条。 + /// 与 LookupUnderlyingForEod 同样只返回已上市(LaunchState=="1")且类型匹配的标的。 + /// 用原生下拉渲染(不依赖 jQuery UI,bundle 未打包),避免几十万标的全量渲染卡死。 + /// + [HttpPost] + public JsonResult SuggestUnderlyingForEod(string q, string kind) + { + if (string.IsNullOrWhiteSpace(q)) + { + return JsonSuccess("", new List()); + } + q = q.Trim(); + + string[] types = kind switch + { + "future" => new[] + { + ConsGlobal.InstrumentType.CommodityFutures, + ConsGlobal.InstrumentType.GoldFutures, + ConsGlobal.InstrumentType.TBFutures, + ConsGlobal.InstrumentType.OtherFutures, + ConsGlobal.InstrumentType.AbroadFutures, + }, + "stock" => new[] + { + ConsGlobal.InstrumentType.Stock, + ConsGlobal.InstrumentType.StockIndex, + ConsGlobal.InstrumentType.StockIF, + ConsGlobal.InstrumentType.HKStock, + ConsGlobal.InstrumentType.HKStockIndex, + ConsGlobal.InstrumentType.NewOtcStock, + ConsGlobal.InstrumentType.AbroadStock, + ConsGlobal.InstrumentType.AbroadStockIndex, + }, + _ => new[] + { + ConsGlobal.InstrumentType.Bonds, + ConsGlobal.InstrumentType.TBonds, + ConsGlobal.InstrumentType.CreditBonds, + ConsGlobal.InstrumentType.OtherBonds, + }, + }; + var set = new HashSet(types); + + var list = yldb.underlying_manager + .Where(n => n.LaunchState == "1" && set.Contains(n.UnderlyingInstrumentType) + && (n.UnderlyingCode.Contains(q) || n.UnderlyingName.Contains(q))) + .OrderBy(n => n.UnderlyingCode) + .Take(20) + .Select(n => new { n.id, Code = n.UnderlyingCode, Name = n.UnderlyingName, Market = n.MarketName }) + .ToList(); + + return JsonSuccess("", list); + } + [HttpPost] public JsonResult EodFuturePriceEditJson(eod_commodity_future_price req) { diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 52d4ae46..717ad13f 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -283,9 +283,12 @@ namespace YLErp.Web.Controllers /// /// /// - public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType) + public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType, decimal notionalValue = 0, decimal posiNotionalValue = 0) { - var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, closePercent, eventType); + // 前端按"占期初(original)"语义传 closePercent(A);后端 GetUnwindInterests 按"占剩余(remaining)"语义(B)计算。 + // 多空互换前端不传 notionalValue/posiNotionalValue(默认 0),则跳过转换保持原行为。 + var convertedClosePercent = SwapDealService.ToRemainingClosePercent(closePercent, notionalValue, posiNotionalValue); + var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, convertedClosePercent, eventType); foreach (var interest in interests) { interest.TdInterestAmount=Math.Round(interest.TdInterestAmount, ConsGlobal.MoneyRound,MidpointRounding.AwayFromZero); diff --git a/YLErpWeb/Controllers/calendarController.cs b/YLErpWeb/Controllers/calendarController.cs index e37905cf..2283634e 100644 --- a/YLErpWeb/Controllers/calendarController.cs +++ b/YLErpWeb/Controllers/calendarController.cs @@ -296,5 +296,13 @@ namespace YLErp.Web.Controllers CalendarBLL.ResetCalendarForQdp(); return JsonSuccess("删除成功"); } + + [HttpPost] + public JsonResult Reset() + { + CalendarBLL.IsListOld = true; + CalendarBLL.ResetCalendarForQdp(); + return JsonSuccess("重置成功"); + } } } diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 0a162c3c..53c4e48d 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -6370,14 +6370,17 @@ namespace YLErp.Web.Controllers return ShowError("请选择文档类型!"); } - if (req.TradeDateStart == null) { req.TradeDateStart = DateTime.MinValue; } - if (req.TradeDateEnd == null) { req.TradeDateEnd = DateTime.MaxValue; } - var db_trade_contract_r = yldb.trade_contract_r.AsQueryable(); + var documentQuery = TradeConfirmationDocumentQuery.Create( + yldb.trade_contract_document, + yldb.trade_contract_r, + yldb.trade, + req.TradeDateStart, + req.TradeDateEnd); - var query = from doc in yldb.trade_contract_document - join r in db_trade_contract_r - on doc.Code equals r.ContractCode - where doc.Type == ContractTypeEnum.Trade && doc.ValueDate >= req.TradeDateStart && doc.ValueDate <= req.TradeDateEnd && r.IsValid + var query = from item in documentQuery + let doc = item.Document + let r = item.Relation + where doc.Type == ContractTypeEnum.Trade select new { doc.ClientId, diff --git a/YLErpWeb/Controllers/trade_cashController.cs b/YLErpWeb/Controllers/trade_cashController.cs index b2730b8c..44106885 100644 --- a/YLErpWeb/Controllers/trade_cashController.cs +++ b/YLErpWeb/Controllers/trade_cashController.cs @@ -148,7 +148,7 @@ namespace YLErp.Web.Controllers { valueDate = QdpCalendarHelper.GetNonHolidayDefore(valueDate); } - if (!EodPriceQueryService.TryGetEodPrice(valueDate, td.UnderlyingCode, out _)) + if (!EodPriceQueryService.TryGetSettlementEodPrice(valueDate, td.UnderlyingCode, out _)) { return JsonError($"交易日{valueDate:yyyy-MM-dd}的结算价或收盘价未找到!"); } diff --git a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml index c5be4363..2ecdfefc 100644 --- a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml +++ b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml @@ -52,7 +52,7 @@ -