原守卫 try 挂在 GetYLDbContext() 上永远打不中(EF context 构造不连库, Connect Timeout 发生在首个查询),无库裸跑 14 个用例逐个假红且各耗 15s。 改为 DbDiagnoseGuard.RequireTestDb():CanConnect 真探测一次并缓存, 不可达全批 Inconclusive——裸跑不再有假失败噪音,探测成本 14×15s→1×15s。 连上库的机器行为不变(照常真跑)。
387 lines
22 KiB
C#
387 lines
22 KiB
C#
using Newtonsoft.Json;
|
||
using Newtonsoft.Json.Linq;
|
||
using YLErp.DBModels.Enums;
|
||
|
||
namespace YLErp.Modules.SwapModule
|
||
{
|
||
/// <summary>
|
||
/// 线上事故诊断:GLMS-JIATT-20260805-FICC-01-2180120IB 100%平仓 vs 40%平仓 利息差异异常
|
||
/// ============================================================================
|
||
/// 现象:同一笔交易,100% 平仓与 40% 平仓算出的利息差异远大于线性比例。
|
||
/// 怀疑点:8/5 创建的交易,撞上 8/6-8/7 对 SwapDealService 平仓利息计算的密集修复窗口,
|
||
/// 尤其 3a435ad8(8/7 13:46) 把 ResolveInterestLegPositionsAsOf 分桶从 UnwindDate 改回
|
||
/// EventDate、并删除 01d7f0c5 的 priorClosePositionIds 防护,可能引入回归。
|
||
///
|
||
/// 直连 96 测试库,对这笔交易:
|
||
/// 1) 录真实数据快照(trade/position/eod/flow_event)
|
||
/// 2) 分别调 GetUnwindInterests(closePercent=1.0) 和 (=0.4),逐腿打印本金/利息
|
||
/// 3) 对比两者是否成线性比例;定位差异落在哪条腿、哪个字段
|
||
/// 4) 检查 EOD 快照的预付金 TdInterestPrincipal 是否用了初始本金(坐实 8/5 基数 bug)
|
||
///
|
||
/// 用法:本地连 96 库跑 Diagnose_100vs40_InterestDiff;连不上库自动 Inconclusive 跳过。
|
||
/// </summary>
|
||
[TestClass]
|
||
public class GLMS20260805ClosePercentDiffDiagnoseTest
|
||
{
|
||
private const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
|
||
|
||
#region 1) 录真实数据快照(手动跑,标 Ignore)
|
||
|
||
[TestMethod]
|
||
[Ignore]
|
||
[TestCategory("DbDiagnose")]
|
||
public void Record_RealSnapshot()
|
||
{
|
||
DbDiagnoseGuard.RequireTestDb();
|
||
YLContext db;
|
||
try { db = DbContextFactory.GetYLDbContext(); }
|
||
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
|
||
|
||
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
|
||
Assert.IsNotNull(td, $"测试库无交易 {TradeNumber},请确认 96 库是否有该数据");
|
||
|
||
var snapshot = new JObject
|
||
{
|
||
["TradeNumber"] = td.TradeNumber,
|
||
["TradeId"] = td.id,
|
||
["TradeDate"] = td.TradeDate,
|
||
["StartDate"] = td.StartDate,
|
||
["StockEqvNotional"] = td.StockEqvNotional,
|
||
["OriginalStockEqvNotional"] = td.OriginalStockEqvNotional,
|
||
["Notional"] = td.Notional,
|
||
["OriginalNotional"] = td.OriginalNotional,
|
||
["TradeStatus"] = td.TradeStatus,
|
||
["HasPartialUnWind"] = td.HasPartialUnWind
|
||
};
|
||
|
||
// 持仓(含 IsInitial=初始 + !IsInitial=已平后剩余)
|
||
var positions = db.swap_position
|
||
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
|
||
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
|
||
.ToList();
|
||
snapshot["Positions"] = JArray.FromObject(positions, JsonSerializer.Create(new JsonSerializerSettings
|
||
{
|
||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||
DateFormatHandling = DateFormatHandling.IsoDateFormat
|
||
}));
|
||
|
||
// EOD 预付金腿逐日(关键:看 TdInterestPrincipal 是否=初始本金)
|
||
var eodPositions = db.eod_swap_position
|
||
.Where(e => e.SwapTradeId == td.id && !e.Invalid)
|
||
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
|
||
.ToList();
|
||
snapshot["EodPositions"] = JArray.FromObject(eodPositions, JsonSerializer.Create(new JsonSerializerSettings
|
||
{
|
||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||
DateFormatHandling = DateFormatHandling.IsoDateFormat
|
||
}));
|
||
|
||
// 所有 flow_event(看平仓事件序列、EventDate vs UnwindDate)
|
||
var flows = db.swap_flow_event
|
||
.Where(f => f.SwapTradeId == td.id)
|
||
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
|
||
.ToList();
|
||
snapshot["FlowEvents"] = JArray.FromObject(flows, JsonSerializer.Create(new JsonSerializerSettings
|
||
{
|
||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||
DateFormatHandling = DateFormatHandling.IsoDateFormat
|
||
}));
|
||
|
||
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260805");
|
||
Directory.CreateDirectory(dir);
|
||
var path = Path.Combine(dir, $"snapshot_{DateTime.Now:yyyyMMdd_HHmmss}.json");
|
||
File.WriteAllText(path, JsonConvert.SerializeObject(snapshot, Formatting.Indented,
|
||
new JsonSerializerSettings { DateFormatHandling = DateFormatHandling.IsoDateFormat }));
|
||
Console.WriteLine($"✅ 快照已保存: {path}");
|
||
}
|
||
|
||
#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]
|
||
[TestCategory("DbDiagnose")]
|
||
public void Diagnose_100vs40_InterestDiff()
|
||
{
|
||
DbDiagnoseGuard.RequireTestDb();
|
||
YLContext db;
|
||
try { db = DbContextFactory.GetYLDbContext(); }
|
||
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
|
||
|
||
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
|
||
if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
|
||
|
||
Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) =====");
|
||
Console.WriteLine($" TradeDate={td.TradeDate:yyyy-MM-dd} StartDate={td.StartDate:yyyy-MM-dd}");
|
||
Console.WriteLine($" StockEqvNotional(剩余)={td.StockEqvNotional} Original(期初)={td.OriginalStockEqvNotional}");
|
||
string remainRatio = td.OriginalStockEqvNotional == 0 ? "N/A" : (td.StockEqvNotional / td.OriginalStockEqvNotional.Value).ToString("P2");
|
||
Console.WriteLine($" 剩余比例={remainRatio}");
|
||
Console.WriteLine($" HasPartialUnWind={td.HasPartialUnWind} TradeStatus={td.TradeStatus}");
|
||
Console.WriteLine();
|
||
|
||
// ---- A. 持仓全景 ----
|
||
var allPositions = db.swap_position
|
||
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
|
||
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
|
||
.ToList();
|
||
PrintPositions("持仓全景(orig=IsInitial初始 vs real=!IsInitial剩余)", allPositions);
|
||
|
||
// ---- B. 历史平仓事件(确认是否之前平过仓、EventDate vs UnwindDate 是否一致)----
|
||
PrintCloseFlowEvents(db, td.id);
|
||
|
||
// ---- C. EOD 预付金腿逐日(看基数是否=初始本金 → 坐实 8/5 基数 bug)----
|
||
PrintEodPrepaySequence(db, td.id);
|
||
|
||
// ---- D. 核心对比:分别调 100% 和 40% ----
|
||
Console.WriteLine("\n\n############ 核心:100% vs 40% GetUnwindInterests 对比 ############");
|
||
var user = new OptUserInfo(0, nameof(GLMS20260805ClosePercentDiffDiagnoseTest), OptUserFrom.UnitTest);
|
||
|
||
// 前端传"占期初(A)"语义,后端转"占剩余(B)"。这里模拟前端两种选择。
|
||
decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初
|
||
decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余
|
||
|
||
Console.WriteLine($"\n 前端参数:期初={frontNotionalValue} 剩余={frontPosiNotionalValue}");
|
||
|
||
// 选 100%(占期初 A=1.0)
|
||
decimal cp100_A = 1.0m;
|
||
decimal cp100_B = SwapDealService.ToRemainingClosePercent(cp100_A, frontNotionalValue, frontPosiNotionalValue);
|
||
Console.WriteLine($" [100%] 前端A={cp100_A} → 后端B={cp100_B}(占剩余)");
|
||
|
||
// 选 40%(占期初 A=0.4)
|
||
decimal cp40_A = 0.4m;
|
||
decimal cp40_B = SwapDealService.ToRemainingClosePercent(cp40_A, frontNotionalValue, frontPosiNotionalValue);
|
||
Console.WriteLine($" [40%] 前端A={cp40_A} → 后端B={cp40_B}(占剩余)");
|
||
Console.WriteLine($" 注:若期初≠剩余,A=1.0→B 被 cap 到 1,A=0.4→B 是另一值,二者本就非线性。\n");
|
||
|
||
// 计算日期用今天(实际前端选哪天可改)
|
||
var valueDate = DateTime.Today;
|
||
var unwindDate = DateTime.Today;
|
||
|
||
// FR007 fixing 是外部数据依赖:非交易日/数据未发布时取价会抛 Exception。
|
||
// 与"连不上库自动 Inconclusive"同语义——外部数据不可用不应判为测试失败。
|
||
List<swap_flow_event> interests100, interests40;
|
||
try
|
||
{
|
||
interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.平仓);
|
||
interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.平仓);
|
||
}
|
||
catch (Exception ex) when (ex.Message.Contains("获取不到") && ex.Message.Contains("价格"))
|
||
{
|
||
Assert.Inconclusive($"FR007 fixing 数据不可用({valueDate:yyyy-MM-dd} 非交易日或数据未发布):{ex.Message}");
|
||
return;
|
||
}
|
||
|
||
PrintInterestComparison(interests100, interests40, cp100_B, cp40_B);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 打印辅助
|
||
|
||
private static void PrintPositions(string title, List<swap_position> positions)
|
||
{
|
||
Console.WriteLine($"===== {title} =====");
|
||
Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}");
|
||
foreach (var p in positions)
|
||
{
|
||
Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-7}{p.PosiDirection,-8}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}");
|
||
}
|
||
}
|
||
|
||
private static void PrintCloseFlowEvents(YLContext db, int tradeId)
|
||
{
|
||
Console.WriteLine($"\n===== 历史平仓/互换事件(EventDate vs UnwindDate)=====");
|
||
var flows = db.swap_flow_event
|
||
.Where(f => f.SwapTradeId == tradeId
|
||
&& (f.EventType == (int)SwapEventTypeEnum.平仓
|
||
|| f.EventType == (int)SwapEventTypeEnum.互换
|
||
|| f.EventType == (int)SwapEventTypeEnum.自动互换)
|
||
&& f.DataState == (int)SwapFlowDateStateEnum.完成)
|
||
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
|
||
.ToList();
|
||
|
||
if (flows.Count == 0) { Console.WriteLine(" (无历史平仓/互换事件 → 此前未平过仓)"); return; }
|
||
|
||
Console.WriteLine($" {"EventDate",-12}{"UnwindDate",-12}{"一致?",-8}{"Type",-6}{"PosId",-8}{"Mode",-6}{"I.Principal",-16}{"I.Amount",-14}");
|
||
foreach (var f in flows)
|
||
{
|
||
var sameDate = f.EventDate == f.UnwindDate;
|
||
var typeStr = f.EventType == (int)SwapEventTypeEnum.平仓 ? "平仓" :
|
||
f.EventType == (int)SwapEventTypeEnum.互换 ? "互换" : "自动";
|
||
Console.WriteLine($" {f.EventDate:yyyy-MM-dd} {f.UnwindDate:yyyy-MM-dd} {(sameDate ? "是" : "否⚠"),-6}{typeStr,-6}{f.PositionId,-8}{f.InterestMode,-6}{f.InterestPrincipal,-16}{f.InterestAmount,-14}");
|
||
}
|
||
Console.WriteLine(" ⚠ EventDate≠UnwindDate 的历史事件:当前 3a435ad8 按 EventDate 分桶,可能与 UnwindDate 口径不一致");
|
||
}
|
||
|
||
private static void PrintEodPrepaySequence(YLContext db, int tradeId)
|
||
{
|
||
Console.WriteLine($"\n===== EOD 预付金腿逐日(看 TdInterestPrincipal 是否=初始本金)=====");
|
||
var eodPrepay = db.eod_swap_position
|
||
.Where(e => e.SwapTradeId == tradeId && !e.Invalid
|
||
&& (e.InterestMode == (int)InterestModeEnum.初始预付金
|
||
|| e.InterestMode == (int)InterestModeEnum.追加预付金))
|
||
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
|
||
.ToList();
|
||
|
||
if (eodPrepay.Count == 0) { Console.WriteLine(" (无预付金腿 EOD 记录)"); return; }
|
||
|
||
Console.WriteLine($" {"ValueDate",-12}{"PosId",-8}{"Mode",-6}{"TdInterestPrincipal",-20}{"InterestProfitSum",-20}{"InterestIncomeSum",-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}{e.InterestIncomeSum,-20}");
|
||
}
|
||
|
||
// 对比初始 vs 实时剩余 vs EOD
|
||
var origPrepay = db.swap_position.Where(p => p.SwapTradeId == tradeId && !p.Invalid && p.IsInitial
|
||
&& (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList();
|
||
var realPrepay = db.swap_position.Where(p => p.SwapTradeId == tradeId && !p.Invalid && !p.IsInitial
|
||
&& (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList();
|
||
|
||
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;
|
||
var eodMatchesOrig = Math.Abs((double)(eodTd - orig.InterestPrincipalFix)) < 0.01;
|
||
var eodMatchesReal = Math.Abs((double)(eodTd - realFix)) < 0.01;
|
||
Console.WriteLine($" PosId={orig.id} origFix(初始)={orig.InterestPrincipalFix} realFix(剩余)={realFix} EOD.TdInterestPrincipal(最新)={eodTd}");
|
||
if (eodMatchesOrig && !eodMatchesReal && orig.InterestPrincipalFix != realFix)
|
||
{
|
||
Console.WriteLine($" ⚠⚠ EOD 基数=初始本金(≠剩余)→ 坐实:日终用了初始预付金本金而非实时剩余,后续利息计算基数错误!");
|
||
}
|
||
}
|
||
}
|
||
|
||
private static void PrintInterestComparison(List<swap_flow_event> interests100, List<swap_flow_event> interests40, decimal cp100_B, decimal cp40_B)
|
||
{
|
||
Console.WriteLine($"\n ---- GetUnwindInterests 返回(100% 共{interests100.Count}条 / 40% 共{interests40.Count}条)----");
|
||
Console.WriteLine($" {"PosId",-8}{"Mode",-6}{"IntDir",-8}{"I.Principal(100)",-18}{"I.Principal(40)",-18}{"本金比",-10}{"I.Amount(100)",-16}{"I.Amount(40)",-16}{"利息比",-10}");
|
||
|
||
decimal totalAmount100 = 0, totalAmount40 = 0;
|
||
|
||
foreach (var i100 in interests100.OrderBy(x => x.PositionId))
|
||
{
|
||
var i40 = interests40.FirstOrDefault(x => x.PositionId == i100.PositionId && x.InterestMode == i100.InterestMode);
|
||
var amt40 = i40?.InterestAmount ?? 0;
|
||
var prin40 = i40?.InterestPrincipal ?? 0;
|
||
totalAmount100 += i100.InterestAmount;
|
||
totalAmount40 += amt40;
|
||
|
||
string prinRatio = prin40 == 0 ? "-" : (i100.InterestPrincipal / prin40).ToString("F4");
|
||
string amtRatio = amt40 == 0 ? "-" : (i100.InterestAmount / amt40).ToString("F4");
|
||
|
||
Console.WriteLine($" {i100.PositionId,-8}{i100.InterestMode,-6}{i100.InterestDirection,-8}{i100.InterestPrincipal,-18}{prin40,-18}{prinRatio,-10}{i100.InterestAmount,-16}{amt40,-16}{amtRatio,-10}");
|
||
}
|
||
|
||
Console.WriteLine($"\n ===== 利息合计 =====");
|
||
Console.WriteLine($" 100% 总利息 = {totalAmount100}");
|
||
Console.WriteLine($" 40% 总利息 = {totalAmount40}");
|
||
var ratioStr = totalAmount40 == 0 ? "N/A" : (totalAmount100 / totalAmount40).ToString("F4");
|
||
Console.WriteLine($" 比值(100/40) = {ratioStr}");
|
||
Console.WriteLine($" 若为线性关系,比值应≈{cp100_B / cp40_B:F4}(即 B_100 / B_40)");
|
||
Console.WriteLine($" 若实际比值远偏离此值 → 存在非线性/bug,重点看上方哪条腿的[利息比]或[本金比]异常");
|
||
|
||
Console.WriteLine($"\n ===== 诊断结论指引 =====");
|
||
Console.WriteLine(" · 本金比≠B_100/B_40:ResolveInterestLegPositions 没用实时剩余本金(看 realFix vs origFix)");
|
||
Console.WriteLine(" · 复利腿利息比异常:检查 consumedInterest 扣除(GetConsumedInterest 用 EventDate 过滤)");
|
||
Console.WriteLine(" · 单利腿利息比异常:检查 preEodPosition.InterestProfitSum 基数(EOD 是否用了初始本金)");
|
||
Console.WriteLine(" · 全部腿都偏:closePercent 双语义转换 + tdClose 导致计息区间坍缩");
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
}
|