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测试全绿,无回归。
354 lines
16 KiB
C#
354 lines
16 KiB
C#
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>
|
||
/// 录制服务:继承 SwapFlowEventService,override 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";
|
||
}
|
||
}
|