From b501e2190428050e00f2b5e65f0b24f5065c747f Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 3 Jul 2026 09:38:05 +0800 Subject: [PATCH] =?UTF-8?q?test(swap):=20=E5=90=AF=E7=94=A8MergeCompose?= =?UTF-8?q?=E5=9C=BA=E6=99=AF6/7+SwapFlowEventService=E5=BD=95=E5=88=B6?= =?UTF-8?q?=E9=92=A9=E5=AD=90+RecordingSwapFlowEventService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. MergeComposeScenarioTest 场景6/7 启用(去掉Ignore): 此前失败是stale binary(未重新编译),重新build后8场景全部通过。 - 场景6 单条merge无持仓→DealNoPosition创建交易 - 场景7 两条merge无持仓→创建交易+平仓 2. SwapFlowEventService 补OnBefore/OnAfterMergePageEvent录制钩子(默认空实现): - OnBefore: DB查询完成、合成逻辑执行前触发,可捕获输入快照 - OnAfter: PersistEvents后触发,捕获输出事件 生产路径为空方法体,零行为影响。 3. 移植RecordingSwapFlowEventService(借鉴testable分支): override OnAfterMergePageEvent录制golden文件,备用录制工具。 内联GoldenFileModel(简单POCO)。FullRecord_WithPreBookingPositions连库测试自动跳过。 SwapModule 171测试全绿,无回归。 --- .../SwapModule/MergeComposeScenarioTest.cs | 5 - .../RecordingSwapFlowEventService.cs | 353 ++++++++++++++++++ .../SwapModule/SwapFlowEventService.cs | 11 + 3 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/RecordingSwapFlowEventService.cs diff --git a/UnitTestProject/Modules/SwapModule/MergeComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/MergeComposeScenarioTest.cs index 249034da..e816b056 100644 --- a/UnitTestProject/Modules/SwapModule/MergeComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/MergeComposeScenarioTest.cs @@ -92,12 +92,8 @@ namespace YLErp.Modules.SwapModule #endregion #region 场景6:单条merge + 无持仓 → DealNoPosition 创建一笔交易 - // TODO: 场景6/7 借鉴自 testable 分支,当前分支 DealNoPosition 内部实现细节 - // (floatRate/swap_position 查询) 与 testable 分支有差异,CreatedTrades 捕获不到。 - // 校验链场景(1-5,8)已通过,创建交易路径待 DealNoPosition seam 对齐后启用。 [TestMethod] - [Ignore] public void Scenario6_SingleMerge_NoPosition_ShouldCreateOneTrade() { var client = CreateClient(); @@ -122,7 +118,6 @@ namespace YLErp.Modules.SwapModule #region 场景7:两条merge + 无持仓 → DealNoPosition 创建交易+平仓 [TestMethod] - [Ignore] public void Scenario7_TwoMerges_NoPosition_ShouldCreateTradeAndUnwind() { var client = CreateClient(); diff --git a/UnitTestProject/Modules/SwapModule/RecordingSwapFlowEventService.cs b/UnitTestProject/Modules/SwapModule/RecordingSwapFlowEventService.cs new file mode 100644 index 00000000..2a0423a5 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/RecordingSwapFlowEventService.cs @@ -0,0 +1,353 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Model; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 录制服务:继承 SwapFlowEventService,override OnBefore/OnAfterMergePageEvent 钩子, + /// 在真实的簿记流程中捕获簿记前的持仓快照和簿记后的事件,保存为黄金文件。 + /// + /// 使用方式:在 SwapTrade2Controller 中临时替换 SwapFlowEventService 为此类即可。 + /// 录制完成后恢复原服务。 + /// + public class RecordingSwapFlowEventService : SwapFlowEventService + { + private static readonly string GoldenDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "SwapFlowEvent"); + + private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Include, + DateFormatString = "yyyy-MM-ddTHH:mm:ss" + }; + + private static readonly object _lock = new object(); + private static int _recordCount = 0; + private readonly int _maxRecords; + + public RecordingSwapFlowEventService(OptUserInfo optUser, int maxRecords = 50) : base(optUser) + { + _maxRecords = maxRecords; + } + + protected override void OnBeforeMergePageEvent( + int swapTradeId, DateTime tradeDate, + trade trade, trade_extend tradeExtend, + List merges, List positions) + { + // 不做任何事,等 OnAfter 里一起保存 + } + + protected override void OnAfterMergePageEvent( + int swapTradeId, DateTime tradeDate, + List resultEvents) + { + if (resultEvents == null || resultEvents.Count == 0) return; + + lock (_lock) + { + if (_recordCount >= _maxRecords) return; + _recordCount++; + + try + { + var firstEvt = resultEvents[0]; + var scenarioId = ClassifyFromEvents(resultEvents); + var fileName = $"recorded_s{scenarioId}_{swapTradeId}_{tradeDate:yyyyMMdd}_{_recordCount}.json"; + + // 注意:这里 trade/tradeExtend/merges/positions 已在 OnBefore 中拿到 + // 但 OnAfter 不传这些参数,需要在这里重新查一次(此时数据可能已变更) + // 所以这个录制模式最好配合 OnBefore 一起使用 + // 简化方案:只记录事件,在专门的测试中做完整录制 + + var goldenData = new GoldenFileModel + { + Scenario = GetScenarioName(scenarioId), + ScenarioId = scenarioId, + SwapTradeId = swapTradeId, + TradeDate = tradeDate, + UnderlyingCode = firstEvt.UnderlyingCode, + InputMerges = new JArray(), // 需要在 OnBefore 中填充 + InputPositions = new JArray(), + ExpectedEvents = JArray.FromObject(resultEvents, JsonSerializer.Create(JsonSettings)), + SourceDb = "recording-hook", + RecordedAt = DateTime.Now + }; + + Directory.CreateDirectory(GoldenDir); + var filePath = Path.Combine(GoldenDir, fileName); + File.WriteAllText(filePath, JsonConvert.SerializeObject(goldenData, JsonSettings)); + } + catch { /* 录制不应影响正常业务 */ } + } + } + + private int ClassifyFromEvents(List events) + { + var hasOpen = events.Any(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓); + var hasClose = events.Any(x => x.EventType == (int)SwapFlowEventTypeEnum.平仓); + + if (hasOpen && !hasClose) return 1; + if (hasClose && !hasOpen) return 3; + if (hasOpen && hasClose) return events.Count >= 3 ? 6 : 4; + return 6; + } + + private string GetScenarioName(int id) => id switch + { + 1 => "单条+无持仓→开仓", + 3 => "单条+有持仓反向→平仓", + 4 => "单条+反向有余→平仓+开仓", + 5 => "两条+无持仓→一开一平", + 6 => "两条+有持仓→复杂组合", + _ => "未知" + }; + + /// 重置录制计数器(每次测试前调用) + public static void ResetCount() { lock (_lock) _recordCount = 0; } + } + + /// + /// 用数据库直接做完整录制:模拟 MergePageEvent 的输入,但在执行前捕获完整上下文。 + /// 这个测试在连 DB 环境下运行,捕获簿记前一刻的持仓状态。 + /// + [TestClass] + public class SwapFlowEventFullRecordingTest + { + private static readonly string GoldenDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "SwapFlowEvent"); + + private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Include, + DateFormatString = "yyyy-MM-ddTHH:mm:ss" + }; + + /// + /// 从数据库找有完整上下文的事件,录制完整黄金文件(含簿记前持仓) + /// 策略:找最近的开仓事件(因为开仓时的持仓必然为 0 或同向), + /// 以及平仓事件(需要找到平仓前一刻的持仓数量) + /// + /// + /// 仅在需要重新录制黄金文件时手动启用。需要数据库连接。 + /// 正常测试运行时跳过此测试,避免生成无效的黄金文件。 + /// + [TestMethod] + [TestCategory("DBRecording")] + [Ignore] + public void FullRecord_WithPreBookingPositions() + { + using var db = DbContextFactory.GetYLDbContext(); + + // 找所有有标的代码的事件,只取"流水自动"产生的事件(排除手工操作等同日多次操作) + var allEvents = db.swap_flow_event + .Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.ClientId > 0) + .AsEnumerable(); + + // 按 (SwapTradeId, EventDate, UnderlyingCode, OptLog) 分组, + // 确保每组事件来自同一次 MergePageEvent 调用 + var eventGroups = allEvents + .Where(x => x.OptLog == "流水自动") + .GroupBy(x => new { x.SwapTradeId, x.EventDate, x.UnderlyingCode }) + .ToList(); + + Console.WriteLine($"总事件分组: {eventGroups.Count}"); + + // 需要找到"刚完成的事件"对应的 merge 数据 + // 完整的 merge 数据 + 未被修改的持仓数据 = 理想的黄金文件 + // 实际上 merge 数据在 DataState=完成后还在 DB 中 + var savedByScenario = new Dictionary(); + int totalSaved = 0; + + foreach (var group in eventGroups) + { + var events = group.ToList(); + int scenarioId = Classify(events); + if (!savedByScenario.ContainsKey(scenarioId)) + savedByScenario[scenarioId] = 0; + if (savedByScenario[scenarioId] >= 3) continue; + + var firstEvt = events[0]; + + // 查 merge + var merges = db.swap_flow_merge + .Where(x => x.SwapTradeId == firstEvt.SwapTradeId + && x.OccurTime == firstEvt.EventDate + && x.UnderlyingCode == firstEvt.UnderlyingCode) + .ToList(); + + if (merges.Count == 0) continue; + + // 查持仓:这是关键 - 查当前时刻的持仓 + // 对于场景1(纯开仓),不需要持仓数据 + // 对于其他场景,我们需要"簿记前"的持仓 + // 但当前持仓是簿记后的,需要从事件反推簿记前持仓 + var positions = db.swap_position + .Where(x => x.SwapTradeId == firstEvt.SwapTradeId + && x.UnderlyingCode == firstEvt.UnderlyingCode + && !x.IsInitial && !x.Invalid) + .AsNoTracking() + .ToList(); + + // 从事件反推簿记前的持仓 + var preBookingPositions = ReconstructPreBookingPositions(events, positions, merges); + + var trade = db.trade.Find(firstEvt.SwapTradeId); + var tradeExtend = db.trade_extend.FirstOrDefault(x => x.TradeId == firstEvt.SwapTradeId); + + var goldenData = new GoldenFileModel + { + Scenario = GetScenarioName(scenarioId), + ScenarioId = scenarioId, + SwapTradeId = firstEvt.SwapTradeId, + TradeDate = firstEvt.EventDate, + UnderlyingCode = firstEvt.UnderlyingCode, + InputMerges = JArray.FromObject(merges, JsonSerializer.Create(JsonSettings)), + InputPositions = JArray.FromObject(preBookingPositions, JsonSerializer.Create(JsonSettings)), + InputTrade = trade != null ? JObject.FromObject(trade, JsonSerializer.Create(JsonSettings)) : null, + InputTradeExtend = tradeExtend != null ? JObject.FromObject(tradeExtend, JsonSerializer.Create(JsonSettings)) : null, + ExpectedEvents = JArray.FromObject(events, JsonSerializer.Create(JsonSettings)), + SourceDb = "full-recording", + RecordedAt = DateTime.Now + }; + + Directory.CreateDirectory(GoldenDir); + var idx = savedByScenario[scenarioId] + 1; + var fileName = $"full_s{scenarioId}_sample{idx}_{firstEvt.SwapTradeId}_{firstEvt.EventDate:yyyyMMdd}.json"; + File.WriteAllText(Path.Combine(GoldenDir, fileName), JsonConvert.SerializeObject(goldenData, JsonSettings)); + + savedByScenario[scenarioId]++; + totalSaved++; + + Console.WriteLine($" Saved {fileName} (merge={merges.Count} pos={preBookingPositions.Count} events={events.Count})"); + } + + Console.WriteLine("\n=== 录制结果 ==="); + foreach (var kv in savedByScenario.OrderBy(x => x.Key)) + Console.WriteLine($"场景{kv.Key}: {kv.Value}个"); + Console.WriteLine($"总计: {totalSaved}个"); + + Assert.IsTrue(totalSaved > 0, "至少录制1个"); + } + + /// + /// 核心:从事件结果反推簿记前的持仓状态 + /// 逻辑: + /// - 平仓事件说明之前有反向持仓,数量=平仓数量 + /// - 持仓的 PosiGrossPrice 可以从平仓金额公式反推 + /// + private List ReconstructPreBookingPositions( + List events, + List currentPositions, + List merges) + { + var result = new List(); + var closeEvents = events.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.平仓).ToList(); + + if (closeEvents.Count == 0) return result; // 纯开仓不需要持仓 + + var firstClose = closeEvents[0]; + var firstMerge = merges[0]; + + // 找到与平仓事件 PositionId 匹配的持仓记录 + var posRecord = currentPositions.FirstOrDefault(x => x.PositionId == firstClose.PositionId); + + // 平仓前持仓数量 = 平仓数量 + // 平仓前的 PosiGrossPrice = 该持仓的期初价格 + // 从平仓金额公式反推: amount = (flowMergeAvg - PosiGrossPrice) * unwindQty * ContractSize + // => PosiGrossPrice = flowMergeAvg - amount / (unwindQty * ContractSize) + + decimal preBookingQty = firstClose.Quantity; + decimal posiGrossPrice = firstMerge.TradingAmountAvgAbs; // 默认值 + + if (firstClose.MarkClosePnl != 0 && preBookingQty != 0 && firstMerge.ContractSize != 0) + { + posiGrossPrice = firstMerge.TradingAmountAvgAbs - firstClose.MarkClosePnl / (preBookingQty * firstMerge.ContractSize); + } + + var reconstructed = new swap_position + { + PositionId = firstClose.PositionId, + SwapTradeId = firstClose.SwapTradeId, + UnderlyingCode = firstClose.UnderlyingCode, + PositionType = firstClose.PositionType, // 被平仓的方向 + PosiDirection = firstClose.PayDirection, + PosiQuantity = preBookingQty, + PosiGrossPrice = posiGrossPrice, + PosiNetPrice = posiGrossPrice, // 近似 + ContractSize = firstMerge.ContractSize, + IsInitial = false, + Invalid = false, + PosiTradingFee = posRecord?.PosiTradingFee ?? 0, + UnderlyingInstrumentType = firstClose.UnderlyingInstrumentType + }; + + result.Add(reconstructed); + return result; + } + + private int Classify(List events) + { + var hasOpen = events.Any(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓); + var hasClose = events.Any(x => x.EventType == (int)SwapFlowEventTypeEnum.平仓); + + if (hasOpen && !hasClose) return 1; + if (hasClose && !hasOpen) return 3; + if (hasOpen && hasClose) return events.Count >= 3 ? 6 : 4; + return 6; + } + + private string GetScenarioName(int id) => id switch + { + 1 => "单条+无持仓→开仓", + 3 => "单条+有持仓反向→平仓", + 4 => "单条+反向有余→平仓+开仓", + 5 => "两条+无持仓→一开一平", + 6 => "两条+有持仓→复杂组合", + _ => "未知" + }; + } + + /// + /// 黄金文件数据模型(录制/回放 MergePageEvent 用) + /// + public class GoldenFileModel + { + public string Scenario { get; set; } + public int ScenarioId { get; set; } + public int SwapTradeId { get; set; } + public DateTime TradeDate { get; set; } + public string UnderlyingCode { get; set; } + + /// 输入:汇总流水列表 + public JArray InputMerges { get; set; } + + /// 输入:已有持仓列表(可能为空) + public JArray InputPositions { get; set; } + + /// 输入:交易主信息 + public JObject InputTrade { get; set; } + + /// 输入:交易扩展信息(含 FlowBookMode、Direction、SettlementRules) + public JObject InputTradeExtend { get; set; } + + /// 期望输出:开平仓事件列表 + public JArray ExpectedEvents { get; set; } + + /// 录制时间 + public DateTime RecordedAt { get; set; } = DateTime.Now; + + /// 录制来源数据库 + public string SourceDb { get; set; } = "test"; + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs index b5d41579..4498b0c9 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs @@ -76,6 +76,15 @@ namespace YLErp.Modules.SwapModule protected virtual void RollbackTransaction(IDisposable transaction) => (transaction as Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction)?.Rollback(); + /// MergePageEvent 业务逻辑执行前钩子(默认空,录制golden用)。在DB查询完成、合成逻辑执行前触发 + protected virtual void OnBeforeMergePageEvent(int swapTradeId, DateTime tradeDate, + trade trade, trade_extend tradeExtend, + List merges, List positions) { } + + /// MergePageEvent 业务逻辑执行后钩子(默认空,录制golden用)。在PersistEvents之后触发,捕获输出事件 + protected virtual void OnAfterMergePageEvent(int swapTradeId, DateTime tradeDate, + List resultEvents) { } + #endregion /// @@ -133,6 +142,7 @@ namespace YLErp.Modules.SwapModule { var eodPositions = FindPositions(swapTradeId);//上一日终持仓信息 FindAndInvalidateFutureEvents(swapTradeId, tradeDate);//废弃当前清算日期及之后的开平仓事件 + OnBeforeMergePageEvent(swapTradeId, tradeDate, trade, tradeExtend, flowquery, eodPositions); var mergeUnderlyingGroup = flowquery.GroupBy(g => g.UnderlyingCode); int direction = tradeExtend.ExtendObj.Direction; foreach (var underlyingGroup in mergeUnderlyingGroup) @@ -252,6 +262,7 @@ namespace YLErp.Modules.SwapModule } } PersistEvents(flowEvents); + OnAfterMergePageEvent(swapTradeId, tradeDate, flowEvents); if (trans != null) CommitTransaction(trans); } catch (Exception ex)