Merge remote-tracking branch 'origin/glms/feature/1.4.2' into glms/feature/1.4.2
This commit is contained in:
@@ -18,7 +18,8 @@ namespace YLErp.Modules.SwapModule
|
||||
[TestClass]
|
||||
public class GLMS20260701DbDiagnoseTest
|
||||
{
|
||||
private const string TradeNumber = "GLMS-20260701-0008";
|
||||
private const string TradeNumber_0008 = "GLMS-20260701-0008";
|
||||
private const string TradeNumber_0013 = "GLMS-20260701-0013";
|
||||
|
||||
#region 1) 录真实数据快照(手动跑)
|
||||
|
||||
@@ -31,8 +32,8 @@ namespace YLErp.Modules.SwapModule
|
||||
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},请确认环境");
|
||||
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber_0008);
|
||||
Assert.IsNotNull(td, $"测试库无交易 {TradeNumber_0008},请确认环境");
|
||||
|
||||
var snapshot = new JObject
|
||||
{
|
||||
@@ -99,13 +100,25 @@ namespace YLErp.Modules.SwapModule
|
||||
[TestMethod]
|
||||
[TestCategory("DbDiagnose")]
|
||||
public void Diagnose_InterestPrincipalFix_Progression_And_UnwindResult()
|
||||
{
|
||||
DiagnoseTrade(TradeNumber_0008);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DbDiagnose")]
|
||||
public void Diagnose_0013_InterestPrincipalFix_Progression_And_UnwindResult()
|
||||
{
|
||||
DiagnoseTrade(TradeNumber_0013);
|
||||
}
|
||||
|
||||
private void DiagnoseTrade(string tradeNumber)
|
||||
{
|
||||
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; }
|
||||
var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber);
|
||||
if (td == null) { Assert.Inconclusive($"测试库无 {tradeNumber}"); return; }
|
||||
|
||||
// 2.1 预付金腿 position.InterestPrincipalFix 当前值(多次平仓后应该已被扣减)
|
||||
var marginPositions = db.swap_position
|
||||
@@ -127,11 +140,11 @@ namespace YLErp.Modules.SwapModule
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine("\n============== 全部 position 全景(对比 IsInitial 原始 vs !IsInitial 剩余) ==============");
|
||||
Console.WriteLine($" {"Id",-8}{"Mode",-6}{"Dir",-6}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}");
|
||||
Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}");
|
||||
foreach (var p in allPositions)
|
||||
{
|
||||
var ul = p.UnderlyingCode ?? "";
|
||||
Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-6}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}{ul,-15}");
|
||||
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}{ul,-15}");
|
||||
}
|
||||
|
||||
// 2.1c 关键诊断:GetUnwindInterests 内部 origPositions vs realPostitions 差异
|
||||
@@ -143,6 +156,14 @@ namespace YLErp.Modules.SwapModule
|
||||
Console.WriteLine($" origPositions(IsInitial=True) 预付金腿 Fix: {string.Join(",", origPositions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))}");
|
||||
Console.WriteLine($" realPostitions(IsInitial=False) 预付金腿 Fix: {string.Join(",", realPostitions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))} ← 应为剩余值");
|
||||
|
||||
// 2.1d 关键诊断:realLeg.PositionId == origPos.id 匹配校验(修复后端 Clone 是否会触发)
|
||||
Console.WriteLine("\n============== realLeg.PositionId ↔ origPos.id 匹配校验(决定 Clone 是否生效)==============");
|
||||
foreach (var origPos in origPositions.Where(p => p.InterestMode == 5 || p.InterestMode == 6))
|
||||
{
|
||||
var realLeg = realPostitions.FirstOrDefault(r => r.PositionId == origPos.id);
|
||||
Console.WriteLine($" origPos.id={origPos.id} Fix={origPos.InterestPrincipalFix} | realLeg found={(realLeg != null)} | realLeg.id={realLeg?.id} realLeg.PositionId={realLeg?.PositionId} realLeg.Fix={realLeg?.InterestPrincipalFix} | 需Clone={(realLeg != null && realLeg.InterestPrincipalFix != origPos.InterestPrincipalFix)}");
|
||||
}
|
||||
|
||||
// 2.2 EOD 持仓 InterestPrincipalFix 逐日序列
|
||||
var eodMarginSeq = db.eod_swap_position
|
||||
.Where(e => e.SwapTradeId == td.id && !e.Invalid
|
||||
@@ -171,12 +192,38 @@ namespace YLErp.Modules.SwapModule
|
||||
Console.WriteLine($" EventDate={f.EventDate:yyyy-MM-dd} PositionId={f.PositionId} InterestPrincipal={f.InterestPrincipal} InterestAmount={f.InterestAmount} Quantity={f.Quantity} TradingAmount={f.TradingAmount}");
|
||||
}
|
||||
|
||||
// 2.3b 直接调 ResolveInterestLegPositions,验证 Clone 是否真的把 Fix 覆盖成 realLeg 值
|
||||
var resolved = SwapDealService.ResolveInterestLegPositions(origPositions, realPostitions);
|
||||
Console.WriteLine("\n============== ResolveInterestLegPositions 直接调用结果 ==============");
|
||||
foreach (var rp in resolved.Where(x => x.InterestMode == 5 || x.InterestMode == 6))
|
||||
{
|
||||
Console.WriteLine($" resolved: id={rp.id} PositionId={rp.PositionId} Mode={rp.InterestMode} Fix={rp.InterestPrincipalFix} (期望=realLeg.Fix)");
|
||||
}
|
||||
|
||||
// 2.3c 模拟前端调用 controller 完整流程:前端传 closePercent=0.7(占期初) + notionalValue/posiNotionalValue
|
||||
// controller 调 ToRemainingClosePercent 转为占剩余,再调 GetUnwindInterests
|
||||
// 等价于 HTTP POST /swaptrade2/GetUnwindInterestList
|
||||
Console.WriteLine("\n============== 模拟 HTTP API 调用(前端 closePercent=0.7 占期初)==============");
|
||||
decimal frontClosePercent = 0.7m;
|
||||
decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初名义本金
|
||||
decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余名义本金
|
||||
Console.WriteLine($" 前端参数: closePercent={frontClosePercent} notionalValue={frontNotionalValue} posiNotionalValue={frontPosiNotionalValue}");
|
||||
decimal convertedClosePercent = SwapDealService.ToRemainingClosePercent(frontClosePercent, frontNotionalValue, frontPosiNotionalValue);
|
||||
Console.WriteLine($" ToRemainingClosePercent 转换后: closePercent={convertedClosePercent}(占剩余)");
|
||||
var svc = new SwapDealService(new OptUserInfo(1, "UnitTest", OptUserFrom.UnitTest));
|
||||
var apiInterests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, convertedClosePercent, (int)SwapEventTypeEnum.平仓);
|
||||
Console.WriteLine($" GetUnwindInterests 返回 {apiInterests.Count} 条,预付金腿:");
|
||||
foreach (var ai in apiInterests.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 || x.InterestMode == (int)InterestModeEnum.追加预付金))
|
||||
{
|
||||
Console.WriteLine($" PositionId={ai.PositionId} Mode={ai.InterestMode} InterestPrincipal={ai.InterestPrincipal} InterestAmount={ai.InterestAmount}");
|
||||
}
|
||||
|
||||
// 2.4 直调后端 GetUnwindInterests(closePercent=1.0) 看"按全部平仓应返"的预付金值
|
||||
try
|
||||
{
|
||||
var user = new OptUserInfo(0, nameof(GLMS20260701DbDiagnoseTest), OptUserFrom.UnitTest);
|
||||
var svc = new SwapDealService(user);
|
||||
var interests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.平仓);
|
||||
var svcFull = new SwapDealService(user);
|
||||
var interests = svcFull.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.平仓);
|
||||
|
||||
Console.WriteLine("============== 后端 GetUnwindInterests(1.0) 实际返回值-预付金腿 ==============");
|
||||
foreach (var it in interests.Where(i => i.InterestMode == 5 || i.InterestMode == 6))
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// InitUnwind 默认 ClosePercent 计算的回归测试。
|
||||
/// ---------------------------------------------------------------
|
||||
/// 守卫提交 e2fb456b "fix 平仓(InitUnwind L267)硬编码 1 而不是剩余平仓比例"。
|
||||
///
|
||||
/// 旧 bug:InitUnwind 默认把 ClosePercent 硬编码为 1(按"占剩余 100%"),
|
||||
/// 但前端约定 ClosePercent 是"占期初(original)"口径(A),1 表示平掉原始本金的 100%。
|
||||
/// 多次部分平仓后剩余本金 < 期初本金,此时默认 1 在前端语义上意味着"还要平掉原始全部",
|
||||
/// 与"平掉剩余全部"意图不符,且会触发后端 ToRemainingClosePercent 转换后 >1 被 cap 到 1,
|
||||
/// 表面看无差异但语义混乱,且若前端 / 事件展示直接用此值会出错。
|
||||
///
|
||||
/// 修复:ClosePercent = PosiNotionalValue / NotionalValue(占期初口径的"平剩余全部")。
|
||||
/// 抽出为纯函数 CalcDefaultInitClosePercent 以支持无库单测。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class InitUnwindDefaultClosePercentTest
|
||||
{
|
||||
// ================================================================
|
||||
// 场景1:未平仓 PosiNotionalValue == NotionalValue → ClosePercent = 1
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 未平仓_剩余等于期初_默认ClosePercent为1()
|
||||
{
|
||||
var result = SwapDealService.CalcDefaultInitClosePercent(
|
||||
notionalValue: 1_000_000m, posiNotionalValue: 1_000_000m);
|
||||
|
||||
Assert.AreEqual(1m, result, "未平仓:默认应平 100%(占期初)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景2:已平 30%(剩 70%)→ ClosePercent = 0.7
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 已平30_剩70_默认ClosePercent为0_7()
|
||||
{
|
||||
var result = SwapDealService.CalcDefaultInitClosePercent(
|
||||
notionalValue: 1_000_000m, posiNotionalValue: 700_000m);
|
||||
|
||||
Assert.AreEqual(0.7m, result, 0.0001m, "已平 30% 剩 70%:默认 ClosePercent=0.7(占期初)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景3:除零保护 NotionalValue = 0 → 返回 1(容错)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 期初名义本金为零_返回1_容错不除零()
|
||||
{
|
||||
var result = SwapDealService.CalcDefaultInitClosePercent(
|
||||
notionalValue: 0m, posiNotionalValue: 100_000m);
|
||||
|
||||
Assert.AreEqual(1m, result, "期初本金为 0 时容错返回 1,不应抛除零异常");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景4:GLMS-20260701-0013 真实快照(已平 2 次)
|
||||
// 期初 NotionalValue = 980,000 / 剩余 PosiNotionalValue = 686,000.07
|
||||
// 期望 ClosePercent ≈ 0.7(686000.07/980000)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void GLMS20260701_0013_已平两次_默认ClosePercent约为0_7()
|
||||
{
|
||||
var result = SwapDealService.CalcDefaultInitClosePercent(
|
||||
notionalValue: 980_000m, posiNotionalValue: 686_000.07m);
|
||||
|
||||
// 686000.07 / 980000 = 0.700000071...
|
||||
Assert.AreEqual(0.7m, result, 0.0001m,
|
||||
"GLMS-20260701-0013 已平两次:默认 ClosePercent 应≈0.7(占期初),旧 bug 会硬编码 1");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景5:与 ToRemainingClosePercent 联动验证
|
||||
// 前端拿 InitUnwind 返回的 A(占期初) 默认值,经 ToRemainingClosePercent 转 B(占剩余),
|
||||
// 应恰好 = 1.0(因为"平剩余全部"在占剩余语义下就是 100%)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 默认A经ToRemainingClosePercent转B应为1_平剩余全部()
|
||||
{
|
||||
const decimal notionalValue = 1_000_000m;
|
||||
const decimal posiNotionalValue = 600_000m; // 已平 40%,剩 60%
|
||||
|
||||
var defaultA = SwapDealService.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue);
|
||||
var convertedB = SwapDealService.ToRemainingClosePercent(defaultA, notionalValue, posiNotionalValue);
|
||||
|
||||
Assert.AreEqual(0.6m, defaultA, 0.0001m, "占期初默认 A=0.6");
|
||||
Assert.AreEqual(1.0m, convertedB, 0.0001m,
|
||||
"A=0.6 经 ToRemainingClosePercent 转换 → B=1.0(占剩余 100% = 平剩余全部),此为占期初/占剩余双语义自洽的关键不变式");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景6:全平完(PosiNotionalValue=0)→ ClosePercent=0(边界,实际不会进 InitUnwind)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 全平完剩余为零_ClosePercent为零()
|
||||
{
|
||||
var result = SwapDealService.CalcDefaultInitClosePercent(
|
||||
notionalValue: 1_000_000m, posiNotionalValue: 0m);
|
||||
|
||||
Assert.AreEqual(0m, result, "剩余本金为 0 时 ClosePercent=0(边界场景,实际全部平完不会再进 InitUnwind)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// SwapEodPositionService.CalculateSwapRealizedPnl 的回归测试。
|
||||
/// ---------------------------------------------------------------
|
||||
/// 守卫张名锐提交 6676b625 "fix(swap): 修正掉期产品保证金利息计算逻辑"。
|
||||
///
|
||||
/// 旧 bug:eod_swap.RealizedPnL 直接 Sum(s.RealizedPnl),未对保证金腿利息做方向反向,
|
||||
/// 导致"收取对手方保证金"产生的利息被错误计入我方收益(实际是我方支付给对手方的成本),
|
||||
/// 框架合约已实现收益虚高。
|
||||
///
|
||||
/// 修复:新增 CalculateSwapRealizedPnl ——
|
||||
/// 非保证金腿:interestRatio = Direction==收取 ? 1 : -1(维持数据库方向)
|
||||
/// 保证金腿(初始预付金 5 / 追加预付金 6):interestRatio 反向
|
||||
/// 最终:RealizedInterest × interestRatio + 其他 4 字段
|
||||
///
|
||||
/// 抽为 public static 纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。
|
||||
/// 本测试直接锁定方向反向契约,防止后续误改回归。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SwapEodRealizedPnlCalcTest
|
||||
{
|
||||
// ================================================================
|
||||
// 场景1:非保证金腿收取方向 → RealizedInterest × +1(维持原向)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 非保证金腿_收取方向_利息维持原向系数为1()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.固定值,
|
||||
interestDirection: (int)SwapDirectionEnum.收取,
|
||||
realizedInterest: 1000m);
|
||||
|
||||
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(1000m, result, 0.0001m,
|
||||
"非保证金腿收取方向:利息 ×(+1)=1000");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景2:非保证金腿支付方向 → RealizedInterest × -1(维持原向)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 非保证金腿_支付方向_利息维持原向系数为负1()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.固定值,
|
||||
interestDirection: (int)SwapDirectionEnum.支付,
|
||||
realizedInterest: 1000m);
|
||||
|
||||
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(-1000m, result, 0.0001m,
|
||||
"非保证金腿支付方向:利息 ×(-1)=-1000");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景3:保证金腿(初始预付金)收取方向 → 利息反向,系数 -1
|
||||
// 这是 6676b625 修复的核心场景:收取对手方保证金产生的利息是我方支付成本
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 保证金腿_初始预付金_收取方向_利息反向系数为负1()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.初始预付金,
|
||||
interestDirection: (int)SwapDirectionEnum.收取,
|
||||
realizedInterest: 1000m);
|
||||
|
||||
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(-1000m, result, 0.0001m,
|
||||
"保证金腿收取方向:利息应反向 ×(-1)=-1000(修复前会错误得 +1000)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景4:保证金腿(初始预付金)支付方向 → 利息反向,系数 +1
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 保证金腿_初始预付金_支付方向_利息反向系数为1()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.初始预付金,
|
||||
interestDirection: (int)SwapDirectionEnum.支付,
|
||||
realizedInterest: 1000m);
|
||||
|
||||
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(1000m, result, 0.0001m,
|
||||
"保证金腿支付方向:利息应反向 ×(+1)=1000");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景5:追加预付金同初始预付金,同样走反向逻辑
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 保证金腿_追加预付金_收取方向_利息反向()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.追加预付金,
|
||||
interestDirection: (int)SwapDirectionEnum.收取,
|
||||
realizedInterest: 500m);
|
||||
|
||||
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(-500m, result, 0.0001m,
|
||||
"追加预付金(mode=6)与初始预付金(mode=5)同走反向逻辑");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景6:完整 5 字段汇总(MtmPnL + Dividend + Fee + Interest×ratio + InterestFee)
|
||||
// 保证金腿收取方向,Interest=200, 其他各 100
|
||||
// 期望:100 + 100 + 100 + 200×(-1) + 100 = 200
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 完整5字段汇总_保证金腿收取方向_利息反向后合计正确()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.初始预付金,
|
||||
interestDirection: (int)SwapDirectionEnum.收取,
|
||||
realizedMtmPnL: 100m,
|
||||
realizedDividend: 100m,
|
||||
realizedFee: 100m,
|
||||
realizedInterest: 200m,
|
||||
realizedInterestFee: 100m);
|
||||
|
||||
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
|
||||
|
||||
// 100 + 100 + 100 + 200×(-1) + 100 = 200
|
||||
Assert.AreEqual(200m, result, 0.0001m,
|
||||
"5 字段汇总:保证金腿收取方向,Interest×(-1) 后合计=200,验证所有字段都参与计算");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景7:完整 5 字段汇总(非保证金腿收取方向)
|
||||
// 非保证金腿收取方向,Interest=200, 其他各 100
|
||||
// 期望:100 + 100 + 100 + 200×(+1) + 100 = 600
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 完整5字段汇总_非保证金腿收取方向_利息原向合计正确()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.固定值,
|
||||
interestDirection: (int)SwapDirectionEnum.收取,
|
||||
realizedMtmPnL: 100m,
|
||||
realizedDividend: 100m,
|
||||
realizedFee: 100m,
|
||||
realizedInterest: 200m,
|
||||
realizedInterestFee: 100m);
|
||||
|
||||
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
|
||||
|
||||
// 100 + 100 + 100 + 200×(+1) + 100 = 600
|
||||
Assert.AreEqual(600m, result, 0.0001m,
|
||||
"5 字段汇总:非保证金腿收取方向,Interest×(+1) 后合计=600");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景8:RealizedInterest=0 边界 —— 方向反向无影响,结果为其他 4 字段之和
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 利息为零_方向反向无影响_结果为其他4字段之和()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.初始预付金,
|
||||
interestDirection: (int)SwapDirectionEnum.收取,
|
||||
realizedMtmPnL: 100m,
|
||||
realizedDividend: 50m,
|
||||
realizedFee: 30m,
|
||||
realizedInterest: 0m,
|
||||
realizedInterestFee: 20m);
|
||||
|
||||
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
|
||||
|
||||
// 100 + 50 + 30 + 0×(-1) + 20 = 200
|
||||
Assert.AreEqual(200m, result, 0.0001m,
|
||||
"RealizedInterest=0 时方向反向无影响,结果为其他 4 字段之和");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helper:构造 eod_swap_position(只设置参与计算的 7 个字段)
|
||||
// ================================================================
|
||||
private static eod_swap_position NewPosition(
|
||||
int interestMode,
|
||||
int interestDirection,
|
||||
decimal realizedMtmPnL = 0m,
|
||||
decimal realizedDividend = 0m,
|
||||
decimal realizedFee = 0m,
|
||||
decimal realizedInterest = 0m,
|
||||
decimal realizedInterestFee = 0m)
|
||||
{
|
||||
return new eod_swap_position
|
||||
{
|
||||
InterestMode = interestMode,
|
||||
InterestDirection = interestDirection,
|
||||
RealizedMtmPnL = realizedMtmPnL,
|
||||
RealizedDividend = realizedDividend,
|
||||
RealizedFee = realizedFee,
|
||||
RealizedInterest = realizedInterest,
|
||||
RealizedInterestFee = realizedInterestFee
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.Modules.TradeModule;
|
||||
|
||||
namespace YLErp.Modules.TradeModule
|
||||
{
|
||||
/// <summary>
|
||||
/// TradeServiceBase.CalcSwapCloseNotionalFromEventData / CalcOptionCloseNotional 的回归测试。
|
||||
/// ---------------------------------------------------------------
|
||||
/// 守卫锦麟王提交 23108016 "BugFix 互换本次名义本金取错"。
|
||||
///
|
||||
/// 旧 bug:BuildTriggerContext 了结场景统一用 trade_cash.UnwindPercentRate × 期初名义本金
|
||||
/// 算本次名义本金,但收益互换的 trade_cash.UnwindPercentRate 口径与期权不同,
|
||||
/// 导致互换审批触发条件用错本金,可能绕过/误触发审批阈值。
|
||||
///
|
||||
/// 修复:互换分支从 swap_event.EventData 反序列化取 CloseNotionalValue 绝对值;
|
||||
/// 期权分支保留旧逻辑(期初名义本金 × UnwindPercentRate 绝对值)。
|
||||
///
|
||||
/// 抽出两个静态纯函数以支持无库单测,重点验证容错(null/空/非法 JSON)不会抛异常
|
||||
/// 而是返回 0,避免静默吞异常导致名义本金为 0 进而绕过审批阈值。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class TradeServiceBaseCloseNotionalCalcTest
|
||||
{
|
||||
// ================================================================
|
||||
// 一、CalcSwapCloseNotionalFromEventData 容错与绝对值语义
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void 互换_EventData为null_返回0_不抛异常()
|
||||
{
|
||||
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(null);
|
||||
Assert.AreEqual(0d, result, 0.0001, "null EventData 应容错返回 0");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 互换_EventData为空字符串_返回0_不抛异常()
|
||||
{
|
||||
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData("");
|
||||
Assert.AreEqual(0d, result, 0.0001, "空字符串 EventData 应容错返回 0");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 互换_EventData为非法JSON_返回0_不抛异常()
|
||||
{
|
||||
// 旧实现 catch{} 静默吞异常,抽函数后必须保持此容错契约
|
||||
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData("not-a-json");
|
||||
Assert.AreEqual(0d, result, 0.0001, "非法 JSON 应被 catch 返回 0,不能抛异常");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 互换_EventData为合法JSON_正数CloseNotionalValue_原值返回()
|
||||
{
|
||||
var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 500_000m });
|
||||
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData);
|
||||
Assert.AreEqual(500_000d, result, 0.01, "正数 CloseNotionalValue 应原值返回");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 互换_EventData为合法JSON_负数CloseNotionalValue_取绝对值()
|
||||
{
|
||||
// 修复的核心契约:Math.Abs 取绝对值,防止方向反向导致名义本金变负
|
||||
var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = -500_000m });
|
||||
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData);
|
||||
Assert.AreEqual(500_000d, result, 0.01, "负数 CloseNotionalValue 应取绝对值返回 500000");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 互换_EventData为合法JSON_CloseNotionalValue为零_返回0()
|
||||
{
|
||||
var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 0m });
|
||||
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData);
|
||||
Assert.AreEqual(0d, result, 0.0001, "CloseNotionalValue=0 应返回 0");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 二、CalcOptionCloseNotional 容错与绝对值语义
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void 期权_两者都为null_返回0()
|
||||
{
|
||||
var result = TradeServiceBase.CalcOptionCloseNotional(null, null);
|
||||
Assert.AreEqual(0d, result, 0.0001, "两者 null 应返回 0");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 期权_期初名义本金为null_返回0()
|
||||
{
|
||||
var result = TradeServiceBase.CalcOptionCloseNotional(null, 0.5d);
|
||||
Assert.AreEqual(0d, result, 0.0001, "originalStockEqvNotional=null 应返回 0");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 期权_平仓比例为null_返回0()
|
||||
{
|
||||
var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, null);
|
||||
Assert.AreEqual(0d, result, 0.0001, "unwindPercentRate=null 应返回 0");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 期权_两者都有值_正数相乘_返回乘积()
|
||||
{
|
||||
// 1,000,000 × 0.3 = 300,000
|
||||
var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, 0.3d);
|
||||
Assert.AreEqual(300_000d, result, 0.01, "1M × 0.3 = 300K");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 期权_期初名义本金为负数_取绝对值后相乘()
|
||||
{
|
||||
// 异常但容错:-1,000,000 × 0.3 → Abs → 300,000
|
||||
var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, 0.3d);
|
||||
Assert.AreEqual(300_000d, result, 0.01, "期初名义本金为负数应取绝对值后相乘");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 期权_平仓比例为负数_取绝对值后相乘()
|
||||
{
|
||||
// 异常但容错:1,000,000 × -0.3 → Abs → 300,000
|
||||
var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, -0.3d);
|
||||
Assert.AreEqual(300_000d, result, 0.01, "平仓比例为负数应取绝对值后相乘");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 期权_两者都为负数_取绝对值后相乘()
|
||||
{
|
||||
// -1,000,000 × -0.3 = 300,000(先乘后取 Abs,结果一致)
|
||||
var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, -0.3d);
|
||||
Assert.AreEqual(300_000d, result, 0.01, "两者都为负数应取绝对值后相乘");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user