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)