Files
zszq-trs/UnitTestProject/Modules/SwapModule/RecordingSwapFlowEventService.cs
T
hjhan b501e21904 test(swap): 启用MergeCompose场景6/7+SwapFlowEventService录制钩子+RecordingSwapFlowEventService
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测试全绿,无回归。
2026-07-03 09:38:05 +08:00

354 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// 录制服务:继承 SwapFlowEventServiceoverride OnBefore/OnAfterMergePageEvent 钩子,
/// 在真实的簿记流程中捕获簿记前的持仓快照和簿记后的事件,保存为黄金文件。
///
/// 使用方式:在 SwapTrade2Controller 中临时替换 SwapFlowEventService 为此类即可。
/// 录制完成后恢复原服务。
/// </summary>
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<swap_flow_merge> merges, List<swap_position> positions)
{
// 不做任何事,等 OnAfter 里一起保存
}
protected override void OnAfterMergePageEvent(
int swapTradeId, DateTime tradeDate,
List<swap_flow_event> 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<swap_flow_event> 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 => "两条+有持仓→复杂组合",
_ => "未知"
};
/// <summary>重置录制计数器(每次测试前调用)</summary>
public static void ResetCount() { lock (_lock) _recordCount = 0; }
}
/// <summary>
/// 用数据库直接做完整录制:模拟 MergePageEvent 的输入,但在执行前捕获完整上下文。
/// 这个测试在连 DB 环境下运行,捕获簿记前一刻的持仓状态。
/// </summary>
[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"
};
/// <summary>
/// 从数据库找有完整上下文的事件,录制完整黄金文件(含簿记前持仓)
/// 策略:找最近的开仓事件(因为开仓时的持仓必然为 0 或同向),
/// 以及平仓事件(需要找到平仓前一刻的持仓数量)
/// </summary>
/// <summary>
/// 仅在需要重新录制黄金文件时手动启用。需要数据库连接。
/// 正常测试运行时跳过此测试,避免生成无效的黄金文件。
/// </summary>
[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, int>();
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个");
}
/// <summary>
/// 核心:从事件结果反推簿记前的持仓状态
/// 逻辑:
/// - 平仓事件说明之前有反向持仓,数量=平仓数量
/// - 持仓的 PosiGrossPrice 可以从平仓金额公式反推
/// </summary>
private List<swap_position> ReconstructPreBookingPositions(
List<swap_flow_event> events,
List<swap_position> currentPositions,
List<swap_flow_merge> merges)
{
var result = new List<swap_position>();
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<swap_flow_event> 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 => "两条+有持仓→复杂组合",
_ => "未知"
};
}
/// <summary>
/// 黄金文件数据模型(录制/回放 MergePageEvent 用)
/// </summary>
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; }
/// <summary>输入:汇总流水列表</summary>
public JArray InputMerges { get; set; }
/// <summary>输入:已有持仓列表(可能为空)</summary>
public JArray InputPositions { get; set; }
/// <summary>输入:交易主信息</summary>
public JObject InputTrade { get; set; }
/// <summary>输入:交易扩展信息(含 FlowBookMode、Direction、SettlementRules</summary>
public JObject InputTradeExtend { get; set; }
/// <summary>期望输出:开平仓事件列表</summary>
public JArray ExpectedEvents { get; set; }
/// <summary>录制时间</summary>
public DateTime RecordedAt { get; set; } = DateTime.Now;
/// <summary>录制来源数据库</summary>
public string SourceDb { get; set; } = "test";
}
}