diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index dcfde1d7..3e852ba8 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -80,7 +80,7 @@ namespace YLErp.Modules.SwapModule => fallbackPrice; public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate) - => TryRestoreEffectiveFundPosition(unwindData, valueDate); + => TryRestoreAndValidateUnwindData(unwindData, valueDate); protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 232622b4..7bd8621d 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -225,6 +225,10 @@ namespace YLErp.Modules.SwapModule /// 查询从最近 EOD 之后到平仓日已经生效的 Stock/Fund 公司行为。 /// 平仓可能跨越登记日、生效日和多个非交易日,因此不能只按 valueDate 命中一条记录。 /// 调用方已限定为 Stock/Fund 浮动腿;同日多条记录按生效日、主键稳定排序后逐条应用。 + /// + /// 查询范围说明: + /// - 严格 > eodDate(开区间):EOD 快照本身已经是除权后结果,不能再次套用 + /// - 范围示例:eodDate=8/14(除权后 2000/50),valueDate=8/20,则查询 (8/14, 8/20] 内的记录 /// protected virtual List FindFundCorporateActions( string underlyingCode, @@ -233,7 +237,7 @@ namespace YLErp.Modules.SwapModule { var fromDate = eodDate.Date; var toDate = valueDate.Date; - return DbContext.ex_dividend_info + var corporateActions = DbContext.ex_dividend_info .Where(x => x.ValidStatus && x.UnderlyingCode == underlyingCode && x.EffectiveDate.HasValue @@ -242,6 +246,9 @@ namespace YLErp.Modules.SwapModule .OrderBy(x => x.EffectiveDate) .ThenBy(x => x.id) .ToList(); + + Logger.Info($"[公司行为查询] 标的={underlyingCode} EOD={eodDate:yyyy-MM-dd} 平仓日={valueDate:yyyy-MM-dd} 查询到{corporateActions.Count}条公司行为"); + return corporateActions; } /// @@ -287,7 +294,7 @@ namespace YLErp.Modules.SwapModule /// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Stock/Fund、无 /// EOD 和固定/利息腿均返回 false,沿用原逻辑。 /// - protected virtual bool TryRestoreEffectiveFundPosition( + protected virtual bool TryRestorePositionFromEod( swap_position position, DateTime valueDate) { @@ -298,6 +305,7 @@ namespace YLErp.Modules.SwapModule || position.PosiDirection <= 0 || !SwapEodPositionService.IsCorporateActionInstrument(position.UnderlyingInstrumentType)) { + Logger.Info($"[公司行为恢复] 跳过非适用场景 positionId={position?.PositionId} direction={position?.PosiDirection} instrumentType={position?.UnderlyingInstrumentType}"); return false; } @@ -305,16 +313,33 @@ namespace YLErp.Modules.SwapModule position.SwapTradeId, position.PositionId, valueDate); - if (eodPosition == null - || HasCompletedFlowAfterFundEod( - position.SwapTradeId, - position.PositionId, - eodPosition.ValueDate, - valueDate)) + if (eodPosition == null) { + Logger.Info($"[公司行为恢复] 未找到有效EOD tradeId={position.SwapTradeId} positionId={position.PositionId} valueDate={valueDate:yyyy-MM-dd}"); return false; } + // 验证 EOD 数据完整性 + if (eodPosition.PosiQuantity <= 0 || eodPosition.PosiGrossPrice <= 0) + { + Logger.Info($"[公司行为恢复] EOD快照数据异常 tradeId={position.SwapTradeId} positionId={position.PositionId} " + + $"eodDate={eodPosition.ValueDate:yyyy-MM-dd} qty={eodPosition.PosiQuantity} price={eodPosition.PosiGrossPrice}"); + return false; + } + + if (HasCompletedFlowAfterFundEod( + position.SwapTradeId, + position.PositionId, + eodPosition.ValueDate, + valueDate)) + { + Logger.Info($"[公司行为恢复] EOD后已有完成流水,保持实时持仓 tradeId={position.SwapTradeId} positionId={position.PositionId} eodDate={eodPosition.ValueDate:yyyy-MM-dd}"); + return false; + } + + Logger.Info($"[公司行为恢复] 从EOD恢复基线 tradeId={position.SwapTradeId} positionId={position.PositionId} " + + $"eodDate={eodPosition.ValueDate:yyyy-MM-dd} eodQty={eodPosition.PosiQuantity} eodPrice={eodPosition.PosiGrossPrice}"); + if (!SwapEodPositionService.RestoreFundPositionFromEod(position, eodPosition)) { return false; @@ -326,15 +351,24 @@ namespace YLErp.Modules.SwapModule position.UnderlyingCode, eodPosition.ValueDate, valueDate); + foreach (var corporateAction in corporateActions ?? new List()) { + Logger.Info($"[公司行为应用] 除权前 id={corporateAction.id} " + + $"登记日={corporateAction.ExDividendDate:yyyy-MM-dd} " + + $"生效日={corporateAction.EffectiveDate:yyyy-MM-dd} " + + $"标的={position.UnderlyingCode} Q={position.PosiQuantity} P={position.PosiGrossPrice}"); + var closePrice = GetFundCorporateActionClosePrice( corporateAction, position.PosiGrossPrice); if (closePrice <= 0) { throw new ServiceException( - $"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); + $"Stock/Fund 标的【{position.UnderlyingCode}】" + + $"登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】" + + $"生效日【{corporateAction.EffectiveDate:yyyy-MM-dd}】" + + $"缺少有效收盘价(id={corporateAction.id}),无法执行除权"); } // TODO: 现金模式不使用税率参与 Q/P 除权;价格调整模式启用后再根据需求 考虑接入该配置。 @@ -344,6 +378,8 @@ namespace YLErp.Modules.SwapModule corporateAction, closePrice, 0m); + + Logger.Info($"[公司行为应用] 除权后 id={corporateAction.id} Q={position.PosiQuantity} P={position.PosiGrossPrice} notional={position.PosiNotionalValue}"); } return true; @@ -353,12 +389,12 @@ namespace YLErp.Modules.SwapModule /// 在直接提交前复核前端平仓数据。基线恢复成功时同步浮动流水价格、有效数量和名义本金, /// 并拒绝 CloseQty 超过有效 EOD 数量;全平请求则把数量规范为当前有效全部持仓。 /// - protected virtual bool TryRestoreEffectiveFundPosition( + protected virtual bool TryRestoreAndValidateUnwindData( UnwindData unwindData, DateTime valueDate) { var position = FindRealtimeFloatPosition(unwindData); - if (!TryRestoreEffectiveFundPosition(position, valueDate)) + if (!TryRestorePositionFromEod(position, valueDate)) { return false; } @@ -482,7 +518,7 @@ namespace YLErp.Modules.SwapModule var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault(); // Stock/Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50, // 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。 - var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, dealDate); // 恢复失败表示非 Stock/Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值, // 继续原有盘中流程,避免用不完整快照制造数量/价格。 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); @@ -636,7 +672,7 @@ namespace YLErp.Modules.SwapModule td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); // 收益结算与手工平仓共用 Stock/Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。 - var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, dealDate); // 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD // 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。 //var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期 @@ -1902,7 +1938,7 @@ namespace YLErp.Modules.SwapModule } UnwindNormalizer.NormalizeEventUnwindDate(unwindData); // 提交时再次从有效 EOD/实时腿复核 Stock/Fund 基线,不能只相信前端缓存的数量和价格。 - var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate); + var restoredCorporateActionBaseline = TryRestoreAndValidateUnwindData(unwindData, unwindData.ValueDate); // 这是直接提交路径的最后一道复核。若返回 false(非 Stock/Fund、无快照、或 EOD 后已有完成流水), // 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。 if (restoredCorporateActionBaseline)