test(swap): 新增互换分红重复计算录制测试+排查SQL(TDD红灯)

分红被重复计入 RealizedPnl 两次的根因:互换/平仓事件的 MarkClosePnl
已含分红成分(DividendIn),导致分红既进 RealizedMtmPnL(盯市列) 又进
RealizedDividend(分红列)。本次提供数据证据与回归基线:

- SwapDividendGoldenRecordTest: 连库录制1875(纯分红型)/1891(混合型)
  5表快照→JSON golden,录制时逐事件拆解 MarkClosePnl=价差+费+分红,
  按 PositionId 汇总出重复计入的分红金额。Step2 离线校验已通过。
  诊断结论:1875 重复302400,1891 重复-18.66,均与 eod 分红列吻合。
- golden JSON 纳入源码树(全量复制规则),修复后可作回归基线。
- 排查SQL工具箱: 基于真实DDL核对全部列名(swap_flow_event.EventDate/
  bond_payment_info.paying_interest 等),含定位样本/导出/验证/守恒4步。
- appsettings.json 测试库 zszq→glms 前缀修正。

详见 项目文档/互换分红损益字段语义与重复计算分析.md(9e6af84e)。
This commit is contained in:
hjhan
2026-06-26 09:13:37 +08:00
parent 9e6af84e47
commit 029475ce8d
7 changed files with 8100 additions and 3 deletions
+3
View File
@@ -1,6 +1,9 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# 数据库结构快照(体积大且随库结构变化失效,仅本地参考,不入版本库)
项目文档/数据库/排查SQL/*_yltrs_ylcms.sql
# User-specific files
*.suo
*.user
@@ -0,0 +1,426 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换分红损益 - 黄金文件录制测试(A1:仅录制,不回放)
/// ============================================================================
/// 目的:
/// 从测试库录制一笔"带分红的互换交易"完整生命周期数据(5表快照),
/// 序列化为 JSON golden 文件。既作为:
/// (1) 人工/脚本验证"分红重复计算 2 次"的数据证据;
/// (2) 后续 EOD 重构(抽虚方法)后,回放回归测试的 golden source 种子。
///
/// 为什么是录制而不是回放:
/// 回放需要 SwapEodPositionService 把 DB 调用抽成虚方法(参考
/// refactor-swap-event-testable 分支的 TestableSwapEodPositionService)。
/// 当前 1.4.2 分支尚未做该重构,故先录制 golden 数据。
/// TDD 红灯:录制数据会暴露 RealizedPnl 中分红被计 2 次的事实,
/// 待"方向A:让 MarkClosePnl 不含分红"修复后,同一批 golden 用于回归守底。
///
/// 运行方式:
/// 全部标 [Ignore]+[TestCategory("DBRecording")],不会自动跑(不依赖测试库环境)。
/// 手动执行:在测试资源管理器取消忽略,或用 vstest:
/// vstest.console.exe UnitTestProject.dll /TestCaseFilter:"TestCategory=DBRecording"
/// 录制产物落 bin/$(Configuration)/net6.0/Resources/GoldenFiles/SwapDividend/*.json
/// ============================================================================
[TestClass]
public class SwapDividendGoldenRecordTest
{
private static readonly string GoldenDir = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "SwapDividend");
private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Include,
DateFormatString = "yyyy-MM-ddTHH:mm:ss",
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
};
// 已确认的样本交易(来自测试库 swap_flow_event EventType in(3,4) DividendIn<>0 筛选):
// 1875 = 纯分红型(MarkClosePnl==DividendIn,最干净,重复计算最直观)
// 1891 = 混合型 MarkClosePnl 含价差成分,复杂场景)
private static readonly int[] SampleTradeIds = { 1875, 1891 };
/// <summary>
/// Step0:列出库中所有"带分红的互换交易",确认样本有效性。
/// 打印 SwapTradeId / 分红合计 / 盯市合计 / eod快照数,供挑选样本。
///
/// 可直接运行:连不上测试库时返回 Inconclusive(不计入失败),不挡 CI;
/// 连得上时输出诊断表。这是日常排查"库里有啥分红交易"的入口。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Step0_ListDividendSwapTrades()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex)
{
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
return;
}
try
{
var trades = db.swap_flow_event
.Where(x => (x.EventType == (int)SwapFlowEventTypeEnum.
|| x.EventType == (int)SwapFlowEventTypeEnum.)
&& x.DividendIn != 0m
&& x.DataState == (int)SwapFlowDateStateEnum.)
.AsEnumerable()
.GroupBy(x => x.SwapTradeId)
.Select(g => new
{
SwapTradeId = g.Key,
SwapTradeNo = g.Select(x => x.SwapTradeNo).FirstOrDefault(s => !string.IsNullOrEmpty(s)),
DividendSum = g.Sum(x => x.DividendIn),
MarkCloseSum = g.Sum(x => x.MarkClosePnl),
LastEventDate = g.Max(x => x.EventDate),
EodPositionCount = db.eod_swap_position.Count(e => e.SwapTradeId == g.Key),
EodSwapCount = db.eod_swap.Count(e => e.SwapTradeId == g.Key)
})
.OrderByDescending(t => Math.Abs(t.DividendSum))
.ToList();
Console.WriteLine($"=== 带分红的互换交易数: {trades.Count} ===");
Console.WriteLine($"{"TradeId",8} {"SwapTradeNo",-24} {"",14} {"",14} {"eod持仓",8} {"eod汇总",8}");
foreach (var t in trades)
{
Console.WriteLine($"{t.SwapTradeId,8} {(t.SwapTradeNo ?? ""),-24} {t.DividendSum,14:F4} {t.MarkCloseSum,14:F4} {t.EodPositionCount,8} {t.EodSwapCount,8}");
// 直观诊断:分红型交易若 MarkCloseSum≈DividendSum,说明 MarkClosePnl 全是分红(重复计算铁证)
if (Math.Abs(t.MarkCloseSum - t.DividendSum) < 0.01m && t.DividendSum != 0m)
{
Console.WriteLine($" ↳ ⚠ MarkClosePnl合计≈DividendIn合计 → 盯市列里全是分红,RealizedPnl 会计 2 次");
}
}
Assert.IsTrue(trades.Count > 0, "库中应存在带分红的互换交易");
}
finally
{
db?.Dispose();
}
}
/// <summary>
/// Step1:逐笔录制样本交易的完整快照(5表),并输出分红重复计算诊断。
///
/// 为何保留 [Ignore]:本方法有写文件副作用(落 golden JSON),
/// 不应随每次构建/CI 自动执行;只在需要"刷新 golden 种子"时手动触发。
/// 运行方式(三选一):
/// - VS 测试资源管理器:选中本方法 → 右键 → 运行(VS 默认会跑被 Ignore 的,除非全局过滤)
/// - 命令行:dotnet test --filter "FullyQualifiedName~Step1_RecordSampleTrades"
/// - 临时:删掉本方法上的 [Ignore] 再跑,跑完恢复
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
// [Ignore] 临时取消以运行录制
public void Step1_RecordSampleTrades()
{
using var db = DbContextFactory.GetYLDbContext();
Directory.CreateDirectory(GoldenDir);
int recorded = 0;
foreach (var tradeId in SampleTradeIds)
{
Console.WriteLine($"\n========== 录制 SwapTradeId={tradeId} ==========");
// 1. 交易主信息
var trade = db.trade.FirstOrDefault(t => t.id == tradeId);
if (trade == null)
{
Console.WriteLine($"⚠ trade 表无 SwapTradeId={tradeId},跳过");
continue;
}
// 2. 五表快照
var positions = db.swap_position
.Where(p => p.SwapTradeId == tradeId)
.OrderByDescending(p => p.IsInitial).ThenBy(p => p.PositionId)
.ToList();
var flowEvents = db.swap_flow_event
.Where(e => e.SwapTradeId == tradeId)
.OrderBy(e => e.EventDate).ThenBy(e => e.EventType).ThenBy(e => e.id)
.ToList();
var eodPositions = db.eod_swap_position
.Where(e => e.SwapTradeId == tradeId)
.OrderBy(e => e.PositionId).ThenBy(e => e.ValueDate)
.ToList();
var eodSwaps = db.eod_swap
.Where(e => e.SwapTradeId == tradeId)
.OrderBy(e => e.ValueDate)
.ToList();
// 3. 关联的债券付息(理论应付分红来源)
var bondCode = positions.Select(p => p.UnderlyingCode).FirstOrDefault(c => !string.IsNullOrEmpty(c))
?? trade.UnderlyingCode;
List<BondPayment> bondPayments = new List<BondPayment>();
if (!string.IsNullOrEmpty(bondCode))
{
bondPayments = db.bondPayment
.Where(b => b.underlyingCode == bondCode)
.OrderBy(b => b.payment_date_pl)
.ToList();
}
// 4. 诊断:坐实分红重复计算(这是录制测试的核心价值)
var diagnosis = DiagnoseDividendDoubleCount(tradeId, flowEvents, eodPositions);
Console.WriteLine(diagnosis.Summary);
// 5. 序列化为 golden 文件
var golden = new SwapDividendGoldenModel
{
SwapTradeId = tradeId,
SwapTradeNo = trade.TradeNumber,
UnderlyingCode = bondCode,
RecordedAt = DateTime.Now,
SourceDb = "test",
Purpose = "分红重复计算验证 + EOD重构回归基线",
InputTrade = JObject.FromObject(trade, JsonSerializer.Create(JsonSettings)),
InputPositions = JArray.FromObject(positions, JsonSerializer.Create(JsonSettings)),
InputFlowEvents = JArray.FromObject(flowEvents, JsonSerializer.Create(JsonSettings)),
InputEodPositions = JArray.FromObject(eodPositions, JsonSerializer.Create(JsonSettings)),
InputEodSwaps = JArray.FromObject(eodSwaps, JsonSerializer.Create(JsonSettings)),
InputBondPayments = JArray.FromObject(bondPayments, JsonSerializer.Create(JsonSettings)),
Diagnosis = JObject.FromObject(diagnosis, JsonSerializer.Create(JsonSettings))
};
string fileName = $"dividend_trade_{tradeId}.json";
string filePath = Path.Combine(GoldenDir, fileName);
File.WriteAllText(filePath, JsonConvert.SerializeObject(golden, JsonSettings));
Console.WriteLine($"✅ 已保存: {filePath}");
recorded++;
}
Assert.IsTrue(recorded > 0, "至少应录制 1 笔样本");
Console.WriteLine($"\n录制完成,共 {recorded} 笔,输出目录: {GoldenDir}");
}
/// <summary>
/// 分红重复计算诊断:对照代码行号,把链路数据逐一算出来。
/// 重复计算根因链路(SwapEodPositionService.cs):
/// SetPriceInfoByFlowEvent:1610 TdCloseMtmPnl = Σ MarkClosePnl(互换事件里已含分红)
/// UpdateEodPosition:1486 RealizedMtmPnL += TdCloseMtmPnl ← 分红第1次(盯市列)
/// UpdateEodPosition:1488 TdCloseDividend = Σ DividendIn
/// UpdateEodPosition:1494 RealizedDividend += TdCloseDividend ← 分红第2次(分红列)
/// SaveEodSwap:1869 eod_swap.RealizedPnL = Σ(RealizedMtmPnL + RealizedDividend + ...)
/// → 分红在盯市列和分红列各计一次 = 2 次
/// </summary>
private DiagnoseResult DiagnoseDividendDoubleCount(
int tradeId,
List<swap_flow_event> flowEvents,
List<eod_swap_position> eodPositions)
{
var r = new DiagnoseResult { SwapTradeId = tradeId };
var lines = new List<string>
{
$"--- 分红重复计算诊断 SwapTradeId={tradeId} ---",
"",
"[流水层] 每条平仓/互换事件拆解 (MarkClosePnl = 价差 + 费CloseFee + 分红DividendIn)",
string.Format(" {0,-8}{1,-12}{2,-8}{3,16}{4,12}{5,10}{6,16}",
"id", "EventDate", "EvType", "MarkClosePnl", "DividendIn", "CloseFee", "价差(残差)")
};
// 仅取完成状态的平仓/互换事件(开仓事件 MarkClosePnl=0 不参与)
var closeSwapEvents = flowEvents
.Where(e => (e.EventType == (int)SwapFlowEventTypeEnum.
|| e.EventType == (int)SwapFlowEventTypeEnum.
|| e.EventType == (int)SwapFlowEventTypeEnum.)
&& e.DataState == (int)SwapFlowDateStateEnum.)
.ToList();
decimal totalPriceComponent = 0m; // 全交易价差成分合计(用于类型判定)
foreach (var e in closeSwapEvents)
{
string et = e.EventType switch
{
(int)SwapFlowEventTypeEnum. => "平仓",
(int)SwapFlowEventTypeEnum. => "互换",
(int)SwapFlowEventTypeEnum. => "自动互换",
_ => e.EventType.ToString()
};
// 残差 = MarkClosePnl - 分红 - 费 = 纯价差成分
decimal priceComp = e.MarkClosePnl - e.DividendIn - e.CloseFee;
totalPriceComponent += priceComp;
lines.Add(string.Format(" {0,-8}{1,-12}{2,-8}{3,16:F4}{4,12:F4}{5,10:F4}{6,16:F4}",
e.id, e.EventDate.ToString("yyyy-MM-dd"), et, e.MarkClosePnl, e.DividendIn, e.CloseFee, priceComp));
}
// ===== 按 PositionId 拆解(避免双向腿抵消)=====
lines.Add("");
lines.Add("[EOD层] 按 PositionId 拆解盯市列成分:");
lines.Add(string.Format(" {0,-12}{1,16}{2,16}{3,12}{4,16}{5,16}{6,16}",
"PositionId", "盯市列合计", "价差成分", "费成分", "分红成分(重复)", "分红列累计", "重复计入"));
decimal totalRepeat = 0m;
var evByPos = closeSwapEvents.GroupBy(e => e.PositionId).ToDictionary(g => g.Key, g => g.ToList());
var eodByPos = eodPositions.GroupBy(e => e.PositionId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.ValueDate).First());
foreach (var pid in eodByPos.Keys.OrderBy(k => k))
{
var evs = evByPos.ContainsKey(pid) ? evByPos[pid] : new List<swap_flow_event>();
decimal mtmTotal = evs.Sum(e => e.MarkClosePnl);
decimal feeComp = evs.Sum(e => e.CloseFee);
decimal divComp = evs.Sum(e => e.DividendIn); // 盯市列里的分红成分(被重复计入)
decimal priceComp = mtmTotal - divComp - feeComp;
decimal realizedDiv = eodByPos[pid].RealizedDividend;
totalRepeat += divComp;
r..Add(new PositionDiagnose
{
PositionId = pid,
= mtmTotal,
= priceComp,
= feeComp,
= divComp,
= realizedDiv,
= divComp
});
lines.Add(string.Format(" {0,-12}{1,16:F4}{2,16:F4}{3,12:F4}{4,16:F4}{5,16:F4}{6,16:F4}",
pid, mtmTotal, priceComp, feeComp, divComp, realizedDiv, divComp));
}
// ===== 全交易累计与类型判定 =====
r. = eodByPos.Values.Sum(e => e.RealizedMtmPnL);
r. = eodByPos.Values.Sum(e => e.RealizedDividend);
r. = eodByPos.Values.Sum(e => e.RealizedPnl);
r. = totalRepeat;
r. = Math.Abs(totalRepeat) > 0.01m;
r. = Math.Abs(totalPriceComponent) < 0.01m ? "纯分红型" : "混合型";
// 结论
if (r.)
{
r. = string.Format(
"⚠ 坐实重复计算:盯市列含分红成分 {0:F4}(既在 RealizedMtmPnL 又在 RealizedDividend)," +
"修复后 RealizedPnl 应减少 {0:F4}。类型={1}。",
totalRepeat, r.);
}
else
{
r. = "未检测到重复计算(盯市列分红成分≈0,可能已修复或无分红平仓/互换事件)。";
}
lines.Add("");
lines.Add("[汇总]");
lines.Add($" 交易类型: {r.交易类型}(价差成分合计={totalPriceComponent:F4}");
lines.Add($" 盯市列里被重复计入的分红成分: {r.重复计入分红金额:F4}");
lines.Add($" 最终 RealizedMtmPnL(盯市列): {r.最终累计盯市已实现:F4}");
lines.Add($" 最终 RealizedDividend(分红列): {r.最终累计分红已实现:F4}");
lines.Add($" 最终 RealizedPnl(持仓层): {r.最终持仓层累计已实现:F4}");
lines.Add($" 重复计算成立: {r.重复计算成立}");
lines.Add($" [结论] {r.结论}");
r.Summary = string.Join("\n", lines);
return r;
}
/// <summary>
/// Step2:校验已录制 golden 文件的完整性(离线,不连库)。
/// 确认每个 json 含 5 表数据、能正确反序列化。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Step2_VerifyRecordedGoldenFiles()
{
if (!Directory.Exists(GoldenDir))
{
Assert.Inconclusive($"golden 目录不存在: {GoldenDir}(请先跑 Step1_RecordSampleTrades");
return;
}
var files = Directory.GetFiles(GoldenDir, "dividend_trade_*.json");
Assert.IsTrue(files.Length > 0, $"应至少有 1 个 golden 文件 in {GoldenDir}");
foreach (var file in files)
{
var json = File.ReadAllText(file);
var golden = JsonConvert.DeserializeObject<SwapDividendGoldenModel>(json);
Assert.IsTrue(golden.SwapTradeId > 0, $"{file}: SwapTradeId 无效");
Assert.IsNotNull(golden.InputFlowEvents, $"{file}: InputFlowEvents 缺失");
Assert.IsTrue(golden.InputFlowEvents.Count > 0, $"{file}: InputFlowEvents 为空");
Assert.IsNotNull(golden.InputEodPositions, $"{file}: InputEodPositions 缺失");
Assert.IsNotNull(golden.Diagnosis, $"{file}: Diagnosis 缺失");
Console.WriteLine($"✅ {Path.GetFileName(file)}: trade={golden.SwapTradeId}, " +
$"flow={golden.InputFlowEvents.Count}条, eod={golden.InputEodPositions.Count}条, " +
$"结论={golden.Diagnosis?[""]?.Value<string>()}");
}
}
}
/// <summary>
/// 分红黄金文件数据模型。5 表快照 + 诊断结论。
/// 字段名沿用数据库实体类名,反序列化时类型一致。
/// </summary>
public class SwapDividendGoldenModel
{
public int SwapTradeId { get; set; }
public string SwapTradeNo { get; set; }
public string UnderlyingCode { get; set; }
public DateTime RecordedAt { get; set; }
public string SourceDb { get; set; }
public string Purpose { get; set; }
public JObject InputTrade { get; set; }
public JArray InputPositions { get; set; } // swap_position
public JArray InputFlowEvents { get; set; } // swap_flow_event(分红核心)
public JArray InputEodPositions { get; set; } // eod_swap_position(重复计算发生处)
public JArray InputEodSwaps { get; set; } // eod_swap(汇总层)
public JArray InputBondPayments { get; set; } // bond_payment_info(理论应付)
public JObject Diagnosis { get; set; }
}
/// <summary>
/// 重复计算诊断结果,随 golden 一起持久化,便于修复后对比。
/// 拆解原理:每条平仓/互换事件的 MarkClosePnl = 价差成分 + 费成分(CloseFee) + 分红成分(DividendIn)。
/// 盯市列(TdCloseMtmPnl=ΣMarkClosePnl) 含了分红成分一份,分红列(TdCloseDividend=ΣDividendIn) 又含一份,
/// 故"重复金额" = 进入盯市列的分红成分 = Σ(事件 DividendIn)。按 PositionId 分别拆解避免双向腿抵消。
/// </summary>
public class DiagnoseResult
{
public int SwapTradeId { get; set; }
/// <summary>"纯分红型"(价差成分≈0) 或 "混合型"(价差成分≠0)。基于 MarkClosePnl 是否含价差判定。</summary>
public string { get; set; }
// ===== 逐 PositionId 拆解 =====
public List<PositionDiagnose> { get; set; } = new List<PositionDiagnose>();
/// <summary>盯市列里被重复计入的分红成分合计(=修复后 RealizedPnl 应减少的金额)。</summary>
public decimal { get; set; }
public decimal { get; set; }
public decimal { get; set; }
public decimal { get; set; }
/// <summary>若重复计入分红金额≠0,则重复计算成立。</summary>
public bool { get; set; }
public string { get; set; }
public string Summary { get; set; }
}
/// <summary>
/// 单个持仓腿(PositionId)的拆解结果。
/// </summary>
public class PositionDiagnose
{
public long PositionId { get; set; }
/// <summary>该腿盯市列合计 = Σ 事件 MarkClosePnl。</summary>
public decimal { get; set; }
/// <summary>盯市列里的价差成分 = Σ(MarkClosePnl - DividendIn - CloseFee)。</summary>
public decimal { get; set; }
/// <summary>盯市列里的费成分 = Σ CloseFee。</summary>
public decimal { get; set; }
/// <summary>盯市列里的分红成分 = Σ DividendIn(这是被重复计入的部分)。</summary>
public decimal { get; set; }
/// <summary>该腿分红列最终累计 RealizedDividend。</summary>
public decimal { get; set; }
/// <summary>该腿被重复计入的分红 = 盯市分红成分。</summary>
public decimal { get; set; }
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -49,6 +49,9 @@
<None Update="Data\Calendars\chn.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources\GoldenFiles\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="NLog.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
+3 -3
View File
@@ -1,8 +1,8 @@
{
"ConnectionStrings": {
"ylcms": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=zszq_yltrs_ylcms;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;",
"yladmin": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=zszq_yltrs_admin;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;",
"ylclient": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=zszq_yltrs_client;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;",
"ylcms": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=glms_yltrs_ylcms;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;",
"yladmin": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=glms_yltrs_admin;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;",
"ylclient": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=glms_yltrs_client;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;",
"bondoms": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=zszq_bond_oms;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;"
},
"LibreOffice": {
@@ -0,0 +1,303 @@
-- ============================================================================
-- 互换分红损益:数据导出与重复计算验证 SQL 工具箱
-- ----------------------------------------------------------------------------
-- ⚠ 本脚本所有列名均取自真实库 DDLglms_yltrs_ylcms.sql),非实体类属性名。
-- 实体类属性名与库列名存在差异(如 swap_flow_event.EventDate、
-- bond_payment_info.paying_interest、swap_position.IsInitial 等)。
--
-- 目的:
-- 1) 定位并导出一笔"带分红的互换交易"完整数据,作 golden source 种子;
-- 2) 用真实库数据验证"分红被重复计算 2 次"
-- 3) 生命周期守恒校验。
--
-- 涉及表与关键列(取自 DDL,列名以反引号为准):
-- swap_event 事件主表(窄列)id,SwapTradeId,ValueDate,EventType,
-- EventReason,EventData(json),Invalid,BackId,OptId,OptTime,ClientCashId
-- ※ 无 SwapTradeNo/UnderlyingCode/金额,这些在 EventData 或流水表
-- swap_position 持仓:PositionId,SwapTradeId,UnderlyingCode,PosiQuantity,
-- PosiNotionalValue,PosiNetPrice,PosiGrossPrice,IsInitial(0实时/1期初),
-- PosiDividendIncome,PosiTradingFeePending,Invalid
-- swap_flow_event 流水(分红核心)EventDate(非ValueDate!),SwapTradeId,SwapTradeNo,
-- EventType,PositionId,Quantity,MarkClosePnl,DividendIn,DividendPending,
-- CloseFee,DataState(0废弃/1等待/100完成)
-- eod_swap_position 日终归档:ValueDate,SwapTradeId,PositionId,TdCloseMtmPnl,TdCloseDividend,
-- RealizedMtmPnL,RealizedDividend,RealizedFee,RealizedInterest,
-- RealizedInterestFee,RealizedPnl,DV01(大写),PosiDividendSum,Invalid
-- eod_swap 日终汇总:ValueDate,SwapTradeId,SwapTradeNo,TdRealizedPnL,RealizedPnL,
-- PostionValue
-- bond_payment_info 债券付息:underlying_code,pay_date_PL,paying_interest(非payment_interest!),
-- paying_principal,paying_price
--
-- EventType 枚举(注意 swap_event 与 swap_flow_event 取值不同!):
-- swap_event.EventType (SwapEventTypeEnum): 展期1/平仓2/互换3/自动互换4/回退5/合成持仓6...
-- swap_flow_event.EventType (SwapFlowEventTypeEnum): 开仓1/平仓2/互换3/自动互换4
-- → 分红型互换在两表均为 EventType IN (3,4)
-- ============================================================================
-- ============================================================================
-- 第 0 步:定位一笔"带分红的互换交易"作为样本
-- ============================================================================
-- 思路:互换/自动互换事件(EventType in 3,4) 的 DividendIn != 0,即发生过分红型互换。
-- swap_flow_event 自带 SwapTradeNo(人类可读),无需关联 swap_event。
-- DataState=100 仅取已完成流水。
SELECT SwapTradeId,
MAX(SwapTradeNo) AS SwapTradeNo,
COUNT(*) AS ,
SUM(DividendIn) AS ,
SUM(MarkClosePnl) AS ,
MAX(EventDate) AS
FROM swap_flow_event
WHERE EventType IN (3, 4) -- 互换 / 自动互换
AND DividendIn <> 0 -- 真正发生过分红
AND DataState = 100 -- 仅完成的
GROUP BY SwapTradeId
ORDER BY SUM(DividendIn) DESC, MAX(EventDate) DESC
LIMIT 20;
-- 选定其中一行 SwapTradeId,填入下面 @TargetTradeId。
-- ============================================================================
-- 第 1 步:单笔交易完整数据导出(golden source 种子)
-- ============================================================================
-- 用法:把 @TargetTradeId 改为第 0 步选出的值,逐段执行。
SET @TargetTradeId := 1874; -- ← 替换为实际样本 SwapTradeId
-- 1.1 互换事件主表(窄列;EventData 是 json,含详细快照)
SELECT id, SwapTradeId, ValueDate, EventType, EventReason,
Invalid, BackId, OptId, OptName, OptTime, ClientCashId
FROM swap_event
WHERE SwapTradeId = @TargetTradeId
ORDER BY ValueDate, id;
-- 1.2 互换持仓(区分期初/实时:IsInitial 1=期初, 0=实时)
SELECT PositionId, SwapTradeId, UnderlyingCode, PosiDirection, PositionType,
PosiQuantity, PosiNotionalValue, PosiNetPrice, PosiGrossPrice,
ContractSize, CountRatio, PosiTradingFee, PosiTradingFeePending,
PosiDividendIncome, IsInitial, Invalid
FROM swap_position
WHERE SwapTradeId = @TargetTradeId
ORDER BY IsInitial DESC, PositionId;
-- 说明:IsInitial=1 是期初开仓腿;=0 是实时持仓(会随平仓/互换变动)。
-- 1.3 流水事件(分红核心表,导出全字段便于复盘)
SELECT id, EventDate, SwapTradeId, SwapTradeNo, EventType, EventReason,
PositionId, PayDirection, PositionType, UnderlyingCode, Quantity,
TradingAmount, TradingAmountAvg, TradingAmountFeeAvg,
TradingFee, TradingFeePending, DividendPending,
MarkClosePnl, DividendIn, CloseFee, DataState, ClientCashId
FROM swap_flow_event
WHERE SwapTradeId = @TargetTradeId
ORDER BY EventDate, EventType, id;
-- 1.4 日终持仓归档(按日快照,含所有 TdClose* / Realized* 字段)
SELECT id, ValueDate, SwapTradeId, PositionId, PosiDirection, PositionType,
UnderlyingCode, PosiQuantity, PosiNotionalValue,
PosiNetPrice, PosiGrossPrice, UnderlyingPrice, UnderlyingMarketValue,
TdPosiDividend, PosiMtmPnL, PosiDividendSum, PosiFeePending, PosiProfitSum,
TdCloseQty, TdCloseMtmPnl, TdCloseDividend, TdCloseFee,
RealizedMtmPnL, RealizedDividend, RealizedFee, RealizedInterest, RealizedInterestFee,
RealizedPnl, DV01, PosiStatus, Invalid
FROM eod_swap_position
WHERE SwapTradeId = @TargetTradeId
ORDER BY PositionId, ValueDate;
-- 1.5 日终互换层汇总
SELECT id, ValueDate, SwapTradeId, SwapTradeNo, TdCloseQty,
TdRealizedPnL, RealizedPnL, PostionValue
FROM eod_swap
WHERE SwapTradeId = @TargetTradeId
ORDER BY ValueDate;
-- 1.6 债券付息明细(理论应付分红来源)
-- 先从 swap_position 取该交易挂钩的标的代码:
SELECT DISTINCT UnderlyingCode
FROM swap_position
WHERE SwapTradeId = @TargetTradeId
AND UnderlyingCode IS NOT NULL;
-- 再用取到的 UnderlyingCode 查付息明细(替换 @BondCode):
SET @BondCode := 'PUT_UNDERLYING_CODE_HERE';
SELECT id, underlying_code, inner_code,
pay_date_PL, pay_date_act, paying_interest, paying_principal, paying_price,
interest_tax_rate, event_type, info_source, insert_time
FROM bond_payment_info
WHERE underlying_code = @BondCode
ORDER BY pay_date_PL;
-- ============================================================================
-- 第 2 步:分红重复计算验证(证明"分红被算 2 次"
-- ============================================================================
-- 根因链路(SwapEodPositionService.cs):
-- SetPriceInfoByFlowEvent:1610 TdCloseMtmPnl = Σ unwindEvents.MarkClosePnl
-- (互换/平仓事件的 MarkClosePnl 已含分红)
-- UpdateEodPosition:1486 RealizedMtmPnL += TdCloseMtmPnl ← 分红第1次进"盯市"列
-- UpdateEodPosition:1488/1607 TdCloseDividend = Σ DividendIn
-- UpdateEodPosition:1494 RealizedDividend += TdCloseDividend ← 分红第2次进"分红"列
-- SaveEodSwap:1869 eod_swap.RealizedPnL = Σ(RealizedMtmPnL + RealizedDividend + ...)
-- → 分红在盯市列和分红列各计一次 = 2 次
--
-- 验证思路:若 MarkClosePnl 含分红,则同一事件日同一持仓满足:
-- 该日"盯市列中扣除纯平仓价差后的余额" ≈ "分红列",且两者都进了 RealizedPnL → 重复。
SET @TargetTradeId := 1874; -- ← 替换为实际样本
-- 2.1 逐日核对:盯市列 vs 分红列
-- 关键比对:盯市列里扣除"纯平仓事件(EventType=2)的价差"后,剩余是否≈分红列。
-- 若是,说明互换/自动互换事件(EventType in 3,4)的 MarkClosePnl 含分红。
SELECT esp.ValueDate,
esp.PositionId,
esp.TdCloseMtmPnl AS ,
esp.TdCloseDividend AS ,
-- 当日纯平仓事件(EventType=2)的盯市价差合计(理论上=纯价差,不含分红)
(SELECT COALESCE(SUM(sfe2.MarkClosePnl), 0)
FROM swap_flow_event sfe2
WHERE sfe2.SwapTradeId = esp.SwapTradeId
AND sfe2.PositionId = esp.PositionId
AND sfe2.EventType = 2
AND sfe2.DataState = 100
AND sfe2.EventDate = esp.ValueDate) AS ,
-- 盯市列 - 纯平仓价差 = 互换/自动互换事件贡献的盯市成分(若≈分红列→含分红)
(esp.TdCloseMtmPnl - (
SELECT COALESCE(SUM(sfe2.MarkClosePnl), 0)
FROM swap_flow_event sfe2
WHERE sfe2.SwapTradeId = esp.SwapTradeId
AND sfe2.PositionId = esp.PositionId
AND sfe2.EventType = 2
AND sfe2.DataState = 100
AND sfe2.EventDate = esp.ValueDate
)) AS ,
esp.TdCloseDividend AS ,
esp.RealizedMtmPnL AS ,
esp.RealizedDividend AS ,
esp.RealizedPnl AS
FROM eod_swap_position esp
WHERE esp.SwapTradeId = @TargetTradeId
AND (esp.TdCloseDividend <> 0 OR esp.TdCloseMtmPnl <> 0)
ORDER BY esp.PositionId, esp.ValueDate;
-- 2.2 全生命周期汇总:盯市列累计 + 分红列累计 vs RealizedPnL
-- 若 MarkClosePnl 含分红:盯市累计里多算了一份分红,导致
-- RealizedMtmPnL + RealizedDividend > 真实盯市价差 + 分红 (多出 ≈ 分红金额)
SELECT esp.PositionId,
MAX(esp.RealizedMtmPnL) AS ,
MAX(esp.RealizedDividend) AS ,
MAX(esp.RealizedPnl) AS ,
-- 互换层汇总公式(SaveEodSwap:1869)的口径:
(MAX(esp.RealizedMtmPnL) + MAX(esp.RealizedDividend)
+ COALESCE(MAX(esp.RealizedFee),0)
+ COALESCE(MAX(esp.RealizedInterest),0)
+ COALESCE(MAX(esp.RealizedInterestFee),0))
AS ,
-- 理论上不含费的纯盯市价差(用纯平仓 EventType=2 的 MarkClosePnl 估算):
(SELECT COALESCE(SUM(sfe.MarkClosePnl), 0)
FROM swap_flow_event sfe
WHERE sfe.SwapTradeId = esp.SwapTradeId
AND sfe.PositionId = esp.PositionId
AND sfe.EventType = 2
AND sfe.DataState = 100) AS
FROM eod_swap_position esp
WHERE esp.SwapTradeId = @TargetTradeId
GROUP BY esp.PositionId;
-- 2.3 一句话诊断:互换/自动互换分红事件的 MarkClosePnl 是否含分红
-- 对每个 EventType in (3,4) 且 DividendIn<>0 的事件日,
-- 检查当日 eod 盯市列是否也包含了等额成分。
SELECT sfe.EventDate,
sfe.PositionId,
sfe.EventType,
sfe.DividendIn AS ,
sfe.MarkClosePnl AS ,
esp.TdCloseMtmPnl AS ,
esp.TdCloseDividend AS ,
CASE
-- 同一互换事件自身:若 MarkClosePnl≈DividendIn,则该事件盯市就含分红
WHEN ABS(sfe.MarkClosePnl - sfe.DividendIn) < 0.01 AND sfe.DividendIn <> 0
THEN '⚠该事件MarkClosePnl≈DividendIn→盯市含分红(根因)'
-- 当日整列:盯市列≈分红列
WHEN esp.TdCloseMtmPnl <> 0
AND ABS(esp.TdCloseMtmPnl - esp.TdCloseDividend) < 0.01
THEN '⚠当日盯市列≈分红列→重复'
ELSE '需人工核对'
END AS
FROM swap_flow_event sfe
JOIN eod_swap_position esp
ON esp.SwapTradeId = sfe.SwapTradeId
AND esp.PositionId = sfe.PositionId
AND esp.ValueDate = sfe.EventDate
WHERE sfe.SwapTradeId = @TargetTradeId
AND sfe.EventType IN (3, 4)
AND sfe.DividendIn <> 0
AND sfe.DataState = 100
ORDER BY sfe.EventDate;
-- ============================================================================
-- 第 3 步:生命周期守恒校验(累计已实现分红 vs 理论应付分红)
-- ============================================================================
-- 含义:一笔互换交易从开仓到全部平仓,"已实现分红收益总额"应等于持仓期间
-- 该债券应付分红(税后)的累加。任何偏差说明核算有误。
-- 若存在第 2 步的重复计算,累计已实现分红会被放大,本步量化偏差作修复后回归基线。
SET @TargetTradeId := 1874;
-- 3.1 实际已实现分红(从事件流水 DividendIn 累加)
SELECT
SUM(CASE WHEN EventType IN (2,3,4) THEN DividendIn ELSE 0 END) AS ,
SUM(CASE WHEN EventType IN (3,4) THEN DividendIn ELSE 0 END) AS ,
SUM(CASE WHEN EventType = 2 THEN DividendIn ELSE 0 END) AS
FROM swap_flow_event
WHERE SwapTradeId = @TargetTradeId
AND DataState = 100;
-- 3.2 日终表口径的最终累计已实现分红(应与 3.1 一致)
SELECT PositionId,
MAX(RealizedDividend) AS ,
MAX(RealizedPnl) AS
FROM eod_swap_position
WHERE SwapTradeId = @TargetTradeId
GROUP BY PositionId;
-- 3.3 理论应付分红(税后)—— 需人工带入持仓区间与标的
-- 业务口径(BondPaymentService.CalcPayment):
-- totalPayment = CalcPayment(UnderlyingCode, StartDate, EndDate, Qty, shortRatio, dirRatio)
-- 理论税后分红 = totalPayment / (1 + tax) * (1 - tax)
-- 这里给出从 bond_payment_info 直接估算的简化版(仅供量级对照):
SET @BondCode := 'PUT_UNDERLYING_CODE_HERE';
SET @StartDate := '2024-01-01';
SET @EndDate := '2024-12-31';
SELECT underlying_code,
SUM(COALESCE(paying_interest, 0)) * 0.01 AS _相对值,
COUNT(*) AS
FROM bond_payment_info
WHERE underlying_code = @BondCode
AND pay_date_PL BETWEEN @StartDate AND @EndDate
GROUP BY underlying_code;
-- 说明:paying_interest 为"每张兑付利息额",×0.01 转相对价后还需 ×持仓数量 ×方向,
-- 再做税后调整,才能与 3.1/3.2 对齐。精确口径见 BondPaymentService.CalcPayment。
-- ============================================================================
-- 附录:导出为 json / csv 的方式
-- ============================================================================
-- 【方式A:MySQL 客户端导出(推荐,最简单)】
-- 在 Navicat / DBeaver / MySQL Workbench 中执行上述任一 SELECT,结果区右键
-- "导出" → 选 JSON / CSV / Excel。推荐把 1.1~1.6 各导一份,按表名命名:
-- swap_event.json / swap_position.json / swap_flow_event.json /
-- eod_swap_position.json / eod_swap.json / bond_payment_info.json
--
-- 【方式B:命令行 mysqldump(整表+DDL,含 CREATE)】
-- mysqldump -h<host> -u<user> -p<db> swap_flow_event \
-- --where="SwapTradeId=1874 AND DataState=100" \
-- --skip-add-drop-table --no-create-info > sfe_1874.sql
--
-- 【方式CSELECT ... INTO OUTFILE(服务端导 csv,需 FILE 权限)】
-- SELECT ... FROM swap_flow_event WHERE SwapTradeId=1874
-- INTO OUTFILE '/tmp/sfe_1874.csv'
-- FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\r\n';
--
-- golden source 种子建议:用方式A导出 1.1~1.6 共 6 个 json
-- 连同 trade 主记录,作为"一笔带分红互换交易"的完整快照纳入版本库。