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"; } }