test(accrual): 新增同日双次部分平仓字符化测试 + EOD基数快照replay诊断
- SwapUnwindSameDayDoublePartialTest: 内存Stub驱动同日两次部分平仓(30%/50%), 验证基数线性累加(0.3N+0.35N=0.65N),推翻"基数不滚动"假设;缺口仅在 调用方是否传陈旧notional,已文档化 - GLMS20260805ClosePercentDiffDiagnoseTest: 从Resources快照反序列化离线重跑 EOD基数诊断,去DB化;无快照Inconclusive,保留Assert.Fail作8/5 bug护栏
This commit is contained in:
@@ -96,6 +96,97 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
#endregion
|
||||
|
||||
#region 1.5) 离线回放:从已录快照重跑 EOD 基数诊断(不连库)
|
||||
|
||||
/// <summary>
|
||||
/// 去 DB 化回放:从 <see cref="Record_RealSnapshot"/> 落盘的 snapshot_*.json 反序列化
|
||||
/// eod_swap_position / swap_position,离线重跑「EOD 预付金基数是否=初始本金」诊断。
|
||||
///
|
||||
/// 目的:原 Diagnose_100vs40_InterestDiff 直接连 96 库跑 GetUnwindInterests,
|
||||
/// 依赖数据库可用性、且每次重跑都重新查库。本方法把「一次录制、内存多次回放」
|
||||
/// 落地——录制一次(连库)后,后续诊断完全在内存完成,确定性、可重复、不依赖库。
|
||||
///
|
||||
/// 语义保持为 bug 护栏:若快照录制时 EOD 基数用了初始本金而非实时剩余,本测试
|
||||
/// 仍会 Assert.Fail(不掩盖生产 bug)。录制一份「修复后」的快照即可转绿。
|
||||
/// 无快照时 Inconclusive(须先连库跑一次 Record_RealSnapshot)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[TestCategory("DbDiagnose")]
|
||||
public void Replay_100vs40_FromSnapshot()
|
||||
{
|
||||
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260805");
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
Assert.Inconclusive($"未找到快照目录 {dir},请先连库跑一次 Record_RealSnapshot 录制真实数据快照");
|
||||
return;
|
||||
}
|
||||
var files = Directory.GetFiles(dir, "snapshot_*.json").OrderByDescending(f => f).ToArray();
|
||||
if (files.Length == 0)
|
||||
{
|
||||
Assert.Inconclusive($"目录 {dir} 下无 snapshot_*.json,请先连库跑一次 Record_RealSnapshot");
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshotPath = files[0];
|
||||
Console.WriteLine($"✅ 载入快照(离线回放): {snapshotPath}");
|
||||
|
||||
var snapshot = JObject.Parse(File.ReadAllText(snapshotPath));
|
||||
var tradeNumber = snapshot.Value<string>("TradeNumber");
|
||||
Console.WriteLine($"===== 离线回放 交易 {tradeNumber} =====");
|
||||
|
||||
var eodPrepay = JsonConvert.DeserializeObject<List<eod_swap_position>>(snapshot["EodPositions"].ToString())
|
||||
.Where(e => !e.Invalid
|
||||
&& (e.InterestMode == (int)InterestModeEnum.初始预付金
|
||||
|| e.InterestMode == (int)InterestModeEnum.追加预付金))
|
||||
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId).ToList();
|
||||
|
||||
var origPrepay = JsonConvert.DeserializeObject<List<swap_position>>(snapshot["Positions"].ToString())
|
||||
.Where(p => !p.Invalid && p.IsInitial
|
||||
&& (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList();
|
||||
var realPrepay = JsonConvert.DeserializeObject<List<swap_position>>(snapshot["Positions"].ToString())
|
||||
.Where(p => !p.Invalid && !p.IsInitial
|
||||
&& (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList();
|
||||
|
||||
bool bugDetected = false;
|
||||
if (eodPrepay.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (快照无预付金腿 EOD 记录 → 无可诊断的基数 bug)");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($" {"ValueDate",-12}{"PosId",-8}{"Mode",-6}{"TdInterestPrincipal",-20}{"InterestProfitSum",-20}");
|
||||
foreach (var e in eodPrepay)
|
||||
{
|
||||
Console.WriteLine($" {e.ValueDate:yyyy-MM-dd} {e.PositionId,-8}{e.InterestMode,-6}{e.TdInterestPrincipal,-20}{e.InterestProfitSum,-20}");
|
||||
}
|
||||
|
||||
Console.WriteLine("\n ---- 预付金本金基数三方对比(离线)----");
|
||||
foreach (var orig in origPrepay)
|
||||
{
|
||||
var real = realPrepay.FirstOrDefault(r => r.PositionId == orig.id);
|
||||
var latestEod = eodPrepay.Where(e => e.PositionId == orig.id).OrderByDescending(e => e.ValueDate).FirstOrDefault();
|
||||
var realFix = real?.InterestPrincipalFix ?? 0;
|
||||
var eodTd = latestEod?.TdInterestPrincipal ?? 0;
|
||||
Console.WriteLine($" PosId={orig.id} origFix(初始)={orig.InterestPrincipalFix} realFix(剩余)={realFix} EOD.TdInterestPrincipal(最新)={eodTd}");
|
||||
|
||||
bool eodMatchesOrig = Math.Abs((double)(eodTd - orig.InterestPrincipalFix)) < 0.01;
|
||||
bool eodMatchesReal = Math.Abs((double)(eodTd - realFix)) < 0.01;
|
||||
if (eodMatchesOrig && !eodMatchesReal && orig.InterestPrincipalFix != realFix)
|
||||
{
|
||||
bugDetected = true;
|
||||
Console.WriteLine($" ⚠⚠ EOD 基数=初始本金(≠剩余)→ 坐实:日终用了初始预付金本金而非实时剩余,后续利息计算基数错误!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bug 护栏:快照若录制到基数 bug,离线回放仍须红,不掩盖生产事故。
|
||||
// 修复生产并重新录制快照后,此断言自然转绿。
|
||||
Assert.IsFalse(bugDetected,
|
||||
"离线回放复现 8/5 基数 bug:EOD 预付金基数用了初始本金而非实时剩余。需先修复生产、再录制新快照让本测试转绿。");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 2) 诊断:100% vs 40% 利息差异根因定位(连库跑)
|
||||
|
||||
[TestMethod]
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 【同日多次部分平仓 · unwind 基数滚动表征测试】
|
||||
/// ============================================================================
|
||||
/// 背景:unwind 计息基数公式 basis = priorNotional + notional - baseNotional
|
||||
/// (FundingLegAccrual / CalcDailyCompoundInterestByEod 同源),其中
|
||||
/// - priorNotional = 上一日终归档 eod_swap_position.TdInterestPrincipal
|
||||
/// - baseNotional = orginPv = ResolveUnwindPreviousNotional(lastEod)(上一日终浮动端名义本金)
|
||||
/// - notional = 当前持仓名义本金(posiNotionalValue,来自实时持仓)
|
||||
/// 既有测试(AS_* / SwapUnwindPrepay*Tdd)全是「单事件」场景,没有覆盖
|
||||
/// 「同一天第 2 次部分平仓」:第 1 次平仓后持仓已缩减,第 2 次平仓传入的
|
||||
/// notional 应是缩减后的实时值。本文件用内存对象驱动真实 GetInterests 两次,
|
||||
/// 定性验证「同日多次部分平仓」的应返还本金/计息基数是否按线性拆分。
|
||||
///
|
||||
/// 建模:标的期初全价腿(mode=9),初始名义本金 N=1,000,000;上一日终归档
|
||||
/// eod.TdInterestPrincipal=N、PosiNotionalValue=N(lastEod)。
|
||||
/// 第1次平仓 30%(closePercent=0.3,传入 notional=N)
|
||||
/// 第2次平仓剩余 50%(closePercent=0.5,传入 notional=0.7N=实时缩减后)
|
||||
/// 预期(领域线性):IP1=0.3N、IP2=0.5×0.7N=0.35N,合计 0.65N。
|
||||
/// 若公式在 notional 正确传入时仍非线性 → 暴露 unwind 基数滚动缺陷。
|
||||
/// 注:本测试同时是「前置条件护栏」——它证明"只要调用方传入实时缩减后的
|
||||
/// notional,公式即线性正确";若生产在第2次平仓时传入的是未缩减的陈旧 notional,
|
||||
/// 则结果会偏离,需另查调用方(GetUnwindInterests 的 notional 来源)。
|
||||
/// ============================================================================
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SwapUnwindSameDayDoublePartialTest
|
||||
{
|
||||
private sealed class StubSwapDealService : SwapDealService
|
||||
{
|
||||
public StubSwapDealService(OptUserInfo optUser) : base(optUser) { }
|
||||
|
||||
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
|
||||
{
|
||||
rate = 0;
|
||||
return false; // 标的期初全价腿无浮动标的,不查库
|
||||
}
|
||||
}
|
||||
|
||||
private const decimal N = 1_000_000m; // 初始名义本金(标的期初全价维度)
|
||||
private const int AnnualDays = 365;
|
||||
private static readonly DateTime StartDate = new(2026, 8, 1);
|
||||
private static readonly DateTime LastEodDate = new(2026, 8, 4);
|
||||
private static readonly DateTime UnwindDate = new(2026, 8, 5);
|
||||
|
||||
private SwapDealService _svc;
|
||||
|
||||
[TestInitialize]
|
||||
public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindSameDayDoublePartialTest), OptUserFrom.UnitTest));
|
||||
|
||||
private static trade MakeTrade()
|
||||
{
|
||||
var extend = new trade_extend
|
||||
{
|
||||
TradeId = 1,
|
||||
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||||
{
|
||||
AnnualDays = AnnualDays,
|
||||
InterestCalcMode = "10", // 算头不算尾
|
||||
SettlementRules = 0
|
||||
})
|
||||
};
|
||||
return new trade
|
||||
{
|
||||
id = 1, TradeNumber = "UT-SAMEDAY-2UNWIND", ClientId = 999997,
|
||||
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
|
||||
ExerciseDate = new DateTime(2027, 8, 1), TradeStatus = "确认成交", ValidState = "Valid",
|
||||
StockEqvNotional = (double)N, Notional = (double)N,
|
||||
trade_extend = extend
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>标的期初全价腿(mode=9),单利、重置周期1天(无重置日分支,隔离基数滚动行为)。</summary>
|
||||
private static swap_position MakePosition(decimal posiNotionalValue)
|
||||
{
|
||||
return new swap_position
|
||||
{
|
||||
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.标的期初全价,
|
||||
InterestRateDefault = 0.01m, InterestPrincipalFix = 0m,
|
||||
PosiStartDate = StartDate, PosiMatuirityDate = new DateTime(2027, 8, 1),
|
||||
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||||
IsAnnualized = true, interest_rest_days = 1,
|
||||
interest_rule = 0, FloatRateUnderlyingCode = null,
|
||||
InterestSwapInterval = "[]",
|
||||
PosiNotionalValue = posiNotionalValue
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>上一日终归档:basis 锚点。TdInterestPrincipal=N、PosiNotionalValue=N(lastEod 尚未缩减)。</summary>
|
||||
private static List<eod_swap_position> MakeLastEod()
|
||||
{
|
||||
return new List<eod_swap_position>
|
||||
{
|
||||
new eod_swap_position
|
||||
{
|
||||
id = 1, SwapTradeId = 1, PositionId = 1001,
|
||||
ValueDate = LastEodDate,
|
||||
TdInterestPrincipal = N,
|
||||
PosiNotionalValue = N,
|
||||
InterestProfitSum = 0m, FloatRate = 0m
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 驱动一次盘中平仓(与前端平仓页相同路径,仅用内存对象、不查库)。
|
||||
/// <paramref name="currentNotional"/> = 本次平仓时实时持仓名义本金;
|
||||
/// <paramref name="closePercent"/> = 占剩余比例(前端 ToRemainingClosePercent 转换后的值)。
|
||||
/// orginPv 取 lastEod 名义本金 N(与 GetUnwindInterests 真实传参 ResolveUnwindPreviousNotional(lastEod) 一致)。
|
||||
/// </summary>
|
||||
private swap_flow_event CalcUnwind(decimal currentNotional, decimal closePercent)
|
||||
{
|
||||
var td = MakeTrade();
|
||||
var position = MakePosition(currentNotional);
|
||||
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
MakeLastEod(), new List<swap_position> { position },
|
||||
currentNotional, currentNotional, currentNotional, currentNotional * closePercent, closePercent,
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
false, false, 0, N, false, settment: false, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count, "标的期初全价腿应生成 1 条 flow_event");
|
||||
return interests[0];
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 同日两次部分平仓_应返还本金应线性拆分且合计等于65pct()
|
||||
{
|
||||
// 第1次:平仓 30%(持仓仍满 N)
|
||||
var fe1 = CalcUnwind(N, 0.3m);
|
||||
// 第2次:同日再平剩余 50%(持仓已缩减为 0.7N,传入实时 notional)
|
||||
var fe2 = CalcUnwind(0.7m * N, 0.5m);
|
||||
|
||||
Console.WriteLine($"[表征] 第1次(30%) InterestPrincipal={fe1.InterestPrincipal} InterestAmount={fe1.InterestAmount}");
|
||||
Console.WriteLine($"[表征] 第2次(剩余50%) InterestPrincipal={fe2.InterestPrincipal} InterestAmount={fe2.InterestAmount}");
|
||||
Console.WriteLine($"[表征] 合计 InterestPrincipal={fe1.InterestPrincipal + fe2.InterestPrincipal} (期望=0.65N={(0.65m * N)})");
|
||||
|
||||
// 领域预期(线性):第1次返 0.3N,第2次返 0.5×0.7N=0.35N,合计 0.65N
|
||||
Assert.AreEqual(0.3m * N, fe1.InterestPrincipal,
|
||||
"第1次平仓30%: 应返还本金应=0.3N(线性)");
|
||||
Assert.AreEqual(0.35m * N, fe2.InterestPrincipal,
|
||||
"第2次平仓剩余50%: 应返还本金应=0.5×0.7N=0.35N(基于实时缩减后的 notional,线性)");
|
||||
Assert.AreEqual(0.65m * N, fe1.InterestPrincipal + fe2.InterestPrincipal,
|
||||
"同日两次部分平仓合计应返还本金应=0.65N(线性拆分,无重复/遗漏)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user