Merge remote-tracking branch 'origin/glms/feature/1.4.2' into feature/p132_74-risk-engine
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.Helpers;
|
||||
|
||||
namespace YLErp.Helpers.Tests
|
||||
{
|
||||
[TestClass]
|
||||
public class ConditionEvaluatorTests
|
||||
{
|
||||
private static ConditionContext CreateContext(double notional = 0, string tradeType = "", int? userId = null, int? initGroupId = null, string processCategory = "")
|
||||
{
|
||||
return new ConditionContext
|
||||
{
|
||||
Trade = new DBModels.trade
|
||||
{
|
||||
StockEqvNotional = notional,
|
||||
TradeType = tradeType
|
||||
},
|
||||
UserId = userId,
|
||||
InitGroupId = initGroupId,
|
||||
ProcessCategory = processCategory
|
||||
};
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_EmptyOrNullCondition_ReturnsFalse()
|
||||
{
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(null, CreateContext()));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate("", CreateContext()));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(" ", CreateContext()));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_InvalidJson_ReturnsFalse()
|
||||
{
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate("not a json", CreateContext()));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate("{\"tokens\": [", CreateContext()));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_TokenSingleCondition_Works()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken
|
||||
{
|
||||
type = "condition",
|
||||
condition = new ConditionItem { field = "notional", op = ">", value = 100 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_TokenAnd_Works()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } },
|
||||
new ConditionToken { type = "operator", connector = "and" },
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<=", value = 500 } }
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_TokenOr_Works()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 100 } },
|
||||
new ConditionToken { type = "operator", connector = "or" },
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 500 } }
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 600)));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 300)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_TokenWithParentheses_Works()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "lparen" },
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } },
|
||||
new ConditionToken { type = "operator", connector = "or" },
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 50 } },
|
||||
new ConditionToken { type = "rparen" },
|
||||
new ConditionToken { type = "operator", connector = "and" },
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } }
|
||||
}
|
||||
});
|
||||
|
||||
// (200>100 or 200<50) and tradeType=="香草" => true
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "香草")));
|
||||
// (30>100 or 30<50) and tradeType=="雪球" => false
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 30, tradeType: "雪球")));
|
||||
// (80>100 or 80<50) and tradeType=="香草" => false
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 80, tradeType: "香草")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_MixedAndOr_PriorityAndOverOr()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } },
|
||||
new ConditionToken { type = "operator", connector = "or" },
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 50 } },
|
||||
new ConditionToken { type = "operator", connector = "and" },
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } }
|
||||
}
|
||||
});
|
||||
|
||||
// A or (B and C) — and 优先级高于 or
|
||||
// 200>100 -> true,无需计算右侧
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "")));
|
||||
// 30>100=false, 30<50=true, 香草==香草=true -> false or (true and true) = true
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 30, tradeType: "香草")));
|
||||
// 80>100=false, 80<50=false, 雪球==香草=false -> false or (false and false) = false
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 80, tradeType: "雪球")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_InitGroup_Works()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "initGroup", op = "==", value = 5 } }
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { InitGroupId = 5 }));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { InitGroupId = 3 }));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_ProcessCategory_Works()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "processCategory", op = "==", value = "CloseProcess" } }
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(processCategory: "CloseProcess")));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(processCategory: "TradeProcess")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_TradeTypeStringComparison_Works()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "!=", value = "雪球" } }
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(tradeType: "香草")));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(tradeType: "雪球")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_IncompleteParentheses_DoesNotThrow()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "lparen" },
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } }
|
||||
}
|
||||
});
|
||||
|
||||
// Parser tolerates missing closing parenthesis and returns the value inside
|
||||
var result = ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200));
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_NotEquals_Works()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "!=", value = 100 } }
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_GreaterOrEqual_Works()
|
||||
{
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">=", value = 100 } }
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 99)));
|
||||
}
|
||||
|
||||
// ===== 字母 op 标识符(前端改用 gt/lt/gte/lte/eq/neq 规避 > < 编码问题)=====
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_AlphaOp_GreaterThan_Works()
|
||||
{
|
||||
var condition = BuildTokens(new ConditionItem { field = "notional", op = "gt", value = 100 });
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_AlphaOp_LessThanOrEqual_Works()
|
||||
{
|
||||
var condition = BuildTokens(new ConditionItem { field = "notional", op = "lte", value = 100 });
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 101)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_AlphaOp_EqualAndNotEqual_Works()
|
||||
{
|
||||
var eqCond = BuildTokens(new ConditionItem { field = "tradeType", op = "eq", value = "香草" });
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(eqCond, CreateContext(tradeType: "香草")));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(eqCond, CreateContext(tradeType: "雪球")));
|
||||
|
||||
var neqCond = BuildTokens(new ConditionItem { field = "tradeType", op = "neq", value = "雪球" });
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(neqCond, CreateContext(tradeType: "香草")));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(neqCond, CreateContext(tradeType: "雪球")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_AlphaOp_CaseInsensitive_Works()
|
||||
{
|
||||
// 大写字母 op 也应识别(归一化为小写)
|
||||
var condition = BuildTokens(new ConditionItem { field = "notional", op = "GTE", value = 100 });
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 99)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_MixedAlphaAndSymbolOp_Works()
|
||||
{
|
||||
// 字母 op 与符号 op 混用:notional gt 100 and tradeType == 香草
|
||||
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "gt", value = 100 } },
|
||||
new ConditionToken { type = "operator", connector = "and" },
|
||||
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } }
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "香草")));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50, tradeType: "香草")));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "雪球")));
|
||||
}
|
||||
|
||||
// ===== 需求①触发条件字段:initialNotional(期初)/ currentNotional(本次)=====
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_InitialNotional_UsesOriginalStockEqvNotional()
|
||||
{
|
||||
// initialNotional 取 trade.OriginalStockEqvNotional(与 StockEqvNotional 是不同字段)
|
||||
var condition = BuildTokens(new ConditionItem { field = "initialNotional", op = "gte", value = 1000000 });
|
||||
var ctx = new ConditionContext
|
||||
{
|
||||
Trade = new DBModels.trade
|
||||
{
|
||||
StockEqvNotional = 500, // 当前份额,不应被 initialNotional 使用
|
||||
OriginalStockEqvNotional = 2000000 // 期初名义本金
|
||||
}
|
||||
};
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, ctx));
|
||||
|
||||
var ctxBelow = new ConditionContext
|
||||
{
|
||||
Trade = new DBModels.trade { OriginalStockEqvNotional = 500000 }
|
||||
};
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, ctxBelow));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_CurrentNotional_UsesContextValue()
|
||||
{
|
||||
// currentNotional 取 ConditionContext.CurrentNotional(了结场景由 trade_cash 取绝对值传入)
|
||||
var condition = BuildTokens(new ConditionItem { field = "currentNotional", op = "gt", value = 1000000 });
|
||||
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 5000000 }));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 500000 }));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = null }));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_CurrentNotional_AbsoluteValueSemantics()
|
||||
{
|
||||
// 需求:平仓500万,本次交易名义本金按绝对值判断。
|
||||
// 调用方应传 Math.Abs 后的正值(BuildTriggerContext 已处理),这里验证传入正值即可。
|
||||
var condition = BuildTokens(new ConditionItem { field = "currentNotional", op = "gte", value = 5000000 });
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 5000000 }));
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 8000000 }));
|
||||
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 4999999 }));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Evaluate_InitialNotional_AbsoluteValueStored()
|
||||
{
|
||||
// OriginalStockEqvNotional 的 setter 已做 Math.Abs,负值存入会变正
|
||||
var condition = BuildTokens(new ConditionItem { field = "initialNotional", op = "gt", value = 100 });
|
||||
var ctx = new ConditionContext
|
||||
{
|
||||
Trade = new DBModels.trade { OriginalStockEqvNotional = -500 } // setter 归一化为 500
|
||||
};
|
||||
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, ctx));
|
||||
}
|
||||
|
||||
/// <summary>辅助:单条件 tokens 序列化为 JSON。</summary>
|
||||
private static string BuildTokens(ConditionItem item)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new System.Collections.Generic.List<ConditionToken>
|
||||
{
|
||||
new ConditionToken { type = "condition", condition = item }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.Helpers;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace YLErp.Helpers.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// 需求①:交易提交进入审批流程时的「起点触发条件跳过」测试。
|
||||
/// <para>场景:交易一进来,若节点1、2配置的触发条件均不满足,应直接从节点3开始审核;
|
||||
/// 若全部节点都不满足,则直接审批通过(无需任何审核)。</para>
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class TriggerNodeSkipTests
|
||||
{
|
||||
/// <summary>构造一个审批节点:order + 可选的触发条件JSON。</summary>
|
||||
private static approvalprocess Node(int order, string triggerCondition = null)
|
||||
{
|
||||
return new approvalprocess
|
||||
{
|
||||
order = order,
|
||||
node = 0,
|
||||
triggerCondition = triggerCondition
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>构造"期初名义本金 > 阈值"的触发条件JSON。</summary>
|
||||
private static string InitialNotionalGt(double threshold)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new ConditionExpressionConfig
|
||||
{
|
||||
tokens = new List<ConditionToken>
|
||||
{
|
||||
new ConditionToken
|
||||
{
|
||||
type = "condition",
|
||||
condition = new ConditionItem { field = "initialNotional", op = "gt", value = threshold }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static ConditionContext Ctx(double initialNotional)
|
||||
{
|
||||
return new ConditionContext
|
||||
{
|
||||
Trade = new trade { OriginalStockEqvNotional = initialNotional }
|
||||
};
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StartNode_NoTrigger_ReturnsStartDirectly()
|
||||
{
|
||||
// 节点1无触发条件 → 直接返回节点1
|
||||
var nodes = new List<approvalprocess> { Node(1), Node(2), Node(3) };
|
||||
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(500));
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.order);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StartNode_TriggerSatisfied_ReturnsStart()
|
||||
{
|
||||
// 节点1触发条件">100",交易期初200满足 → 返回节点1
|
||||
var nodes = new List<approvalprocess>
|
||||
{
|
||||
Node(1, InitialNotionalGt(100)),
|
||||
Node(2),
|
||||
Node(3)
|
||||
};
|
||||
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.order);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StartNode_TriggerNotSatisfied_SkipsToNextSatisfied()
|
||||
{
|
||||
// 节点1触发">100",节点2触发">500",交易期初200
|
||||
// → 节点1不满足(200>100满足? 满足)... 重新设计:节点1触发">1000",200不满足;节点2无触发 → 返回节点2
|
||||
var nodes = new List<approvalprocess>
|
||||
{
|
||||
Node(1, InitialNotionalGt(1000)), // 200 不满足 >1000
|
||||
Node(2), // 无触发条件
|
||||
Node(3)
|
||||
};
|
||||
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(2, result.order);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AllStartNodesNotSatisfied_ReturnsNull_DirectlyApproved()
|
||||
{
|
||||
// 节点1、2、3都有触发条件,交易都不满足 → 返回null(表示直接审批通过)
|
||||
var nodes = new List<approvalprocess>
|
||||
{
|
||||
Node(1, InitialNotionalGt(1000)), // 200 不满足
|
||||
Node(2, InitialNotionalGt(5000)), // 200 不满足
|
||||
Node(3, InitialNotionalGt(10000)) // 200 不满足
|
||||
};
|
||||
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SkipMultipleNodes_LandsOnThird()
|
||||
{
|
||||
// 节点1、2都不满足,节点3无触发 → 返回节点3
|
||||
// 模拟"A默认审核、B>100W再审核、C>=500W再审核"中,小额交易跳过B、C直达... 实际应停在满足的节点
|
||||
var nodes = new List<approvalprocess>
|
||||
{
|
||||
Node(1, InitialNotionalGt(1000000)), // 50W 不满足
|
||||
Node(2, InitialNotionalGt(2000000)), // 50W 不满足
|
||||
Node(3) // 无触发条件(兜底审核)
|
||||
};
|
||||
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(500000));
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.order);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SatisfiedAtSecondNode_StopsThere()
|
||||
{
|
||||
// 节点1触发">1000"不满足,节点2触发">100"满足 → 返回节点2(不会继续到节点3)
|
||||
var nodes = new List<approvalprocess>
|
||||
{
|
||||
Node(1, InitialNotionalGt(1000)), // 200 不满足
|
||||
Node(2, InitialNotionalGt(100)), // 200 满足
|
||||
Node(3, InitialNotionalGt(50)) // 不会走到这
|
||||
};
|
||||
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(2, result.order);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StartFromMiddleNode_Works()
|
||||
{
|
||||
// 起点不是节点1(如分支调整后从节点2开始),从节点2起判断
|
||||
var nodes = new List<approvalprocess>
|
||||
{
|
||||
Node(1, InitialNotionalGt(1000)),
|
||||
Node(2, InitialNotionalGt(1000)), // 200 不满足
|
||||
Node(3) // 无触发
|
||||
};
|
||||
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[1], Ctx(200));
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.order);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NullStart_ReturnsNull()
|
||||
{
|
||||
var nodes = new List<approvalprocess> { Node(1) };
|
||||
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, null, Ctx(500));
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace YLErp.Modules.DataProviderModule
|
||||
{
|
||||
/// <summary>
|
||||
/// TryGetSettlementEodPrice(债券感知统一取价)的白盒测试。
|
||||
/// 覆盖期权/交易到期结算场景:债券标的应走中债估值表取到价(修复"结算价未找到"),
|
||||
/// 非债券标的行为应与原 TryGetEodPrice 完全一致(不影响期货/股票)。
|
||||
/// 注:DB 驱动,需连测试库;无数据时 Assert.Inconclusive 跳过。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class EodPriceQueryServiceSettlementTest : YLUnitTestBase
|
||||
{
|
||||
[TestMethod]
|
||||
public void BondUnderlying_RoutesToChinaBondValuation()
|
||||
{
|
||||
using var db = DbContextFactory.GetYLDbContext();
|
||||
var bond = (from b in db.china_bond_valuation
|
||||
join u in db.underlying_manager on b.bond_id equals u.UnderlyingCode
|
||||
where b.dirty_price_close > 0
|
||||
orderby b.valuation_date descending
|
||||
select new { b.bond_id, vd = b.valuation_date }).FirstOrDefault();
|
||||
if (bond == null) Assert.Inconclusive("测试库无债券估值数据,跳过");
|
||||
|
||||
var ok = EodPriceQueryService.TryGetSettlementEodPrice(bond.vd, bond.bond_id, out var ep);
|
||||
Assert.IsTrue(ok, "债券标的应走中债估值表取到价(修复点)");
|
||||
Assert.IsNotNull(ep);
|
||||
// 债券 ClosePrice=全价(dirty_price_close),应与 GetBondPrice().ClosePrice 一致
|
||||
var bondPrice = EodPriceQueryService.GetBondPrice(bond.vd, bond.bond_id);
|
||||
Assert.IsNotNull(bondPrice);
|
||||
Assert.AreEqual(bondPrice.ClosePrice, ep.ClosePrice, 1e-6);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NonBondUnderlying_RoutesToStockOrFuturePath()
|
||||
{
|
||||
using var db = DbContextFactory.GetYLDbContext();
|
||||
var stock = (from s in db.eod_stock_price
|
||||
join u in db.underlying_manager on s.UnderlyingCode equals u.UnderlyingCode
|
||||
where s.ClosePrice > 0 && u.UnderlyingInstrumentType == "Stock"
|
||||
select new { s.UnderlyingCode, s.ValueDate }).FirstOrDefault();
|
||||
if (stock == null) Assert.Inconclusive("测试库无(股票类型)价格数据,跳过");
|
||||
|
||||
var ok = EodPriceQueryService.TryGetSettlementEodPrice(stock.ValueDate, stock.UnderlyingCode, out var ep);
|
||||
var okOld = EodPriceQueryService.TryGetEodPrice(stock.ValueDate, stock.UnderlyingCode, out var epOld);
|
||||
Assert.AreEqual(okOld, ok, "非债券标的行为应与原 TryGetEodPrice 一致");
|
||||
if (ok)
|
||||
{
|
||||
Assert.IsNotNull(ep);
|
||||
Assert.AreEqual(epOld.ClosePrice, ep.ClosePrice, 1e-6, "非债券标的取到的收盘价应与原路径相同");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BondOptionExpiry_Regression_OldPathFailsNewPathSucceeds()
|
||||
{
|
||||
using var db = DbContextFactory.GetYLDbContext();
|
||||
var bond = (from b in db.china_bond_valuation
|
||||
join u in db.underlying_manager on b.bond_id equals u.UnderlyingCode
|
||||
where b.dirty_price_close > 0
|
||||
orderby b.valuation_date descending
|
||||
select new { b.bond_id, vd = b.valuation_date }).FirstOrDefault();
|
||||
if (bond == null) Assert.Inconclusive("测试库无债券估值数据,跳过");
|
||||
|
||||
// 旧路径:TryGetEodPrice 只 join 期货/股票两表,债券取不到价
|
||||
var oldOk = EodPriceQueryService.TryGetEodPrice(bond.vd, bond.bond_id, out _);
|
||||
// 新路径:债券感知统一取价,应能取到
|
||||
var newOk = EodPriceQueryService.TryGetSettlementEodPrice(bond.vd, bond.bond_id, out var ep);
|
||||
Assert.IsFalse(oldOk, "回归基线:旧路径对债券标的应取不到价(这正是期权到期报'结算价未找到'的根因)");
|
||||
Assert.IsTrue(newOk && ep != null && ep.ClosePrice > 0,
|
||||
"修复验证:统一取价应能为债券标的取到结算价,期权到期不再报'结算价未找到'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using YLErp;
|
||||
|
||||
namespace YLErp.Modules.EodModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 日终价格管理 —— 纯单元测试(不连库、秒级)。
|
||||
/// 锁定两处改动的意图:
|
||||
/// 问题4:列表"标的种类"按真实类型显示,且路由键 UnderlyingInstrumentType 不变;
|
||||
/// 问题3:债券(china_bond_valuation)数据来源按是否手工改过区分"人工"/"系统"。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class EodPriceDtoTest
|
||||
{
|
||||
#region 问题4:显示走真实类型,不再一律"商品期货";路由键保持不变
|
||||
|
||||
[TestMethod]
|
||||
[Description("有真实类型时,标的种类按真实类型显示,而非硬编码'商品期货'")]
|
||||
public void 有真实类型_按真实类型显示_而非商品期货()
|
||||
{
|
||||
// 模拟从 eod_commodity_future_price 出来、但真实是现券的一行
|
||||
var dto = new EodUnderlyingPriceDto
|
||||
{
|
||||
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, // 路由键(旧硬编码值)
|
||||
RealInstrumentType = ConsGlobal.InstrumentType.CreditBonds // 真实类型=信用债
|
||||
};
|
||||
|
||||
Assert.AreEqual("信用债", dto.UnderlyingInstrumentTypeCn, "显示应走真实类型");
|
||||
Assert.AreNotEqual("商品期货", dto.UnderlyingInstrumentTypeCn, "不应再一律显示商品期货");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("贵金属现货从商品期货表出来,也应显示真实种类")]
|
||||
public void 贵金属现货_显示黄金现货_而非商品期货()
|
||||
{
|
||||
var dto = new EodUnderlyingPriceDto
|
||||
{
|
||||
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures,
|
||||
RealInstrumentType = ConsGlobal.InstrumentType.GoldSpot
|
||||
};
|
||||
|
||||
Assert.AreEqual("黄金现货", dto.UnderlyingInstrumentTypeCn);
|
||||
Assert.AreNotEqual("商品期货", dto.UnderlyingInstrumentTypeCn);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("真正的商品期货,真实类型=CommodityFutures,仍显示商品期货")]
|
||||
public void 真商品期货_仍显示商品期货()
|
||||
{
|
||||
var dto = new EodUnderlyingPriceDto
|
||||
{
|
||||
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures,
|
||||
RealInstrumentType = ConsGlobal.InstrumentType.CommodityFutures
|
||||
};
|
||||
|
||||
Assert.AreEqual("商品期货", dto.UnderlyingInstrumentTypeCn);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("RealInstrumentType 为空时,回退到路由键,保证 null 安全不崩")]
|
||||
public void 真实类型为空_回退到路由键()
|
||||
{
|
||||
var dto = new EodUnderlyingPriceDto
|
||||
{
|
||||
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures,
|
||||
RealInstrumentType = null
|
||||
};
|
||||
|
||||
Assert.AreEqual("商品期货", dto.UnderlyingInstrumentTypeCn, "?? 回退应等于路由键的中文");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("路由键 UnderlyingInstrumentType 不受显示改动影响(保证'查看'不串表)")]
|
||||
public void 路由键不变_保证查看不串表()
|
||||
{
|
||||
var dto = new EodUnderlyingPriceDto
|
||||
{
|
||||
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures,
|
||||
RealInstrumentType = ConsGlobal.InstrumentType.TBonds
|
||||
};
|
||||
|
||||
// 显示变了,但路由键仍是 CommodityFutures → EodPriceView 仍会去 eod_commodity_future_price 取数
|
||||
Assert.AreEqual("利率债", dto.UnderlyingInstrumentTypeCn);
|
||||
Assert.AreEqual(ConsGlobal.InstrumentType.CommodityFutures, dto.UnderlyingInstrumentType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 问题3:债券数据来源按是否手工改过区分"人工"/"系统"
|
||||
|
||||
[TestMethod]
|
||||
[Description("债券来源:被手工改过(update_user 有值)→人工,中债自动同步(update_user 为 NULL)→系统")]
|
||||
public void 债券来源_手工改过为人工_否则为系统()
|
||||
{
|
||||
Assert.AreEqual(EodPriceBase.人工, EodPriceService.ResolveBondDisplaySource(1024L));
|
||||
Assert.AreEqual(EodPriceBase.系统, EodPriceService.ResolveBondDisplaySource(null));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("来源常量应为约定的中文'人工'/'系统'")]
|
||||
public void 来源常量取值正确()
|
||||
{
|
||||
Assert.AreEqual("系统", EodPriceBase.系统);
|
||||
Assert.AreEqual("人工", EodPriceBase.人工);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 债券手工编辑:操作人写入已有列(不新增字段)
|
||||
|
||||
[TestMethod]
|
||||
[Description("新增债券估值:create_user 与 update_user 都写入当前登录用户ID")]
|
||||
public void 新增债券估值_写入创建人与更新人()
|
||||
{
|
||||
var m = new ChinaBondValuation();
|
||||
EodPriceService.StampBondOperator(m, 1024, isNew: true);
|
||||
|
||||
Assert.AreEqual(1024L, m.create_user, "新增时应写创建人");
|
||||
Assert.AreEqual(1024L, m.update_user, "新增时应写更新人");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("更新已有债券估值:仅更新 update_user,保留原 create_user(不覆盖创建人)")]
|
||||
public void 更新债券估值_仅写更新人_保留创建人()
|
||||
{
|
||||
var m = new ChinaBondValuation { create_user = 7 };
|
||||
EodPriceService.StampBondOperator(m, 1024, isNew: false);
|
||||
|
||||
Assert.AreEqual(7L, m.create_user, "更新时不应覆盖原创建人");
|
||||
Assert.AreEqual(1024L, m.update_user, "更新人应为本次操作者");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("聚源/中债自动同步(外部ETL)不调用 StampBondOperator,故 create_user/update_user 保持 NULL = 自动同步")]
|
||||
public void 自动同步路径_操作人列为NULL()
|
||||
{
|
||||
// 注意:SettlementPriceImportService 是"手工上传"入口(会戳操作人),不是自动同步。
|
||||
// 真正的聚源/中债自动同步在外部 ETL(本仓库无代码),其写入不经 StampBondOperator。
|
||||
var m = new ChinaBondValuation(); // 模拟自动同步:仅写价格字段,不戳操作人
|
||||
Assert.IsNull(m.create_user);
|
||||
Assert.IsNull(m.update_user);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("手工上传(SettlementPriceImportService):新增行(id==0)应写 create_user+update_user,使来源列显示上传人")]
|
||||
public void 手工上传新增_写入创建人与更新人()
|
||||
{
|
||||
// 模拟上传债券新增分支:eodPrice.id 默认 0 → isNew=true
|
||||
var m = new ChinaBondValuation();
|
||||
EodPriceService.StampBondOperator(m, 2048, isNew: m.id == 0);
|
||||
|
||||
Assert.AreEqual(2048L, m.create_user, "上传新增应写创建人");
|
||||
Assert.AreEqual(2048L, m.update_user, "上传新增应写更新人");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("手工上传(SettlementPriceImportService):命中已有行(id!=0)只写 update_user,保留原 create_user")]
|
||||
public void 手工上传更新_仅写更新人_保留创建人()
|
||||
{
|
||||
// 模拟上传命中已有债券行:id!=0 → isNew=false
|
||||
var m = new ChinaBondValuation { id = 55, create_user = 9 };
|
||||
EodPriceService.StampBondOperator(m, 2048, isNew: m.id == 0);
|
||||
|
||||
Assert.AreEqual(9L, m.create_user, "上传更新不应覆盖原创建人");
|
||||
Assert.AreEqual(2048L, m.update_user, "上传更新应写本次上传人");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 问题3补充:债券来源人工/系统(判定逻辑已并入上方"问题3"区域)
|
||||
|
||||
#endregion
|
||||
|
||||
#region 日期窗口解析("页面始终5条"根因锁定,纯单测不连库)
|
||||
|
||||
[TestMethod]
|
||||
[Description("前端未传日期(年份<=2000) → 回退到 [今天-1年, 今天+1年)")]
|
||||
public void 未传日期_回退最近一年到明年()
|
||||
{
|
||||
var (start, end) = EodPriceService.ResolveValueDateWindow(DateTime.MinValue, DateTime.MinValue);
|
||||
Assert.AreEqual(DateTime.Today.AddYears(-1).Date, start.Date, "起始应回退到今天-1年");
|
||||
Assert.AreEqual(DateTime.Today.AddYears(1).Date, end.Date, "结束应回退到今天+1年");
|
||||
Assert.IsTrue(end > start, "窗口应正向");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("列表页默认起止都填今天 → 窗口=[今天, 今天+1天),仅返回当天记录(即'5条'现象成因)")]
|
||||
public void 起止都填今天_窗口仅今天()
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var (start, end) = EodPriceService.ResolveValueDateWindow(today, today);
|
||||
Assert.AreEqual(today.Date, start.Date, "起始应为今天");
|
||||
Assert.AreEqual(today.AddDays(1).Date, end.Date, "结束应为今天+1天(半开区间含今天)");
|
||||
Assert.IsTrue(today >= start && today < end, "今天的记录应落入窗口");
|
||||
Assert.IsFalse(today.AddDays(-1) >= start && today.AddDays(-1) < end, "昨天的记录不应落入仅今天窗口");
|
||||
Assert.IsFalse(today.AddDays(1) >= start && today.AddDays(1) < end, "明天的记录不应落入仅今天窗口");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("显式传区间(如近30天) → 原样生效,不被回退覆盖")]
|
||||
public void 显式区间_原样生效()
|
||||
{
|
||||
var start0 = DateTime.Today.AddDays(-30);
|
||||
var end0 = DateTime.Today;
|
||||
var (start, end) = EodPriceService.ResolveValueDateWindow(start0, end0);
|
||||
Assert.AreEqual(start0.Date, start.Date, "起始应等于传入");
|
||||
Assert.AreEqual(end0.AddDays(1).Date, end.Date, "结束应等于传入+1天");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Description("结束日=今天 → 半开区间上界=今天+1天,今天当天记录可命中")]
|
||||
public void 结束日今天_上界为明天_当天可命中()
|
||||
{
|
||||
var (start, end) = EodPriceService.ResolveValueDateWindow(DateTime.Today.AddDays(-365), DateTime.Today);
|
||||
var today = DateTime.Today;
|
||||
Assert.IsTrue(today >= start && today < end, "今天记录应命中");
|
||||
Assert.IsFalse(today.AddDays(1) >= start && today.AddDays(1) < end, "明天记录不应命中");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp;
|
||||
|
||||
namespace YLErp.Modules.EodModule
|
||||
{
|
||||
#region Golden 数据模型
|
||||
|
||||
/// <summary>
|
||||
/// 日终价格"标的种类 + 数据来源"golden 场景模型。
|
||||
/// 每个 JSON 文件存:一组原始输入行 + 每行的期望输出(种类中文/来源/路由键)。
|
||||
/// 结构与 SwapModule 的 GoldenScenarioModel 对齐(Scenario/Description/Source + Rows)。
|
||||
/// </summary>
|
||||
public class EodPriceGoldenModel
|
||||
{
|
||||
public string Scenario { get; set; }
|
||||
public string Description { get; set; }
|
||||
/// <summary>synthetic(合成 Mock) / recorded(真实库录制)</summary>
|
||||
public string Source { get; set; } = "synthetic";
|
||||
public DateTime? RecordedAt { get; set; }
|
||||
public List<EodPriceGoldenRow> Rows { get; set; } = new();
|
||||
}
|
||||
|
||||
public class EodPriceGoldenRow
|
||||
{
|
||||
public string UnderlyingCode { get; set; }
|
||||
|
||||
/// <summary>存储表路由键 = DTO.UnderlyingInstrumentType(EodPriceView 靠它选表)</summary>
|
||||
public string RouteKey { get; set; }
|
||||
|
||||
/// <summary>真实标的种类 = underlying_manager.UnderlyingInstrumentType</summary>
|
||||
public string RealInstrumentType { get; set; }
|
||||
|
||||
public bool IsBond { get; set; }
|
||||
|
||||
/// <summary>期望的"标的种类"列显示值</summary>
|
||||
public string ExpectedTypeCn { get; set; }
|
||||
|
||||
/// <summary>期望的"数据来源"(仅债券行断言)</summary>
|
||||
public string ExpectedDataSource { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 日终价格 Golden 回放测试
|
||||
/// ============================================================================
|
||||
/// 仿 SwapModule/DealInterestsGoldenReplayTest:
|
||||
/// - Record_* :连真实库拉数据生成 golden JSON(标 [Ignore],手动跑)
|
||||
/// - Replay_* :读 Mock/录制 JSON 重放并逐行断言(进 CI,不碰库)
|
||||
///
|
||||
/// 守护点(回放时任何一行不符即失败):
|
||||
/// 1. 标的种类按真实类型显示(现券→信用债、贵金属→黄金现货…),不再一律"商品期货";
|
||||
/// 2. 路由键 UnderlyingInstrumentType 保持不变(保证"查看"不串表);
|
||||
/// 3. 债券数据来源固定为中债估值(聚源仅转发,无人手工维护,不随 JSID 变化)。
|
||||
/// ============================================================================
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class EodPriceGoldenReplayTest
|
||||
{
|
||||
private static readonly string GoldenDir = Path.Combine(
|
||||
AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "EodPriceGolden");
|
||||
|
||||
#region 回放:读 golden 重放 + 逐行断言(进 CI)
|
||||
|
||||
[TestMethod]
|
||||
public void Replay_AllGoldenFiles()
|
||||
{
|
||||
if (!Directory.Exists(GoldenDir))
|
||||
{
|
||||
Assert.Inconclusive($"golden 目录不存在: {GoldenDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(GoldenDir, "*.json").OrderBy(f => f).ToArray();
|
||||
Assert.IsTrue(files.Length > 0, "应至少有 1 个 golden 文件");
|
||||
|
||||
int rowsChecked = 0;
|
||||
foreach (var file in files)
|
||||
{
|
||||
var golden = JsonConvert.DeserializeObject<EodPriceGoldenModel>(File.ReadAllText(file));
|
||||
Console.WriteLine($"\n回放: {Path.GetFileName(file)} - {golden.Scenario} [{golden.Source}]");
|
||||
|
||||
foreach (var row in golden.Rows)
|
||||
{
|
||||
// 用原始输入重建 DTO(等价于 SearchUnderlyingList 的投影结果)
|
||||
var dto = new EodUnderlyingPriceDto
|
||||
{
|
||||
UnderlyingCode = row.UnderlyingCode,
|
||||
UnderlyingInstrumentType = row.RouteKey, // 路由键
|
||||
RealInstrumentType = row.RealInstrumentType, // 真实类型
|
||||
IsBond = row.IsBond
|
||||
};
|
||||
// 债券来源:自动同步(中债)→系统(等价 SearchUnderlyingList 后处理赋值;synthetic 无 UpdateUser 故为系统)
|
||||
if (dto.IsBond)
|
||||
{
|
||||
dto.DataSource = EodPriceBase.系统;
|
||||
}
|
||||
|
||||
// 守护点1:显示按真实类型
|
||||
Assert.AreEqual(row.ExpectedTypeCn, dto.UnderlyingInstrumentTypeCn,
|
||||
$"[{row.UnderlyingCode}] 标的种类显示不符");
|
||||
|
||||
// 守护点2:路由键不变
|
||||
Assert.AreEqual(row.RouteKey, dto.UnderlyingInstrumentType,
|
||||
$"[{row.UnderlyingCode}] 路由键被改动,会导致查看串表");
|
||||
|
||||
// 守护点3:债券来源
|
||||
if (row.IsBond)
|
||||
{
|
||||
Assert.AreEqual(row.ExpectedDataSource, dto.DataSource,
|
||||
$"[{row.UnderlyingCode}] 债券数据来源判定不符");
|
||||
}
|
||||
|
||||
rowsChecked++;
|
||||
Console.WriteLine($" ✅ {row.UnderlyingCode}: {dto.UnderlyingInstrumentTypeCn}" +
|
||||
(row.IsBond ? $" / {dto.DataSource}" : ""));
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"\n回放完成,共校验 {rowsChecked} 行");
|
||||
Assert.IsTrue(rowsChecked > 0, "至少应校验 1 行");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 录制:连真实库拉数据生成 golden(标 [Ignore],手动跑)
|
||||
|
||||
/// <summary>
|
||||
/// 从真实库拉一批 underlying_manager + china_bond_valuation,
|
||||
/// 按当前生产逻辑生成 recorded golden JSON。
|
||||
/// 手动取消 [Ignore] 运行;生成后复制到 Resources/GoldenFiles/EodPriceGolden/ 持久化。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[Ignore]
|
||||
[TestCategory("GoldenRecord")]
|
||||
public void Record_FromRealDb()
|
||||
{
|
||||
Directory.CreateDirectory(GoldenDir);
|
||||
|
||||
var golden = new EodPriceGoldenModel
|
||||
{
|
||||
Scenario = "标的种类与来源(真实库录制)",
|
||||
Description = "从 underlying_manager/china_bond_valuation 采样,快照当前生产映射",
|
||||
Source = "recorded",
|
||||
RecordedAt = DateTime.Now
|
||||
};
|
||||
|
||||
using (var db = DbContextFactory.GetYLDbContext())
|
||||
{
|
||||
// 采样若干上线标的(含真实类型)
|
||||
var uns = db.underlying_manager
|
||||
.Where(x => x.LaunchState == "1")
|
||||
.Select(x => new { x.UnderlyingCode, x.UnderlyingInstrumentType })
|
||||
.Take(30).ToList();
|
||||
|
||||
// 债券估值采样(来源:自动同步→系统,手工改过→人工)
|
||||
var bonds = db.china_bond_valuation
|
||||
.Select(b => new { b.bond_id })
|
||||
.Take(200).ToList();
|
||||
var bondCodes = new HashSet<string>(bonds.Select(b => b.bond_id));
|
||||
|
||||
foreach (var un in uns)
|
||||
{
|
||||
bool isBond = bondCodes.Contains(un.UnderlyingCode);
|
||||
|
||||
// 路由键:债券走真实类型,其余按来源表默认(这里录制以真实类型近似,
|
||||
// 因为 recorded 主要用于快照真实分布;CI 用 synthetic 覆盖精确路由)。
|
||||
string routeKey = isBond
|
||||
? un.UnderlyingInstrumentType
|
||||
: ConsGlobal.InstrumentType.CommodityFutures;
|
||||
|
||||
golden.Rows.Add(new EodPriceGoldenRow
|
||||
{
|
||||
UnderlyingCode = un.UnderlyingCode,
|
||||
RouteKey = routeKey,
|
||||
RealInstrumentType = un.UnderlyingInstrumentType,
|
||||
IsBond = isBond,
|
||||
ExpectedTypeCn = ConsGlobal.InstrumentType.GetDesc(un.UnderlyingInstrumentType),
|
||||
ExpectedDataSource = isBond ? EodPriceBase.系统 : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var path = Path.Combine(GoldenDir, "golden_标的种类与来源_recorded.json");
|
||||
File.WriteAllText(path, JsonConvert.SerializeObject(golden, Formatting.Indented));
|
||||
Console.WriteLine($"✅ 录制 {golden.Rows.Count} 行 -> {path}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 回归:新增日终价格可见性(连真实库,标 [Ignore] 手动跑)
|
||||
|
||||
/// <summary>
|
||||
/// 回归"新增日终价格后是否查得出",直接跑生产查询 SearchUnderlyingList。
|
||||
/// 守护点(与之前"新增后查不出"的修复一一对应):
|
||||
/// (a) 今天 + 已上市(LaunchState=1) 标的 → 查得出;
|
||||
/// (b) 估值日期=0001(未填) → 落在列表默认"仅今天"窗口外 → 查不出;
|
||||
/// (c) 标的未上市(LaunchState!=1) → 被 inner join(underlying_manager.LaunchState=="1") 过滤 → 查不出。
|
||||
/// 复用库中已有标的(不新建 underlying_manager,避免触碰该表约束),只插入/清理临时债券估值行。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[Ignore]
|
||||
[TestCategory("EodVisibility")]
|
||||
[Description("新增日终价格可见性:(a)今天+已上市可查 (b)日期0001查不出 (c)未上市查不出")]
|
||||
public void Record_NewRecordVisibility()
|
||||
{
|
||||
using (var db = DbContextFactory.GetYLDbContext())
|
||||
{
|
||||
var svc = new EodPriceService(OptUserInfo.SystemUser);
|
||||
var today = DateTime.Today;
|
||||
var req = new EodCommodityFuturePriceReq { ValueDateStart = today, ValueDateEnd = today };
|
||||
|
||||
// 取一个已上市的债券类标的(正向用例);退而求其次取任意已上市标的
|
||||
var listedBond = db.underlying_manager
|
||||
.FirstOrDefault(x => x.LaunchState == "1" && x.UnderlyingInstrumentType == ConsGlobal.InstrumentType.CreditBonds)
|
||||
?? db.underlying_manager.FirstOrDefault(x => x.LaunchState == "1");
|
||||
Assert.IsNotNull(listedBond, "需存在一个 LaunchState=1 的标的用于正向回归");
|
||||
|
||||
// 取一个未上市的标的(负向用例)
|
||||
var unlisted = db.underlying_manager.FirstOrDefault(x => x.LaunchState != "1");
|
||||
Assert.IsNotNull(unlisted, "需存在一个 LaunchState!=1 的标的用于负向回归");
|
||||
|
||||
var insertedIds = new List<long>();
|
||||
try
|
||||
{
|
||||
// (a) 今天 + 已上市 → 查得出
|
||||
var a = new ChinaBondValuation { bond_id = listedBond.UnderlyingCode, valuation_date = today, dirty_price_close = 100, net_price = 100, yield = 3 };
|
||||
db.china_bond_valuation.Add(a);
|
||||
db.SaveChanges();
|
||||
insertedIds.Add(a.id);
|
||||
var rA = svc.SearchUnderlyingList(req);
|
||||
Assert.IsTrue(rA.rows.Any(x => x.id == a.id), "(a) 今天+已上市债券应查得出");
|
||||
|
||||
// (b) 日期=0001(未填) → 落在仅今天窗口外,查不出
|
||||
var b = new ChinaBondValuation { bond_id = listedBond.UnderlyingCode, valuation_date = DateTime.MinValue, dirty_price_close = 100, net_price = 100, yield = 3 };
|
||||
db.china_bond_valuation.Add(b);
|
||||
db.SaveChanges();
|
||||
insertedIds.Add(b.id);
|
||||
var rB = svc.SearchUnderlyingList(req);
|
||||
Assert.IsFalse(rB.rows.Any(x => x.id == b.id), "(b) 日期0001 应查不出");
|
||||
|
||||
// (c) 未上市标的 → 被 inner join 过滤,查不出
|
||||
var c = new ChinaBondValuation { bond_id = unlisted.UnderlyingCode, valuation_date = today, dirty_price_close = 100, net_price = 100, yield = 3 };
|
||||
db.china_bond_valuation.Add(c);
|
||||
db.SaveChanges();
|
||||
insertedIds.Add(c.id);
|
||||
var rC = svc.SearchUnderlyingList(req);
|
||||
Assert.IsFalse(rC.rows.Any(x => x.id == c.id), "(c) 未上市标的应查不出");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var id in insertedIds)
|
||||
{
|
||||
var e = db.china_bond_valuation.Find(id);
|
||||
if (e != null) db.china_bond_valuation.Remove(e);
|
||||
}
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YLErp.DBModels;
|
||||
|
||||
namespace YLErp.Modules.EodModule
|
||||
{
|
||||
/// <summary>
|
||||
/// FR007 错行根因的校正决策单测(GLMS-20260701)。
|
||||
/// 对应最近提交的 bugfix:eod_commodity_future_price 入库前以 UnderlyingCode(=FutureContractId) 为准
|
||||
/// 重派生 UnderlyingId,防止 UnderlyingId 与 FutureContractId 失同步导致"网页能查到、EOD 结算查不到"。
|
||||
/// 这里只测纯函数 ResolveUnderlyingIdForCode,不依赖数据库。
|
||||
///
|
||||
/// 生产事故还原:FR007 价格行的 FutureContractId='FR007',但 UnderlyingId 被错写成
|
||||
/// 511160.SH 的 2173889 / 159111.SZ 的 2173890,正确应为 FR007 的 2170838。
|
||||
/// 网页端按 UnderlyingId(int) JOIN underlying_manager 把 FR007 行误挂到 511160.SH;
|
||||
/// 而 EOD 结算按 UnderlyingCode(string) JOIN 查不到,报"结算价格缺失"。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class EodPriceUnderlyingIdGuardTest
|
||||
{
|
||||
private const int Fr007CorrectId = 2170838;
|
||||
private const int Id511160 = 2173889; // 511160.SH 的 id(被错写)
|
||||
private const int Id159111 = 2173890; // 159111.SZ 的 id(被错写)
|
||||
|
||||
[TestMethod]
|
||||
public void 空UnderlyingCode_维持原值()
|
||||
{
|
||||
Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode("", 123, null));
|
||||
Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode(null, 123, 999));
|
||||
Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode(" ", 123, 999));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 标的不存在_维持原值()
|
||||
{
|
||||
// resolvedId=null 表示 underlying_manager 无此代码,无法校正
|
||||
Assert.AreEqual(Id511160,
|
||||
EodPriceService.ResolveUnderlyingIdForCode("FR007", Id511160, null));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 已一致_维持原值()
|
||||
{
|
||||
Assert.AreEqual(Fr007CorrectId,
|
||||
EodPriceService.ResolveUnderlyingIdForCode("FR007", Fr007CorrectId, Fr007CorrectId));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 不一致_校正为正确id_FR007生产错行_511160()
|
||||
{
|
||||
// 生产事故:FR007 行 UnderlyingId=2173889(511160.SH) → 应校正为 2170838
|
||||
Assert.AreEqual(Fr007CorrectId,
|
||||
EodPriceService.ResolveUnderlyingIdForCode("FR007", Id511160, Fr007CorrectId));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 不一致_校正为正确id_FR007生产错行_159111()
|
||||
{
|
||||
// 生产事故:另两条错行 UnderlyingId=2173890(159111.SZ) → 应校正为 2170838
|
||||
Assert.AreEqual(Fr007CorrectId,
|
||||
EodPriceService.ResolveUnderlyingIdForCode("FR007", Id159111, Fr007CorrectId));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 行对象_端到端校正_FR007()
|
||||
{
|
||||
var row = new eod_commodity_future_price
|
||||
{
|
||||
UnderlyingCode = "FR007",
|
||||
UnderlyingId = Id511160
|
||||
};
|
||||
// 模拟 db.underlying_manager 解析到的正确 id
|
||||
int resolved = Fr007CorrectId;
|
||||
int? before = row.UnderlyingId;
|
||||
row.UnderlyingId = EodPriceService.ResolveUnderlyingIdForCode(row.UnderlyingCode, row.UnderlyingId ?? 0, resolved);
|
||||
|
||||
Assert.AreNotEqual(before, row.UnderlyingId);
|
||||
Assert.AreEqual((int?)Fr007CorrectId, row.UnderlyingId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 典型非错配_不同标的_各自正确不互相覆盖()
|
||||
{
|
||||
// 511160.SH 自己的行(FutureContractId='511160.SH'),UnderlyingId 已是 2173889 → 不动
|
||||
Assert.AreEqual(Id511160,
|
||||
EodPriceService.ResolveUnderlyingIdForCode("511160.SH", Id511160, Id511160));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// GLMS-20260701-0006 多次部分平仓后提前终止 Tab 序号2 平仓比例显示 32.50% 而非 50% 的回归测试
|
||||
/// ================================================================================
|
||||
/// 根因:SwapDealService.ApplySwapTrade 缺少 A→B 口径转换。
|
||||
/// - SwapUnwind 在入口处将 ClosePercent 从口径A(占期初) 转为 口径B(占剩余),SaveSwapDealInternal 落库时 B→A 还原。
|
||||
/// - ApplySwapTrade 没有做 A→B 转换,导致 SaveSwapDealInternal 的 B→A 还原出错:
|
||||
/// 0.50(A) → ToOriginalClosePercent(0.50, 50000000, 32500000) = 0.50*32500000/50000000 = 0.325 ❌
|
||||
/// - 修复后:0.50(A) → ToRemainingClosePercent → 0.769(B) → ToOriginalClosePercent → 0.50(A) ✅
|
||||
///
|
||||
/// 测试策略:
|
||||
/// 1) 纯函数测试:验证 A→B→A 往返转换的正确性
|
||||
/// 2) ApplySwapTrade 集成测试:验证 SaveSwapDeal 收到的 ClosePercent 已转为口径B
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class ApplySwapTradeClosePercentBugTest
|
||||
{
|
||||
// GLMS-20260701-0006 真实数据
|
||||
private const decimal OriginalNotional = 50_000_000m; // 期初名义本金
|
||||
private const decimal RemainingAfter1st = 32_500_000m; // 首次平35%后剩余
|
||||
private const decimal FirstClosePercent = 0.35m; // 第一次平仓比例(口径A)
|
||||
private const decimal SecondClosePercent = 0.50m; // 第二次平仓比例(口径A, 用户输入50%)
|
||||
|
||||
// ================================================================
|
||||
// 1) 纯函数:A→B→A 往返转换应还原原值
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void AC_001_口径转换_往返A到B到A应还原原值()
|
||||
{
|
||||
// 第二次部分平仓: 用户输入 50%(口径A)
|
||||
decimal closePercentA = SecondClosePercent;
|
||||
|
||||
// A → B(ApplySwapTrade/SwapUnwind 入口转换)
|
||||
decimal closePercentB = SwapDealService.ToRemainingClosePercent(
|
||||
closePercentA, OriginalNotional, RemainingAfter1st);
|
||||
|
||||
// B → A(SaveSwapDealInternal 落库还原)
|
||||
decimal closePercentA_restored = SwapDealService.ToOriginalClosePercent(
|
||||
closePercentB, OriginalNotional, RemainingAfter1st);
|
||||
|
||||
SwapDealTestFactory.AssertDecimalEqual(closePercentA, closePercentA_restored, 1e-10m,
|
||||
"A→B→A 往返转换应还原原值");
|
||||
Console.WriteLine($"A={closePercentA}, B={closePercentB}, A_restored={closePercentA_restored}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AC_002_口径转换_未修复时B到A会得到错误的0_325()
|
||||
{
|
||||
// 模拟 bug:ApplySwapTrade 未做 A→B 转换,直接把 A 传给 SaveSwapDealInternal 的 B→A 还原
|
||||
decimal closePercentA = SecondClosePercent; // 0.50
|
||||
|
||||
// bug 路径:SaveSwapDealInternal 误把 A 当 B 做还原
|
||||
decimal buggyResult = SwapDealService.ToOriginalClosePercent(
|
||||
closePercentA, OriginalNotional, RemainingAfter1st);
|
||||
|
||||
// 0.50 * 32500000 / 50000000 = 0.325
|
||||
SwapDealTestFactory.AssertDecimalEqual(0.325m, buggyResult, 1e-10m,
|
||||
"bug 路径:0.50(A) 被误当 B 做还原 → 0.325");
|
||||
Assert.AreNotEqual(SecondClosePercent, buggyResult,
|
||||
"bug 结果 0.325 不等于用户输入 0.50");
|
||||
Console.WriteLine($"Bug: 0.50(A) 误当 B → ToOriginalClosePercent → {buggyResult} (应为 0.50)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 2) ApplySwapTrade 集成测试:验证 SaveSwapDeal 收到的是口径B
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void AC_003_ApplySwapTrade_第二次部分平仓50perc_应将ClosePercent转为口径B()
|
||||
{
|
||||
// 模拟 GLMS-20260701-0006 第二次部分平仓的场景
|
||||
var td = new trade
|
||||
{
|
||||
id = 1991,
|
||||
TradeNumber = "GLMS-20260701-0006",
|
||||
TradeType = "收益互换",
|
||||
TradeStatus = "确认成交",
|
||||
ValidState = "Valid",
|
||||
StockEqvNotional = (double)RemainingAfter1st, // 32500000
|
||||
OriginalStockEqvNotional = (double)OriginalNotional, // 50000000
|
||||
Notional = (double)RemainingAfter1st,
|
||||
TradeAmount = (double)RemainingAfter1st
|
||||
};
|
||||
|
||||
var service = new TestableSwapDealService(td);
|
||||
|
||||
// 前端传入的 UnwindData(ClosePercent = 0.50, 口径A)
|
||||
var unwindData = new UnwindData
|
||||
{
|
||||
SwapTradeId = td.id,
|
||||
SwapRealizedPnL = 1000m,
|
||||
SwapCloseAmount = 1000m,
|
||||
CloseMethod = (int)CloseMethodEnum.部分平仓,
|
||||
ClosePercent = SecondClosePercent, // 0.50 (口径A, 用户输入50%)
|
||||
CloseQty = 25000000m,
|
||||
CloseNotionalValue = 25000000m, // 50% of original
|
||||
PositionQty = RemainingAfter1st, // 32500000
|
||||
NotionalValue = OriginalNotional, // 50000000 (期初)
|
||||
PosiNotionalValue = RemainingAfter1st, // 32500000 (剩余)
|
||||
ValueDate = new DateTime(2026, 7, 14),
|
||||
UnwindDate = new DateTime(2026, 7, 15),
|
||||
StartDate = new DateTime(2026, 7, 1)
|
||||
};
|
||||
|
||||
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓);
|
||||
|
||||
// 验证 SaveSwapDeal 被调用
|
||||
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "ApplySwapTrade 应调用 SaveSwapDeal");
|
||||
|
||||
// 验证传给 SaveSwapDeal 的 ClosePercent 已转为口径B
|
||||
var savedData = service.SaveSwapDealCalls[0].data;
|
||||
decimal expectedB = SwapDealService.ToRemainingClosePercent(
|
||||
SecondClosePercent, OriginalNotional, RemainingAfter1st);
|
||||
|
||||
SwapDealTestFactory.AssertDecimalEqual(expectedB, savedData.ClosePercent, 1e-10m,
|
||||
"ApplySwapTrade 应将 ClosePercent 从口径A转为口径B");
|
||||
|
||||
// 关键验证:B 值不应等于 A 值(0.50),也不应等于 bug 值(0.325)
|
||||
Assert.AreNotEqual(SecondClosePercent, savedData.ClosePercent,
|
||||
"口径B 不应等于口径A (0.50)");
|
||||
Assert.AreNotEqual(0.325m, savedData.ClosePercent,
|
||||
"口径B 不应等于 bug 值 (0.325)");
|
||||
|
||||
Console.WriteLine($"输入: ClosePercent(A)={SecondClosePercent}");
|
||||
Console.WriteLine($"输出: ClosePercent(B)={savedData.ClosePercent}");
|
||||
Console.WriteLine($"期望: ClosePercent(B)={expectedB}");
|
||||
Console.WriteLine($"往返还原: ClosePercent(A)={SwapDealService.ToOriginalClosePercent(savedData.ClosePercent, OriginalNotional, RemainingAfter1st)}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AC_004_ApplySwapTrade_第一次平仓35perc_口径转换正确()
|
||||
{
|
||||
// 第一次平仓:remaining = original, 所以 A = B = 0.35
|
||||
var td = new trade
|
||||
{
|
||||
id = 1991,
|
||||
TradeNumber = "GLMS-20260701-0006",
|
||||
TradeType = "收益互换",
|
||||
TradeStatus = "确认成交",
|
||||
ValidState = "Valid",
|
||||
StockEqvNotional = (double)OriginalNotional,
|
||||
OriginalStockEqvNotional = (double)OriginalNotional,
|
||||
Notional = (double)OriginalNotional,
|
||||
TradeAmount = (double)OriginalNotional
|
||||
};
|
||||
|
||||
var service = new TestableSwapDealService(td);
|
||||
|
||||
var unwindData = new UnwindData
|
||||
{
|
||||
SwapTradeId = td.id,
|
||||
SwapRealizedPnL = 1000m,
|
||||
SwapCloseAmount = 1000m,
|
||||
CloseMethod = (int)CloseMethodEnum.部分平仓,
|
||||
ClosePercent = FirstClosePercent, // 0.35 (口径A)
|
||||
CloseQty = 17500000m,
|
||||
CloseNotionalValue = 17500000m,
|
||||
PositionQty = OriginalNotional,
|
||||
NotionalValue = OriginalNotional,
|
||||
PosiNotionalValue = OriginalNotional, // 首次平仓 remaining == original
|
||||
ValueDate = new DateTime(2026, 7, 6),
|
||||
UnwindDate = new DateTime(2026, 7, 7),
|
||||
StartDate = new DateTime(2026, 7, 1)
|
||||
};
|
||||
|
||||
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓);
|
||||
|
||||
var savedData = service.SaveSwapDealCalls[0].data;
|
||||
// 首次平仓 remaining == original → A == B == 0.35
|
||||
SwapDealTestFactory.AssertDecimalEqual(FirstClosePercent, savedData.ClosePercent, 1e-10m,
|
||||
"首次平仓 remaining==original → 口径A==口径B==0.35");
|
||||
Console.WriteLine($"首次平仓: ClosePercent(A=B)={savedData.ClosePercent}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,6 +378,39 @@ namespace YLErp.Modules.SwapModule
|
||||
Console.WriteLine($"分红增值税调整: 付息100, 税率6% → TdPosiDividend={result.TdPosiDividend}(期望{expected})✅");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DF_008_CopyBranch_RealizedPnlIncludesRealizedFee()
|
||||
{
|
||||
var service = new StubEodService
|
||||
{
|
||||
UnderlyingPrice = 1.002m,
|
||||
TaxRate = 0m,
|
||||
BondPayment = 0m
|
||||
};
|
||||
var preEod = new eod_swap_position
|
||||
{
|
||||
id = 5001,
|
||||
SwapTradeId = SwapTradeId,
|
||||
PositionId = 3001,
|
||||
ValueDate = PreSettleDate,
|
||||
PosiQuantity = 10000m,
|
||||
PosiGrossPrice = 1.002m,
|
||||
PosiNetPrice = 1.005m,
|
||||
UnderlyingCode = "210210.IB",
|
||||
ContractSize = 1m,
|
||||
PositionType = (int)PositionTypeFlag.Long,
|
||||
PosiDirection = (int)SwapDirectionEnum.收取,
|
||||
RealizedMtmPnL = 98000000m,
|
||||
RealizedDividend = 0m,
|
||||
RealizedFee = 100m,
|
||||
RealizedPnl = 98000000m
|
||||
};
|
||||
|
||||
var result = service.ExecuteCopyEodPosition(preEod, null, CreateTrade(), TradeDate, PreSettleDate);
|
||||
|
||||
Assert.AreEqual(98000100m, result.RealizedPnl);
|
||||
}
|
||||
|
||||
private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "")
|
||||
{
|
||||
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
|
||||
|
||||
@@ -156,9 +156,9 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
/// <summary>
|
||||
/// [FC_006] 结息-债券多头-全量结算(基线)
|
||||
/// income 用 CloseNotionalValue 而非 CloseQty,无 longRatio
|
||||
/// EntryPrice=1.02, TradingAmountAvg=105(×100形态), CloseNotionalValue=10000
|
||||
/// MarkClosePnl = 10000×(105×0.01−1.02)×1 = 10000×0.03 = 300
|
||||
/// income 使用持仓数量和合约乘数,无 longRatio
|
||||
/// EntryPrice=1.02, TradingAmountAvg=105(×100形态), PositionQty=10000, ContractSize=1
|
||||
/// MarkClosePnl = 10000×1×(105×0.01−1.02)×1 = 10000×0.03 = 300
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void FC_006_结息_债券多头_全量结算()
|
||||
@@ -166,7 +166,9 @@ namespace YLErp.Modules.SwapModule
|
||||
var input = new UnwindInput
|
||||
{
|
||||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m,
|
||||
CloseNotionalValue = 10000, // income 用名义本金
|
||||
PositionQty = 10000,
|
||||
ContractSize = 1,
|
||||
CloseNotionalValue = 10200, // 与数量刻意不同,守卫 income 不再误用名义本金
|
||||
CloseQty = 0, // income 不用数量
|
||||
PayDirection = 1, PositionType = 1,
|
||||
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
|
||||
@@ -188,7 +190,8 @@ namespace YLErp.Modules.SwapModule
|
||||
var input = new UnwindInput
|
||||
{
|
||||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 110m,
|
||||
CloseNotionalValue = 10000, CloseQty = 0,
|
||||
PositionQty = 10000, ContractSize = 1,
|
||||
CloseNotionalValue = 10200, CloseQty = 0,
|
||||
PayDirection = 1, PositionType = 1,
|
||||
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
|
||||
};
|
||||
@@ -209,7 +212,8 @@ namespace YLErp.Modules.SwapModule
|
||||
var input = new UnwindInput
|
||||
{
|
||||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m,
|
||||
CloseNotionalValue = 10000, CloseQty = 0,
|
||||
PositionQty = 10000, ContractSize = 1,
|
||||
CloseNotionalValue = 10200, CloseQty = 0,
|
||||
PayDirection = 1, PositionType = 1,
|
||||
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
|
||||
};
|
||||
@@ -225,6 +229,35 @@ namespace YLErp.Modules.SwapModule
|
||||
Console.WriteLine($"FC_008: SwapRealizedPnL={result.SwapRealizedPnL}, SwapMarginRebatePnl={result.SwapMarginRebatePnl} ✅");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [FC_009] 结息-债券支付端:价差盈亏必须按数量计算,不能按期初名义本金计算。
|
||||
/// 纯价差 = 30000000×1×(80%−98%)×(−1) = 5400000;加分红-45000后合计5355000。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void FC_009_结息_债券价差按数量计算()
|
||||
{
|
||||
var input = new UnwindInput
|
||||
{
|
||||
Multiplier = 100,
|
||||
PosiGrossPrice = 0.98m,
|
||||
TradingAmountAvg = 80m,
|
||||
PositionQty = 30000000m,
|
||||
ContractSize = 1m,
|
||||
CloseNotionalValue = 29400000m,
|
||||
CloseQty = 0m,
|
||||
PayDirection = 2,
|
||||
PositionType = 1,
|
||||
TradingFee = "0",
|
||||
TradingFeePending = "0",
|
||||
DividendIn = "-45000"
|
||||
};
|
||||
|
||||
var result = FrontendCalcReference.CalcIncome(input);
|
||||
|
||||
AssertDecimalEqual(5400000m, result.MarkClosePnl, 0.01m, "income MarkClosePnl按数量计算");
|
||||
AssertDecimalEqual(5355000m, result.FloatPnlSum, 0.01m, "income FloatPnlSum包含分红");
|
||||
}
|
||||
|
||||
private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "")
|
||||
{
|
||||
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 线上事故诊断:GLMS-20260701-0008 多次部分平仓后,预付金返还显示仍为原始值
|
||||
/// ============================================================================
|
||||
/// 直连测试库,录制真实数据快照并定位根因(DB 端还是计算端)。
|
||||
/// 测试结构:
|
||||
/// 1) RecordSnapshot - 录 trade/position/eod_swap_position/eod_swap/flow_event
|
||||
/// 2) Diagnose - 把每次部分平仓前后 InterestPrincipalFix 实际值序列打印,
|
||||
/// 验证是否双重扣减;并调用 GetUnwindInterests 1.0 看后端返还值
|
||||
/// 3) 期望对比 - 多次部分平仓后,1.0 closePercent 应返"剩余本金"(=已扣减后),
|
||||
/// 若仍返原始值 ⇒ 后端 EOD 路径 bug (SaveAutoEodWithCloseInterestPosition 双重扣减)
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class GLMS20260701DbDiagnoseTest
|
||||
{
|
||||
private const string TradeNumber_0008 = "GLMS-20260701-0008";
|
||||
private const string TradeNumber_0013 = "GLMS-20260701-0013";
|
||||
|
||||
#region 1) 录真实数据快照(手动跑)
|
||||
|
||||
[TestMethod]
|
||||
[Ignore]
|
||||
[TestCategory("DbDiagnose")]
|
||||
public void Record_RealSnapshot()
|
||||
{
|
||||
YLContext db;
|
||||
try { db = DbContextFactory.GetYLDbContext(); }
|
||||
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
|
||||
|
||||
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber_0008);
|
||||
Assert.IsNotNull(td, $"测试库无交易 {TradeNumber_0008},请确认环境");
|
||||
|
||||
var snapshot = new JObject
|
||||
{
|
||||
["TradeNumber"] = td.TradeNumber,
|
||||
["TradeId"] = td.id,
|
||||
["StockEqvNotional"] = td.StockEqvNotional,
|
||||
["Notional"] = td.Notional,
|
||||
["OriginalStockEqvNotional"] = td.OriginalStockEqvNotional,
|
||||
["TradeDate"] = td.TradeDate,
|
||||
["StartDate"] = td.StartDate,
|
||||
["ExerciseDate"] = td.ExerciseDate
|
||||
};
|
||||
|
||||
// 1.1 当前所有仓位(含 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
|
||||
}));
|
||||
|
||||
// 1.2 EOD 持仓序列(关键:观察 InterestPrincipalFix 逐日变化)
|
||||
var eodPositions = db.eod_swap_position
|
||||
.Where(e => e.SwapTradeId == td.id && !e.Invalid && e.InterestMode == 5 || e.InterestMode == 6)
|
||||
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
|
||||
.ToList();
|
||||
snapshot["EodPositions_MarginLegOnly"] = JArray.FromObject(eodPositions, JsonSerializer.Create(new JsonSerializerSettings
|
||||
{
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
DateFormatHandling = DateFormatHandling.IsoDateFormat
|
||||
}));
|
||||
|
||||
// 1.3 EOD 交易级(eod_swap.NotionalValue 应该是初始值不变)
|
||||
var eodSwaps = db.eod_swap.Where(e => e.SwapTradeId == td.id).OrderBy(e => e.ValueDate).ToList();
|
||||
snapshot["EodSwaps"] = JArray.FromObject(eodSwaps, JsonSerializer.Create(new JsonSerializerSettings
|
||||
{
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
DateFormatHandling = DateFormatHandling.IsoDateFormat
|
||||
}));
|
||||
|
||||
// 1.4 所有 flow_event(看平仓/互换事件序列,及 InterestPrincipal 实际写入值)
|
||||
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", "GLMS20260701");
|
||||
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 2) 诊断:打印"预付金腿"逐日本金变化 + 后端 API 1.0 全平应返值
|
||||
|
||||
[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; }
|
||||
|
||||
// 2.1 预付金腿 position.InterestPrincipalFix 当前值(多次平仓后应该已被扣减)
|
||||
var marginPositions = db.swap_position
|
||||
.Where(p => p.SwapTradeId == td.id && !p.Invalid
|
||||
&& (p.InterestMode == (int)InterestModeEnum.初始预付金
|
||||
|| p.InterestMode == (int)InterestModeEnum.追加预付金))
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine("============== 预付金腿 position 当前值(多次平仓后) ==============");
|
||||
foreach (var p in marginPositions)
|
||||
{
|
||||
Console.WriteLine($"PositionId={p.id} Mode={p.InterestMode} Fix={p.InterestPrincipalFix} Rate={p.InterestRateDefault} Dir={p.InterestDirection} IsInitial={p.IsInitial}");
|
||||
}
|
||||
|
||||
// 2.1b 所有 position 全景(含浮动腿),对比 IsInitial vs !IsInitial 的 PosiNotionalValue / Fix
|
||||
var allPositions = db.swap_position
|
||||
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
|
||||
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine("\n============== 全部 position 全景(对比 IsInitial 原始 vs !IsInitial 剩余) ==============");
|
||||
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,-7}{p.PosiDirection,-8}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}{ul,-15}");
|
||||
}
|
||||
|
||||
// 2.1c 关键诊断:GetUnwindInterests 内部 origPositions vs realPostitions 差异
|
||||
var origPositions = allPositions.Where(x => x.IsInitial).ToList();
|
||||
var realPostitions = allPositions.Where(x => !x.IsInitial).ToList();
|
||||
Console.WriteLine("\n============== GetUnwindInterests 关键源数据对比 ==============");
|
||||
Console.WriteLine($" origPositions(IsInitial=True) 浮动腿 PosiNotionalValue 总和: {origPositions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)}");
|
||||
Console.WriteLine($" realPostitions(IsInitial=False) 浮动腿 PosiNotionalValue 总和: {realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)} ← 应为剩余值");
|
||||
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
|
||||
&& (e.InterestMode == (int)InterestModeEnum.初始预付金
|
||||
|| e.InterestMode == (int)InterestModeEnum.追加预付金))
|
||||
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine("============== EOD 预付金腿 InterestPrincipalFix 逐日变化 ==============");
|
||||
foreach (var e in eodMarginSeq)
|
||||
{
|
||||
Console.WriteLine($" ValueDate={e.ValueDate:yyyy-MM-dd} PositionId={e.PositionId} Fix={e.InterestPrincipalFix} TdInterestPrincipal={e.TdInterestPrincipal} PosiStatus={e.PosiStatus} Invalid={e.Invalid}");
|
||||
}
|
||||
|
||||
// 2.3 平仓事件序列(看 InterestPrincipal 实际入库值)
|
||||
var closeFlows = db.swap_flow_event
|
||||
.Where(f => f.SwapTradeId == td.id && f.EventType == (int)SwapEventTypeEnum.平仓
|
||||
&& f.DataState == (int)SwapFlowDateStateEnum.完成
|
||||
&& (f.InterestMode == (int)InterestModeEnum.初始预付金
|
||||
|| f.InterestMode == (int)InterestModeEnum.追加预付金))
|
||||
.OrderBy(f => f.EventDate).ToList();
|
||||
|
||||
Console.WriteLine("============== 历史平仓事件-预付金腿 实际 InterestPrincipal 序列 ==============");
|
||||
foreach (var f in closeFlows)
|
||||
{
|
||||
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 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))
|
||||
{
|
||||
Console.WriteLine($" PositionId={it.PositionId} Mode={it.InterestMode} InterestPrincipal={it.InterestPrincipal} InterestAmount={it.InterestAmount} InterestRate={it.InterestRate}");
|
||||
}
|
||||
|
||||
// 诊断断言:1.0 全平应返 = realPostitions(剩余持仓)的 InterestPrincipalFix
|
||||
// 后端为保持 eod_swap_position.PositionId 日终归档对齐,返回的 PositionId 仍是 origPositions.id,
|
||||
// 但 InterestPrincipal 应等于 realPostitions[real.PositionId == orig.id].Fix(剩余值)。
|
||||
// 因此对比口径:apiRet.InterestPrincipal vs realLeg.Fix(剩余值),不是 vs origPos.Fix(原始值)。
|
||||
Console.WriteLine("============== 修复验证(apiRet.InterestPrincipal vs realLeg.Fix 剩余值)==============");
|
||||
int okCount = 0, badCount = 0;
|
||||
foreach (var origPos in marginPositions.Where(p => p.IsInitial))
|
||||
{
|
||||
var apiRet = interests.FirstOrDefault(i => i.PositionId == origPos.id);
|
||||
if (apiRet == null) { Console.WriteLine($" ⚠ PositionId={origPos.id} 后端未返回"); continue; }
|
||||
var realLeg = marginPositions.FirstOrDefault(p => !p.IsInitial && p.PositionId == origPos.id);
|
||||
decimal expectedFix = realLeg?.InterestPrincipalFix ?? origPos.InterestPrincipalFix;
|
||||
var diff = Math.Abs((double)(apiRet.InterestPrincipal - expectedFix));
|
||||
bool ok = diff < 0.01;
|
||||
if (ok) okCount++; else badCount++;
|
||||
Console.WriteLine($" {(ok ? "✓" : "✗")} PositionId={origPos.id}(origFix={origPos.InterestPrincipalFix}) → realLeg.Fix={expectedFix} 后端返={apiRet.InterestPrincipal} 差={diff:F4}");
|
||||
}
|
||||
Console.WriteLine($"\n 结论:通过 {okCount} 条 / 失败 {badCount} 条");
|
||||
Assert.IsTrue(badCount == 0, $"修复未生效:{badCount} 条预付金腿后端返还值 ≠ realLeg.Fix 剩余值");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"⚠ GetUnwindInterests 调用失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 3) 诊断 GLMS-20260701-0006:部分平仓比例显示 32.50% 而非 50%
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DbDiagnose")]
|
||||
public void Diagnose_0006_UnwindPercentRate_Display()
|
||||
{
|
||||
const string tradeNumber = "GLMS-20260701-0006";
|
||||
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($" TradeType: {td.TradeType}");
|
||||
Console.WriteLine($" TradeStatus: {td.TradeStatus}");
|
||||
Console.WriteLine($" StockEqvNotional (剩余): {td.StockEqvNotional}");
|
||||
Console.WriteLine($" OriginalStockEqvNotional (期初): {td.OriginalStockEqvNotional}");
|
||||
Console.WriteLine($" Notional: {td.Notional}");
|
||||
Console.WriteLine($" OriginalNotional: {td.OriginalNotional}");
|
||||
Console.WriteLine($" TradeAmount: {td.TradeAmount}");
|
||||
Console.WriteLine($" HasPartialUnWind: {td.HasPartialUnWind}");
|
||||
Console.WriteLine($" 剩余比例 = StockEqvNotional/Original = {td.StockEqvNotional / td.OriginalStockEqvNotional}");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine($"===== trade_cash 记录 =====");
|
||||
var tradeCashList = db.trade_cash
|
||||
.Where(t => t.TradeId == td.id && !t.IsDeleted &&
|
||||
(t.Action == "系统操作-平仓费" || t.Action == "系统操作-行权费"))
|
||||
.OrderBy(t => t.ValueDate).ThenBy(t => t.id)
|
||||
.ToList();
|
||||
|
||||
foreach (var tc in tradeCashList)
|
||||
{
|
||||
Console.WriteLine($" [id={tc.id}] ValueDate={tc.ValueDate:yyyy-MM-dd} Action={tc.Action}");
|
||||
Console.WriteLine($" UnwindType: {tc.UnwindType}");
|
||||
Console.WriteLine($" UnwindPercentRate: {tc.UnwindPercentRate} (=> {tc.UnwindPercentRate * 100}%)");
|
||||
Console.WriteLine($" UnwindStockEqvNotional: {tc.UnwindStockEqvNotional}");
|
||||
Console.WriteLine($" UnwindNotional: {tc.UnwindNotional}");
|
||||
Console.WriteLine($" UnwindTradeAmount: {tc.UnwindTradeAmount}");
|
||||
Console.WriteLine($" UnwindMethod: {tc.UnwindMethod}");
|
||||
Console.WriteLine($" ValidState: {tc.ValidState}");
|
||||
Console.WriteLine($" IsLastAction: {tc.IsLastAction}");
|
||||
Console.WriteLine($" ExerciseWay: {tc.ExerciseWay}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine($"===== swap_event 记录 =====");
|
||||
var swapEvents = db.swap_event
|
||||
.Where(e => e.SwapTradeId == td.id && !e.Invalid)
|
||||
.OrderBy(e => e.ValueDate).ThenBy(e => e.id)
|
||||
.ToList();
|
||||
|
||||
foreach (var se in swapEvents)
|
||||
{
|
||||
Console.WriteLine($" [id={se.id}] ValueDate={se.ValueDate:yyyy-MM-dd} EventType={se.EventType}");
|
||||
Console.WriteLine($" EventReason: {se.EventReason}");
|
||||
Console.WriteLine($" ClientCashId: {se.ClientCashId}");
|
||||
if (!string.IsNullOrEmpty(se.EventData))
|
||||
{
|
||||
try
|
||||
{
|
||||
var ud = JsonConvert.DeserializeObject<JObject>(se.EventData);
|
||||
Console.WriteLine($" EventData.ClosePercent: {ud["ClosePercent"]}");
|
||||
Console.WriteLine($" EventData.CloseNotionalValue: {ud["CloseNotionalValue"]}");
|
||||
Console.WriteLine($" EventData.CloseQty: {ud["CloseQty"]}");
|
||||
Console.WriteLine($" EventData.NotionalValue: {ud["NotionalValue"]}");
|
||||
Console.WriteLine($" EventData.PosiNotionalValue: {ud["PosiNotionalValue"]}");
|
||||
Console.WriteLine($" EventData.PositionQty: {ud["PositionQty"]}");
|
||||
Console.WriteLine($" EventData.CloseMethod: {ud["CloseMethod"]}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" EventData parse error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// 查询 swap_flow_event 记录
|
||||
Console.WriteLine($"===== swap_flow_event 记录 =====");
|
||||
var flowEvents = db.swap_flow_event
|
||||
.Where(f => f.SwapTradeId == td.id)
|
||||
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
|
||||
.ToList();
|
||||
|
||||
foreach (var fe in flowEvents)
|
||||
{
|
||||
Console.WriteLine($" [id={fe.id}] EventDate={fe.EventDate:yyyy-MM-dd} EventType={fe.EventType}");
|
||||
Console.WriteLine($" PositionId: {fe.PositionId}");
|
||||
Console.WriteLine($" Quantity: {fe.Quantity}");
|
||||
Console.WriteLine($" PositionQty: {fe.PositionQty}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// SwapDealService 手动结算(SwapIncome/SwapUnwind)内存单元测试
|
||||
/// ============================================================================
|
||||
/// 背景:SwapIncome/SwapUnwind 是写客户资金流水(ClientCashInCashOut)的核心入口,
|
||||
/// 此前零单元测试(仅 DBRecording,CI 不跑)。本测试通过 7 个 virtual seam
|
||||
/// 把 DB/事务/外部服务打桩,在纯内存下验证控制流、资金流水金额、持仓状态变更。
|
||||
///
|
||||
/// 命名规范说明(见《互换价格字段命名规范决策文档》):
|
||||
/// 本测试引用现状字段(如 PosiGrossPrice/PosiNetPrice)时加对照注释,
|
||||
/// 标明其真实含义与规范名,让测试可读、可作规范示范。
|
||||
/// - PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice
|
||||
/// - PosiNetPrice 现状名,实为"期初全价含费"(非净价!),规范名 EntryDirtyFeePrice
|
||||
/// ============================================================================
|
||||
[TestClass]
|
||||
public class SwapDealSettlementTest
|
||||
{
|
||||
private const int SwapTradeId = 7700;
|
||||
private static readonly DateTime ValueDate = new(2026, 6, 15);
|
||||
private static readonly DateTime UnwindDate = new(2026, 6, 16);
|
||||
|
||||
#region Stub
|
||||
|
||||
/// <summary>
|
||||
/// 继承 SwapDealService,override 7 个 seam,把 DB/事务/外部服务替换为内存收集器。
|
||||
/// 生产路径零改动(seam 生产实现 = 原逻辑),测试可纯内存运行。
|
||||
/// </summary>
|
||||
private sealed class StubDealService : SwapDealService
|
||||
{
|
||||
private readonly trade _trade;
|
||||
private readonly Dictionary<int, swap_event> _swapEvents;
|
||||
private readonly Dictionary<long, List<swap_flow_event>> _flowEventsByEventId;
|
||||
public List<(double amount, string action, DateTime date)> ClientCashCalls = new();
|
||||
public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls = new();
|
||||
public int SaveAllChangesCount;
|
||||
public int CloseReCheckCallCount;
|
||||
|
||||
public StubDealService(trade td,
|
||||
Dictionary<int, swap_event> swapEvents = null,
|
||||
Dictionary<long, List<swap_flow_event>> flowEventsByEventId = null)
|
||||
: base(new OptUserInfo(0, nameof(SwapDealSettlementTest), OptUserFrom.UnitTest))
|
||||
{
|
||||
_trade = td;
|
||||
_swapEvents = swapEvents ?? new Dictionary<int, swap_event>();
|
||||
_flowEventsByEventId = flowEventsByEventId ?? new Dictionary<long, List<swap_flow_event>>();
|
||||
}
|
||||
|
||||
protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null;
|
||||
|
||||
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
|
||||
{
|
||||
ClientCashCalls.Add((amount, action, valueDate));
|
||||
return ClientCashCalls.Count; // 返回自增 id
|
||||
}
|
||||
|
||||
// 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库
|
||||
protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false)
|
||||
{
|
||||
SaveSwapDealCalls.Add((unwindData, eventType, clientCashId));
|
||||
return SaveSwapDealCalls.Count; // 返回自增 eventId
|
||||
}
|
||||
|
||||
// ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType)
|
||||
protected override swap_event FindSwapEvent(int tradeId, int eventType)
|
||||
{
|
||||
return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null;
|
||||
}
|
||||
|
||||
// ApproveSwapTrade 查事件关联流水:从内存字典取
|
||||
protected override List<swap_flow_event> FindFlowEventsByEventId(long eventId)
|
||||
{
|
||||
return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List<swap_flow_event>();
|
||||
}
|
||||
|
||||
// ApplySwapTrade 的前置校验:计数,不实际执行
|
||||
protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck)
|
||||
{
|
||||
CloseReCheckCallCount++;
|
||||
}
|
||||
|
||||
protected override void SaveAllChanges() { SaveAllChangesCount++; }
|
||||
protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行
|
||||
protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作
|
||||
protected override void TriggerRealtimeSwapPosition() { } // 空操作
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 数据构建
|
||||
|
||||
private static trade CreateTrade()
|
||||
{
|
||||
return new trade
|
||||
{
|
||||
id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888,
|
||||
TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5),
|
||||
ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用)
|
||||
TradeStatus = "确认成交", ValidState = "Valid",
|
||||
Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用)</summary>
|
||||
private static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m,
|
||||
decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m,
|
||||
decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m)
|
||||
{
|
||||
return new UnwindData
|
||||
{
|
||||
SwapTradeId = SwapTradeId,
|
||||
SwapRealizedPnL = swapRealizedPnL,
|
||||
SwapMarginRebatePnl = swapMarginRebatePnl,
|
||||
SwapMarginAmount = swapMarginAmount,
|
||||
SwapCloseAmount = swapRealizedPnL,
|
||||
CloseMethod = closeMethod,
|
||||
ClosePercent = closePercent,
|
||||
CloseQty = closeQty,
|
||||
CloseNotionalValue = closeNotionalValue,
|
||||
PositionQty = positionQty,
|
||||
ValueDate = ValueDate,
|
||||
UnwindDate = UnwindDate,
|
||||
StartDate = new DateTime(2026, 1, 5)
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// ================================================================
|
||||
// SD_001:SwapIncome 正常结息 —— 验证资金流水金额正确
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// [SD_001] SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000
|
||||
/// ------------------------------------------------------------
|
||||
/// 后端 SwapDealService.cs:1553 直接用前端传入的 SwapRealizedPnL 记账:
|
||||
/// AddClientCash(td, -SwapRealizedPnL, 系统操作_互换, ValueDate)
|
||||
/// 本测试锁定:资金流水金额 = -SwapRealizedPnL,事件类型 = 互换(3)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SD_001_SwapIncome_正常结息_资金流水金额正确()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支
|
||||
var service = new StubDealService(td);
|
||||
var unwindData = CreateUnwindData(swapRealizedPnL: 1000m);
|
||||
|
||||
service.SwapIncome(unwindData);
|
||||
|
||||
Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)");
|
||||
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换");
|
||||
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
|
||||
Assert.AreEqual((int)SwapEventTypeEnum.互换, service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)");
|
||||
Console.WriteLine($"SD_001 通过:资金流水金额={service.ClientCashCalls[0].amount},事件类型=互换 ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SD_002:SwapIncome 含预付金返息 —— 两条资金流水
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// [SD_002] SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200
|
||||
/// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200
|
||||
/// 后端 SwapDealService.cs:1556 条件:SwapMarginRebatePnl != 0 时追加预付金返息流水。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SD_002_SwapIncome_含预付金返息_两条资金流水()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
td.ExerciseDate = new DateTime(2026, 12, 31);
|
||||
var service = new StubDealService(td);
|
||||
var unwindData = CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m);
|
||||
|
||||
service.SwapIncome(unwindData);
|
||||
|
||||
Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)");
|
||||
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额 -SwapRealizedPnL");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action);
|
||||
Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息 -SwapMarginRebatePnl");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action);
|
||||
Console.WriteLine($"SD_002 通过:2条资金流水,互换={service.ClientCashCalls[0].amount},预付金返息={service.ClientCashCalls[1].amount} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SD_003:SwapUnwind 全平仓 —— 持仓归零、资金流水、状态变更
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// [SD_003] SwapUnwind 全平仓:ClosePercent=1 → TradeStatus=已平仓、持仓扣减、资金流水正确
|
||||
/// 后端 SwapDealService.cs SwapUnwind:全平时 TradeStatus=已平仓,StockEqvNotional/TradeAmount 扣减。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SD_003_SwapUnwind_正常平仓_资金流水与持仓状态正确()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var service = new StubDealService(td);
|
||||
// 全平:ClosePercent=1, CloseQty=10000, CloseNotionalValue=1000000
|
||||
var unwindData = CreateUnwindData(
|
||||
swapRealizedPnL: 5000m, swapMarginAmount: 0m,
|
||||
closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m,
|
||||
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
|
||||
|
||||
service.SwapUnwind(unwindData);
|
||||
|
||||
// 资金流水:平仓费 = -SwapRealizedPnL
|
||||
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水");
|
||||
Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action);
|
||||
// 持仓状态
|
||||
Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓");
|
||||
// 全平仓走"已平仓"分支,不设 HasPartialUnWind(仅部分平仓才设=1)
|
||||
Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind(仅部分平仓设=1)");
|
||||
// 持仓扣减:原 StockEqvNotional=1000000 - CloseNotionalValue=1000000 = 0
|
||||
Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0");
|
||||
Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0");
|
||||
// 事件类型
|
||||
Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)");
|
||||
Console.WriteLine($"SD_003 通过:TradeStatus={td.TradeStatus},StockEqvNotional={td.StockEqvNotional} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SD_004:DealFloatPosition 含费价重算正确(后端唯二真做计算的地方)
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// [SD_004] DealFloatPosition 含费价重算(SwapDealService.cs:1713-1725)
|
||||
/// ------------------------------------------------------------
|
||||
/// 平仓事件重算三个字段(规范语义,见命名文档):
|
||||
/// TradingAmountFeeAvg(ExitDirtyFeePrice)= TradingAmountAvg(ExitDirtyPrice) + TradingFeePending/CloseQty × shortRatio
|
||||
/// TradingAmountNetFeeAvg(ExitCleanFeePrice)= TradingAmountNetAvg(ExitCleanPrice) + TradingFeePending/CloseQty × shortRatio
|
||||
/// TradingAmount = TradingAmountAvg × CloseQty
|
||||
/// 这是后端少数真正做计算(而非透传前端值)的地方,需锁住。
|
||||
///
|
||||
/// 手算:ExitDirtyPrice=1.02, TradingFeePending=50, CloseQty=1000, Long(多头,shortRatio=-1)
|
||||
/// ExitDirtyFeePrice = 1.02 + 50/1000 × (-1) = 1.02 - 0.05 = 0.97
|
||||
/// ExitCleanFeePrice = 1.00 + 50/1000 × (-1) = 1.00 - 0.05 = 0.95
|
||||
/// TradingAmount = 1.02 × 1000 = 1020
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SD_004_DealFloatPosition_含费价重算正确()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var service = new StubDealService(td);
|
||||
|
||||
// 构造平仓事件(PositionType>0 触发重算)
|
||||
var closeEvent = new swap_flow_event
|
||||
{
|
||||
EventType = (int)SwapEventTypeEnum.平仓,
|
||||
PositionType = (int)PositionTypeFlag.Long, // 多头,shortRatio=-1
|
||||
// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice
|
||||
TradingAmountAvg = 1.02m,
|
||||
// TradingAmountNetAvg 现状名,实为"期末净价不含费",规范名 ExitCleanPrice
|
||||
TradingAmountNetAvg = 1.00m,
|
||||
TradingFeePending = 50m,
|
||||
};
|
||||
var unwindData = CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m);
|
||||
unwindData.FlowEvents.Add(closeEvent);
|
||||
|
||||
service.SwapUnwind(unwindData);
|
||||
|
||||
// ExitDirtyFeePrice(TradingAmountFeeAvg)= 1.02 + 50/1000×(-1) = 0.97
|
||||
Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m,
|
||||
$"TradingAmountFeeAvg(ExitDirtyFeePrice) 应=ExitDirtyPrice(1.02)+Fee/CloseQty×(-1)=0.97,实际={closeEvent.TradingAmountFeeAvg}");
|
||||
// ExitCleanFeePrice(TradingAmountNetFeeAvg)= 1.00 + 50/1000×(-1) = 0.95
|
||||
Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m,
|
||||
$"TradingAmountNetFeeAvg(ExitCleanFeePrice) 应=ExitCleanPrice(1.00)+Fee/CloseQty×(-1)=0.95,实际={closeEvent.TradingAmountNetFeeAvg}");
|
||||
// TradingAmount = ExitDirtyPrice × CloseQty = 1.02 × 1000 = 1020
|
||||
Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m,
|
||||
$"TradingAmount 应=ExitDirtyPrice(1.02)×CloseQty(1000)=1020,实际={closeEvent.TradingAmount}");
|
||||
Console.WriteLine($"SD_004 通过:ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg},ExitCleanFeePrice={closeEvent.TradingAmountNetFeeAvg},TradingAmount={closeEvent.TradingAmount} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SD_005:ApproveSwapTrade 审核通过 —— 反序列化事件、资金流水、持仓状态
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// [SD_005] ApproveSwapTrade 审核通过全部平仓
|
||||
/// ------------------------------------------------------------
|
||||
/// 后端 SwapDealService.ApproveSwapTrade:从 swap_event.EventData 反序列化 UnwindData,
|
||||
/// 据此生成资金流水 + 更新持仓状态。
|
||||
/// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario4,验证:
|
||||
/// - SwapRealizedPnL 从事件反序列化正确(EventData JSON)
|
||||
/// - 资金流水金额 = -SwapRealizedPnL
|
||||
/// - 全平仓 → TradeStatus=已平仓
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SD_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
// 构造待审核事件:EventData 里序列化了 UnwindData(含 SwapRealizedPnL=8000)
|
||||
var unwindData = CreateUnwindData(swapRealizedPnL: 8000m,
|
||||
closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m,
|
||||
closeQty: 10000m, closeNotionalValue: 1000000m);
|
||||
var swapEvent = new swap_event
|
||||
{
|
||||
id = 1, SwapTradeId = SwapTradeId,
|
||||
EventType = (int)SwapEventTypeEnum.平仓, Invalid = false,
|
||||
EventData = JsonConvert.SerializeObject(unwindData)
|
||||
};
|
||||
var flowEvents = new Dictionary<long, List<swap_flow_event>>
|
||||
{
|
||||
[1] = new List<swap_flow_event> { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } }
|
||||
};
|
||||
var service = new StubDealService(td,
|
||||
swapEvents: new Dictionary<int, swap_event> { [(int)SwapEventTypeEnum.平仓] = swapEvent },
|
||||
flowEventsByEventId: flowEvents);
|
||||
|
||||
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓);
|
||||
|
||||
// 资金流水:从反序列化的 SwapRealizedPnL(8000) 记账 → -8000
|
||||
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水");
|
||||
Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL");
|
||||
// 持仓状态
|
||||
Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓");
|
||||
Console.WriteLine($"SD_005 通过:审核反序列化 SwapRealizedPnL=8000,资金流水={service.ClientCashCalls[0].amount},TradeStatus={td.TradeStatus} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SD_006:ApplySwapTrade 提交审核 —— 前置校验 + 保存事件
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// [SD_006] ApplySwapTrade 提交审核
|
||||
/// ------------------------------------------------------------
|
||||
/// 后端 SwapDealService.ApplySwapTrade:调 CloseReCheckSetTrade 前置校验 + SaveSwapDeal(approve=true)。
|
||||
/// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario5,验证:
|
||||
/// - CloseReCheckSetTrade 被调用1次
|
||||
/// - SaveSwapDeal 以 approve=true 调用(事件类型正确)
|
||||
/// - SwapRealizedPnL = SwapCloseAmount(ApplySwapTrade 内部赋值)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SD_006_ApplySwapTrade_提交审核_前置校验与保存事件()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var service = new StubDealService(td);
|
||||
// 前端提交时 SwapCloseAmount=6000(前端算好的总额),SwapRealizedPnL 初始可能为0
|
||||
var unwindData = CreateUnwindData(swapRealizedPnL: 0m);
|
||||
unwindData.SwapCloseAmount = 6000m; // 模拟前端传入的平仓总额
|
||||
|
||||
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓);
|
||||
|
||||
// 前置校验被调用
|
||||
Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次");
|
||||
// SaveSwapDeal 以 approve=true 调用
|
||||
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
|
||||
Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓");
|
||||
// SwapRealizedPnL 应被赋值为 SwapCloseAmount(ApplySwapTrade 内部 cs:1631)
|
||||
Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m,
|
||||
"SwapRealizedPnL 应=SwapCloseAmount(6000)");
|
||||
Console.WriteLine($"SD_006 通过:CloseReCheck 调用{service.CloseReCheckCallCount}次,SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,67 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 互换结息(SwapIncome)测试
|
||||
/// ============================================================================
|
||||
/// 借鉴 testable 分支命名,基于当前分支 TestableSwapDealService 共享 stub。
|
||||
/// SwapIncome 是写客户资金流水(ClientCashInCashOut)的核心入口之一。
|
||||
/// ============================================================================
|
||||
[TestClass]
|
||||
public class SwapIncomeScenarioTest
|
||||
{
|
||||
// ================================================================
|
||||
// 场景1:SwapIncome 正常结息 —— 资金流水金额正确
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000。
|
||||
/// 后端 SwapDealService SwapIncome 直接用前端传入的 SwapRealizedPnL 记账。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SI_001_SwapIncome_正常结息_资金流水金额正确()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支
|
||||
var service = new TestableSwapDealService(td);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 1000m);
|
||||
|
||||
service.SwapIncome(unwindData);
|
||||
|
||||
Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)");
|
||||
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换");
|
||||
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
|
||||
Assert.AreEqual((int)SwapEventTypeEnum.互换, service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)");
|
||||
Console.WriteLine($"SI_001: 资金流水={service.ClientCashCalls[0].amount}, 事件类型=互换 ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景2:SwapIncome 含预付金返息 —— 两条资金流水
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200
|
||||
/// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SI_002_SwapIncome_含预付金返息_两条资金流水()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
td.ExerciseDate = new DateTime(2026, 12, 31);
|
||||
var service = new TestableSwapDealService(td);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m);
|
||||
|
||||
service.SwapIncome(unwindData);
|
||||
|
||||
Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)");
|
||||
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action);
|
||||
Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action);
|
||||
Console.WriteLine($"SI_002: 互换={service.ClientCashCalls[0].amount}, 预付金返息={service.ClientCashCalls[1].amount} ✅");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// SwapPositionCompose 日终归档端到端测试
|
||||
/// ============================================================================
|
||||
/// 借鉴 testable 分支 SwapPositionComposeScenarioTest,基于当前分支 seam 重写。
|
||||
/// 覆盖 DealFloatPositions 的首次归档/Copy/Update/异常路径。
|
||||
/// 利息腿场景(自动互换)因 CalcSwapInterests 参数适配复杂留后续。
|
||||
/// ============================================================================
|
||||
[TestClass]
|
||||
public class SwapPositionComposeScenarioTest
|
||||
{
|
||||
private const int SwapTradeId = 100;
|
||||
private static readonly DateTime SettleDate = new(2025, 4, 24);
|
||||
private static readonly DateTime PreSettleDate = new(2025, 4, 23);
|
||||
|
||||
#region 可测试化子类
|
||||
|
||||
/// <summary>
|
||||
/// 继承 SwapEodPositionService,override SwapPositionCompose 路径上的 seam。
|
||||
/// 适配当前分支 seam 签名(GetUnderlyingPrice 带 out、GetCurrencyRate 返回 double 等)。
|
||||
/// </summary>
|
||||
private sealed class TestableSwapEodService : SwapEodPositionService
|
||||
{
|
||||
private readonly List<trade> _trades;
|
||||
private readonly List<swap_position> _positions;
|
||||
private readonly List<eod_swap_position> _eodPositions;
|
||||
private readonly List<eod_swap> _eodSwaps;
|
||||
private readonly List<trade_extend> _extends;
|
||||
private readonly List<swap_flow_event> _flowEvents;
|
||||
private readonly decimal _price;
|
||||
private readonly decimal _vobp;
|
||||
|
||||
public List<eod_swap_position> CreatedEodPositions { get; } = new();
|
||||
public List<(double amount, string action)> ClientCashCalls { get; } = new();
|
||||
|
||||
public TestableSwapEodService(
|
||||
List<trade> trades, List<swap_position> positions,
|
||||
List<eod_swap_position> eodPositions, List<eod_swap> eodSwaps,
|
||||
List<trade_extend> extends, List<swap_flow_event> flowEvents,
|
||||
decimal price = 100m, decimal vobp = 0m)
|
||||
: base(new OptUserInfo(0, nameof(SwapPositionComposeScenarioTest), OptUserFrom.UnitTest))
|
||||
{
|
||||
_trades = trades; _positions = positions; _eodPositions = eodPositions;
|
||||
_eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents;
|
||||
_price = price; _vobp = vobp;
|
||||
}
|
||||
|
||||
// SwapPositionCompose 路径 seam override
|
||||
protected override List<trade> FindActiveSwapTrades(DateTime settleDate, IEnumerable<int> clientIds) => _trades;
|
||||
protected override List<swap_position> FindAllSwapPositions(List<int> tradeIds) => _positions;
|
||||
protected override List<trade_extend> FindTradeExtends(List<int> tradeIds) => _extends;
|
||||
protected override List<eod_swap> FindEodSwapsByDate(DateTime valueDate) => _eodSwaps;
|
||||
protected override List<swap_flow_event> FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents;
|
||||
protected override List<eod_swap_position> FindEodSwapPositions(int swapTradeId, DateTime preSettleDate)
|
||||
=> _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList();
|
||||
protected override List<swap_position> FindSwapPositions(int swapTradeId)
|
||||
=> _positions.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial).ToList();
|
||||
|
||||
// DealFloatPositions 路径 seam override
|
||||
protected override underlying_manager GetUnderlyingData(string underlyingCode)
|
||||
=> new underlying_manager { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" };
|
||||
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
|
||||
{ vobp = _vobp; return _price; }
|
||||
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m;
|
||||
|
||||
// 持久化/事务 seam override
|
||||
protected override void PersistEodSwapPosition(eod_swap_position position) { CreatedEodPositions.Add(position); }
|
||||
protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { }
|
||||
protected override void SaveAllChanges() { }
|
||||
protected override void ExecuteInTransaction(Action action) => action();
|
||||
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
|
||||
{ ClientCashCalls.Add((amount, action)); return ClientCashCalls.Count; }
|
||||
protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List<int> eventTypes) { }
|
||||
public override void ClearSwapPositions(trade td, DateTime valueDate, List<int> eventTypes, bool delAfter) { }
|
||||
protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason)
|
||||
{ return new swap_event { id = 1 }; }
|
||||
protected override List<swap_flow_event> CalcSwapInterests(
|
||||
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
|
||||
List<eod_swap_position> eodPositions, List<swap_position> positions,
|
||||
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
|
||||
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
|
||||
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
|
||||
List<swap_flow_event> closeList = null) => new List<swap_flow_event>();
|
||||
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) => 1.0;
|
||||
|
||||
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
|
||||
=> SwapPositionCompose(settleDate, preSettleDate, null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 工厂方法
|
||||
|
||||
private static trade CreateTrade(DateTime? startDate = null)
|
||||
{
|
||||
var date = startDate ?? SettleDate;
|
||||
return new trade
|
||||
{
|
||||
id = SwapTradeId, TradeNumber = "TEST-COMPOSE-001", ClientId = 10,
|
||||
TradeType = "收益互换", TradeDate = date, StartDate = date,
|
||||
ExerciseDate = SettleDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
|
||||
QuoteCurrency = "CNY", SettlementCurrency = "CNY", StructureType = "普通债券类收益互换",
|
||||
OriginalStockEqvNotional = 100000, TradePrice = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static trade_extend CreateExtend()
|
||||
{
|
||||
return new trade_extend
|
||||
{
|
||||
TradeId = SwapTradeId,
|
||||
ExtendJson = @"{""NeedOpenFee"":false,""AnnualDays"":365,""SettlementRules"":0,""Direction"":1,""FlowBookMode"":0}"
|
||||
};
|
||||
}
|
||||
|
||||
private static swap_position CreateFloatPosition(long positionId, decimal qty)
|
||||
{
|
||||
return new swap_position
|
||||
{
|
||||
id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId,
|
||||
PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long,
|
||||
UnderlyingCode = "220205.IB", UnderlyingInstrumentType = "TBonds",
|
||||
ContractSize = 1m, CountRatio = 1m, IsInitial = true, Invalid = false,
|
||||
PosiQuantity = qty, PosiNotionalValue = qty,
|
||||
PosiNetPrice = 1.0050m, PosiGrossPrice = 1.0020m,
|
||||
PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 0.9970m,
|
||||
InterestDirection = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static eod_swap_position CreateFloatEodPosition(long positionId, decimal qty, decimal grossPrice)
|
||||
{
|
||||
return new eod_swap_position
|
||||
{
|
||||
SwapTradeId = SwapTradeId, PositionId = positionId, ValueDate = PreSettleDate,
|
||||
PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false,
|
||||
PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m,
|
||||
PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m,
|
||||
UnderlyingCode = "220205.IB", ContractSize = 1m,
|
||||
InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty
|
||||
};
|
||||
}
|
||||
|
||||
private static swap_flow_event CreateCloseFlowEvent(long positionId, decimal qty)
|
||||
{
|
||||
return new swap_flow_event
|
||||
{
|
||||
SwapTradeId = SwapTradeId, PositionId = positionId,
|
||||
EventType = (int)SwapFlowEventTypeEnum.平仓,
|
||||
Quantity = qty, EventDate = SettleDate, UnwindDate = SettleDate,
|
||||
MarkClosePnl = 500m, CloseFee = 10m, DividendIn = 5m,
|
||||
TradingAmountAvg = 1.0030m, DataState = (int)SwapFlowDateStateEnum.完成
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// ================================================================
|
||||
// 场景1:首次归档(无前日eod,交易首日)
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void SPC_001_首次归档_无前日Eod_直接取初始持仓()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var extend = CreateExtend();
|
||||
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
|
||||
var service = new TestableSwapEodService(
|
||||
new List<trade> { td }, positions,
|
||||
new List<eod_swap_position>(), new List<eod_swap>(),
|
||||
new List<trade_extend> { extend }, new List<swap_flow_event>());
|
||||
|
||||
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
|
||||
|
||||
Assert.IsTrue(service.CreatedEodPositions.Count >= 1, "应创建至少1条eod");
|
||||
var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1);
|
||||
Assert.IsNotNull(floatEod, "应创建浮动腿持仓");
|
||||
Assert.AreEqual(1000m, floatEod.PosiQuantity, "首次归档 PosiQuantity=初始持仓数量");
|
||||
Console.WriteLine($"SPC_001: PosiQuantity={floatEod.PosiQuantity} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景2:有前日eod无事件 → Copy
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void SPC_002_Copy分支_有前日Eod无事件_价格原样复制()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var extend = CreateExtend();
|
||||
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
|
||||
var prevEod = new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m) };
|
||||
var service = new TestableSwapEodService(
|
||||
new List<trade> { td }, positions,
|
||||
prevEod, new List<eod_swap>(),
|
||||
new List<trade_extend> { extend }, new List<swap_flow_event>());
|
||||
|
||||
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
|
||||
|
||||
var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1);
|
||||
Assert.IsNotNull(floatEod);
|
||||
Assert.AreEqual(1000m, floatEod.PosiQuantity, "Copy分支 PosiQuantity不变");
|
||||
Assert.AreEqual(1.0020m, floatEod.PosiGrossPrice, "Copy分支 PosiGrossPrice从前日eod复制");
|
||||
Console.WriteLine($"SPC_002: PosiQuantity={floatEod.PosiQuantity}, PosiGrossPrice={floatEod.PosiGrossPrice} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景3:有平仓事件 → Update(持仓扣减)
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void SPC_003_Update分支_有平仓事件_持仓扣减()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var extend = CreateExtend();
|
||||
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
|
||||
var prevEod = new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m) };
|
||||
var flowEvents = new List<swap_flow_event> { CreateCloseFlowEvent(1, 400) };
|
||||
var service = new TestableSwapEodService(
|
||||
new List<trade> { td }, positions,
|
||||
prevEod, new List<eod_swap>(),
|
||||
new List<trade_extend> { extend }, flowEvents);
|
||||
|
||||
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
|
||||
|
||||
var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1);
|
||||
Assert.IsNotNull(floatEod);
|
||||
Assert.AreEqual(600m, floatEod.PosiQuantity, "Update分支 PosiQuantity=1000-400=600");
|
||||
Assert.AreEqual(400m, floatEod.TdCloseQty, "TdCloseQty=平仓数量400");
|
||||
Console.WriteLine($"SPC_003: PosiQuantity={floatEod.PosiQuantity}, TdCloseQty={floatEod.TdCloseQty} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景4:未收盘抛异常
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void SPC_004_未收盘_非交易首日无前日Eod_抛异常()
|
||||
{
|
||||
// 交易起始日早于收盘日(非交易首日),且无前日eod
|
||||
var td = CreateTrade(startDate: SettleDate.AddDays(-10));
|
||||
var extend = CreateExtend();
|
||||
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
|
||||
var service = new TestableSwapEodService(
|
||||
new List<trade> { td }, positions,
|
||||
new List<eod_swap_position>(), new List<eod_swap>(),
|
||||
new List<trade_extend> { extend }, new List<swap_flow_event>());
|
||||
|
||||
var ex = Assert.ThrowsException<Exception>(() =>
|
||||
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate));
|
||||
Assert.IsTrue(ex.Message.Contains("未收盘"), $"异常消息应含'未收盘',实际:{ex.Message}");
|
||||
Console.WriteLine($"SPC_004: 抛异常'{ex.Message}' ✅");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 诊断测试:验证「浮动腿 fpositions 仍用 origPositions(orig 100M)」对本 deal 的
|
||||
/// 预付金/返回预付金结果是否产生影响。结论预期:本 deal 利息腿只有 mode 9(标的期初全价)
|
||||
/// 与 mode 5(初始预付金),CalcNotionalByMode 中 posiLong/posiShort 仅在「多头/空头存续名义本金」
|
||||
/// 分支被消费(L709-716),故本 deal 即便 fpositions 用 orig 100M,预付金腿结果也不受其影响。
|
||||
/// 本测试仅做诊断/验证,不改动任何生产代码;用反射调用 private CalcNotionalByMode 以直接证明
|
||||
/// “mode 9 / mode 5 的 closePrincipal 不依赖 posiLong/posiShort”。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SwapUnwindFloatingLegDiagnosticTdd
|
||||
{
|
||||
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 OrigFix = 99_000m; // 期初预付金腿初始本金
|
||||
private const decimal RealFix = 66_813.12m; // 实时预付金腿剩余本金(4 次平仓后)
|
||||
private const decimal OrigLong = 100_000_000m; // 期初标的(多头)名义本金
|
||||
private const decimal RealLong = 68_947_200m; // 实时标的(多头)剩余名义本金
|
||||
private const decimal ClosePct = 0.1m; // 本次平仓比例 10%
|
||||
|
||||
private static readonly DateTime D0 = new(2026, 7, 1);
|
||||
private static readonly DateTime D1 = new(2026, 7, 16);
|
||||
|
||||
private SwapDealService _svc;
|
||||
[TestInitialize] public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindFloatingLegDiagnosticTdd), OptUserFrom.UnitTest));
|
||||
|
||||
// ---- GLMS 双轨持仓构造 ----
|
||||
private static swap_position OrigPrepay(decimal fix = OrigFix) => new swap_position
|
||||
{ id = 35798, SwapTradeId = 1993, PosiDirection = 0, InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = fix, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||||
interest_rest_days = 1, InterestDirection = (int)SwapDirectionEnum.收取, InterestSwapInterval = "[]" };
|
||||
private static swap_position RealPrepay(decimal fix = RealFix) => new swap_position
|
||||
{ id = 35871, SwapTradeId = 1993, PositionId = 35798, PosiDirection = 0, InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = fix, IsInitial = false, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||||
interest_rest_days = 1, InterestDirection = (int)SwapDirectionEnum.收取, InterestSwapInterval = "[]" };
|
||||
private static swap_position OrigBasePrice() => new swap_position
|
||||
{ id = 35797, SwapTradeId = 1993, PosiDirection = 0, InterestMode = (int)InterestModeEnum.标的期初全价,
|
||||
InterestPrincipalFix = 0, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||||
interest_rest_days = 1, InterestSwapInterval = "[]" };
|
||||
private static swap_position RealBasePrice() => new swap_position
|
||||
{ id = 35870, SwapTradeId = 1993, PositionId = 35797, PosiDirection = 0, InterestMode = (int)InterestModeEnum.标的期初全价,
|
||||
InterestPrincipalFix = 0, IsInitial = false, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||||
interest_rest_days = 1, InterestSwapInterval = "[]" };
|
||||
private static swap_position OrigLongLeg() => new swap_position
|
||||
{ id = 35799, SwapTradeId = 1993, PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long, InterestMode = 0,
|
||||
PosiNotionalValue = OrigLong, IsInitial = true, Invalid = false };
|
||||
private static swap_position RealLongLeg() => new swap_position
|
||||
{ id = 35872, SwapTradeId = 1993, PositionId = 35799, PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long, InterestMode = 0,
|
||||
PosiNotionalValue = RealLong, IsInitial = false, Invalid = false };
|
||||
|
||||
private static trade MakeTrade()
|
||||
{
|
||||
var extend = new trade_extend { TradeId = 1993, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||||
{ AnnualDays = 365, InterestCalcMode = "10", SettlementRules = 0 }) };
|
||||
return new trade { id = 1993, TradeNumber = "GLMS-20260701-0008", ClientId = 999998, TradeType = "收益互换",
|
||||
TradeDate = D0, StartDate = D0, ExerciseDate = D1, TradeStatus = "确认成交", ValidState = "Valid",
|
||||
StockEqvNotional = (double)RealLong, Notional = (double)RealLong, trade_extend = extend };
|
||||
}
|
||||
|
||||
/// <summary>用反射调用 private CalcNotionalByMode,直接证明各 mode 的 closePrincipal 是否依赖 posiLong/posiShort。</summary>
|
||||
private (decimal close, decimal posi, decimal pct) CallCalcNotionalByMode(swap_position position, decimal closePct, decimal posiNotional, decimal posiLong, decimal posiShort)
|
||||
{
|
||||
var m = typeof(SwapDealService).GetMethod("CalcNotionalByMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
return ((decimal, decimal, decimal))m.Invoke(_svc, new object[] { position, closePct, posiNotional, posiLong, posiShort });
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 诊断_mode9_标的期初全价_closePrincipal_不依赖posiLong_而用posiNotional()
|
||||
{
|
||||
// mode 9 分支:closePrincipal = posiNotional * closePercent
|
||||
var baseP = OrigBasePrice();
|
||||
var (close, posi, _) = CallCalcNotionalByMode(baseP, ClosePct, RealLong * ClosePct, OrigLong, 0m);
|
||||
Console.WriteLine($"[mode9] posiNotional={RealLong * ClosePct} posiLong(orig)={OrigLong} → closePrincipal={close}");
|
||||
Assert.AreEqual(RealLong * ClosePct * ClosePct, close, "mode9 应 = posiNotional(=real剩余*closePct) * closePct,与 posiLong(orig 100M) 无关");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 诊断_mode5_预付金_closePrincipal_用自身Fix_不依赖posiLong()
|
||||
{
|
||||
// mode 5 分支:closePrincipal = position.InterestPrincipalFix * closePercent(用 Clone 后的 real Fix)
|
||||
var prepay = RealPrepay(); // Fix = RealFix(66,813.12)
|
||||
var (close, posi, _) = CallCalcNotionalByMode(prepay, ClosePct, RealLong * ClosePct, OrigLong, 0m);
|
||||
Console.WriteLine($"[mode5] Fix(cloned real)={RealFix} posiLong(orig)={OrigLong} → closePrincipal={close}");
|
||||
Assert.AreEqual(RealFix * ClosePct, close, "mode5 应 = 实时腿剩余本金(real Fix) * closePct,与 posiLong(orig 100M) 无关");
|
||||
Assert.AreNotEqual(OrigFix * ClosePct, close, "务必不是期初 99,000 * closePct(证明后端修复生效)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 诊断_若将来有_多头存续名义本金_腿_posiLong用orig才出错_本deal无此腿_故不影响()
|
||||
{
|
||||
// 构造一个「多头存续名义本金」腿,证明此时 posiLong 取值(orig vs real)会直接决定结果——
|
||||
// 说明本 deal 没有这种腿,所以 fpositions 用 orig 100M 不影响;但普通收益互换若有此腿则会踩坑。
|
||||
var longLeg = new swap_position { id = 35799, InterestMode = (int)InterestModeEnum.多头存续名义本金 };
|
||||
var byOrig = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, OrigLong, 0m); // 当前代码:posiLong=orig 100M
|
||||
var byReal = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, RealLong, 0m); // 若修正为 real 75.6M
|
||||
Console.WriteLine($"[多头存续名义本金] orig100M→close={byOrig.close} ; real75.6M→close={byReal.close}");
|
||||
Assert.AreEqual(OrigLong * ClosePct, byOrig.close, "现状:多头存续名义本金用 orig 100M → 多次部分平仓后会偏大");
|
||||
Assert.AreEqual(RealLong * ClosePct, byReal.close, "正确应:用 real 剩余本金 75.6M");
|
||||
Assert.AreNotEqual(byOrig.close, byReal.close, "★ 潜在同类 bug:普通收益互换(含多头/空头存续名义本金腿)在多次部分平仓后,posiLong/posiShort 用 orig 会算错——本 deal 无此腿故不触发,属本轮修复范围外");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 多次部分平仓"返回预付金"默认显示仍是初始值 bug 的回归测试(根因修复后应为全绿)。
|
||||
/// ---------------------------------------------------------------
|
||||
/// 生产铁证 GLMS-20260701-0008(SwapTradeId=1993,dev DB 192.168.2.96 / glms_yltrs_ylcms):
|
||||
/// 预付金腿(InterestMode=5) 双轨记录——
|
||||
/// orig 35798 (IsInitial=1, PosiDirection=0, InterestPrincipalFix=99,000) ← 期初腿,恒为初始值
|
||||
/// real 35871 (IsInitial=0, PositionId=35798, InterestPrincipalFix=66,813.12) ← 实时腿,已扣减 4 次平仓
|
||||
/// (9,900 + 15,840 + 3,663 + 2,783.88 = 32,186.88;99,000 − 32,186.88 = 66,813.12,与 dev 库实时腿完全勾稽)
|
||||
///
|
||||
/// 根因:GetUnwindInterests 的利息腿迭代源取 origPositions(IsInitial=1),其预付金腿
|
||||
/// InterestPrincipalFix 恒=99,000;而"当前剩余本金"66,813.12 存在 real 腿。GetInterests 算
|
||||
/// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv 都读 position.InterestPrincipalFix,
|
||||
/// 于是多次部分平仓后打开平仓页,"返回预付金"仍按初始 99,000 计算——完全不对。
|
||||
/// 首次平仓时 orig==real,掩盖了该 bug(解释"为何只修好一次部分平仓")。
|
||||
///
|
||||
/// 修复:SwapDealService.ResolveInterestLegPositions —— 迭代源仍用 origPositions(保留
|
||||
/// orig.id → eod_swap_position.PositionId 的日终匹配,全库 25,441 行 eod 均按 orig.id 归档,
|
||||
/// 换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿(初始5/追加6) Clone 覆盖
|
||||
/// InterestPrincipalFix 为实时腿剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。
|
||||
///
|
||||
/// 覆盖盲区说明:既有 SwapUnwindPrepayPrincipalBugTdd 的 19 个用例全部直接调 GetInterests
|
||||
/// 并只喂一条 IsInitial=true 的持仓,完全绕过 GetUnwindInterests 的 orig-vs-real 选择逻辑,
|
||||
/// 测不到本次 bug。本类直接单测抽出的纯函数 ResolveInterestLegPositions 以锁定该契约。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SwapUnwindPrepayOrigVsRealBugTdd
|
||||
{
|
||||
// 生产 GLMS-20260701-0008 精确值
|
||||
private const long OrigId = 35798;
|
||||
private const long RealId = 35871;
|
||||
private const decimal InitialFix = 99_000m; // orig 腿初始本金
|
||||
private const decimal RemainingFix = 66_813.12m; // real 腿剩余本金(已扣减 4 次平仓 9,900+15,840+3,663+2,783.88=32,186.88)
|
||||
|
||||
private static swap_position OrigPrepay(decimal fix = InitialFix, int mode = (int)InterestModeEnum.初始预付金)
|
||||
=> new swap_position
|
||||
{
|
||||
id = OrigId,
|
||||
SwapTradeId = 1993,
|
||||
PosiDirection = 0, // 利息端(收/支)
|
||||
InterestMode = mode,
|
||||
InterestPrincipalFix = fix,
|
||||
IsInitial = true,
|
||||
Invalid = false
|
||||
};
|
||||
|
||||
private static swap_position RealPrepay(long positionId = OrigId, decimal fix = RemainingFix, int mode = (int)InterestModeEnum.初始预付金)
|
||||
=> new swap_position
|
||||
{
|
||||
id = RealId,
|
||||
SwapTradeId = 1993,
|
||||
PositionId = positionId, // 指向对应 orig 的 id
|
||||
PosiDirection = 0,
|
||||
InterestMode = mode,
|
||||
InterestPrincipalFix = fix,
|
||||
IsInitial = false,
|
||||
Invalid = false
|
||||
};
|
||||
|
||||
[TestMethod]
|
||||
public void 多次部分平仓后_预付金腿本金应取实时腿剩余本金_而非原始腿初始值()
|
||||
{
|
||||
var origs = new List<swap_position> { OrigPrepay() };
|
||||
var reals = new List<swap_position> { RealPrepay() };
|
||||
|
||||
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
|
||||
|
||||
Assert.AreEqual(1, result.Count, "应保留 1 条利息腿");
|
||||
Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix,
|
||||
"多次部分平仓后:预付金腿本金应=实时腿剩余本金 66,813.12,而非原始腿初始值 99,000(bug 症状)");
|
||||
// 必须是 Clone,不能污染原始腿(原始腿要保留 99,000 供其他路径/审计)
|
||||
Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix,
|
||||
"修复必须走 Clone,绝不能就地改写 origPositions 的初始本金");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 首次平仓_实时腿等于原始腿_返回原始腿本身_零改动()
|
||||
{
|
||||
var origs = new List<swap_position> { OrigPrepay(InitialFix) };
|
||||
var reals = new List<swap_position> { RealPrepay(fix: InitialFix) }; // 尚未平仓,real==orig
|
||||
|
||||
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
|
||||
|
||||
Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "首次平仓 orig==real,本金保持初始值");
|
||||
Assert.AreSame(origs[0], result[0], "orig==real 时不应克隆,直接返回原始腿本身(行为与修复前一致)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 追加预付金腿_同样取实时腿剩余本金()
|
||||
{
|
||||
var origs = new List<swap_position> { OrigPrepay(InitialFix, (int)InterestModeEnum.追加预付金) };
|
||||
var reals = new List<swap_position> { RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.追加预付金) };
|
||||
|
||||
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
|
||||
|
||||
Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix,
|
||||
"追加预付金(mode=6)与初始预付金(mode=5)同源修复,同样取实时腿剩余本金");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 非预付金腿_不受影响_始终保持原始腿本金()
|
||||
{
|
||||
// 标的期初全价(=9)等非预付金腿:即便 real 腿本金不同也不应被覆盖(其本金语义不同,不走此纠正)
|
||||
var orig = OrigPrepay(InitialFix, (int)InterestModeEnum.标的期初全价);
|
||||
var real = RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.标的期初全价);
|
||||
var result = SwapDealService.ResolveInterestLegPositions(
|
||||
new List<swap_position> { orig }, new List<swap_position> { real });
|
||||
|
||||
Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "非预付金腿本金不被实时腿覆盖");
|
||||
Assert.AreSame(orig, result[0], "非预付金腿应原样返回,不克隆");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 无匹配实时腿_返回原始腿()
|
||||
{
|
||||
// real 腿 PositionId 指向别的 orig(或根本没有实时腿)→ 找不到匹配,保持原始腿
|
||||
var origs = new List<swap_position> { OrigPrepay() };
|
||||
var mismatched = new List<swap_position> { RealPrepay(positionId: 99999) };
|
||||
|
||||
var r1 = SwapDealService.ResolveInterestLegPositions(origs, mismatched);
|
||||
Assert.AreEqual(InitialFix, r1[0].InterestPrincipalFix, "无匹配实时腿:保持原始腿初始本金");
|
||||
|
||||
var r2 = SwapDealService.ResolveInterestLegPositions(origs, new List<swap_position>());
|
||||
Assert.AreEqual(InitialFix, r2[0].InterestPrincipalFix, "实时腿为空:保持原始腿初始本金");
|
||||
|
||||
var r3 = SwapDealService.ResolveInterestLegPositions(origs, null);
|
||||
Assert.AreEqual(InitialFix, r3[0].InterestPrincipalFix, "实时腿为 null:应容错并保持原始腿初始本金");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 只保留利息腿_过滤掉标的腿()
|
||||
{
|
||||
// PosiDirection>0 的标的腿不属于利息端,应被过滤(与原实现 Where(PosiDirection==0) 一致)
|
||||
var underlyingLeg = new swap_position
|
||||
{
|
||||
id = 40000, SwapTradeId = 1993, PosiDirection = 1,
|
||||
InterestMode = (int)InterestModeEnum.标的期初全价, IsInitial = true, Invalid = false
|
||||
};
|
||||
var origs = new List<swap_position> { OrigPrepay(), underlyingLeg };
|
||||
var reals = new List<swap_position> { RealPrepay() };
|
||||
|
||||
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
|
||||
|
||||
Assert.AreEqual(1, result.Count, "只应保留利息腿(PosiDirection==0),标的腿被过滤");
|
||||
Assert.AreEqual(OrigId, result[0].id, "保留的应是预付金利息腿");
|
||||
Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix, "且其本金已对齐实时剩余本金");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生产 Live Snapshot(2026-07-16 11:00,dev DB 192.168.2.96 / glms_yltrs_ylcms 直连核实):
|
||||
/// GLMS-20260701-0008 已 4 次部分平仓。预付金腿(orig 35798 / real 35871) 实际值——
|
||||
/// orig InterestPrincipalFix = 99,000(期初腿恒为初始值)
|
||||
/// real InterestPrincipalFix = 66,813.12(= 99,000 − 9,900 − 15,840 − 3,663 − 2,783.88)
|
||||
/// swap_flow_event 4 次平仓返还:9,900 / 15,840 / 3,663 / 2,783.88,合计 32,186.88。
|
||||
/// 本用例把这份真实数据硬编码进来,断言修复后取实时腿剩余本金 66,813.12(非 99,000),
|
||||
/// 作为该 deal 在此快照点的忠实回归;日后该 deal 再被平仓,剩余本金会变,本例仍应同步更新。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GLMS20260701_四次部分平仓_LiveSnapshot_预付金腿应取实时腿剩余66813_12()
|
||||
{
|
||||
// 与生产一致的双轨数据:期初腿 99,000 / 实时腿 4 次平仓后 66,813.12
|
||||
var origs = new List<swap_position> { OrigPrepay(InitialFix) };
|
||||
var reals = new List<swap_position> { RealPrepay(fix: 66_813.12m) };
|
||||
|
||||
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual(66_813.12m, result[0].InterestPrincipalFix,
|
||||
"4 次部分平仓后:预付金腿本金应=实时腿剩余本金 66,813.12,而非原始腿初始值 99,000");
|
||||
// 不污染原始腿
|
||||
Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix, "修复必须走 Clone,不能改写 origPositions 的初始本金 99,000");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 预付金(保证金)腿 平仓"应返还本金" bug 的回归测试(根因修复后应为全绿)。
|
||||
/// ---------------------------------------------------------------
|
||||
/// 业务预期:平仓"应返还本金"(swap_flow_event.InterestPrincipal) 应等于该预付金腿的
|
||||
/// 保证金本金(InterestPrincipalFix * closePercent),且与逐日利息计算无关;
|
||||
/// 同时预付金腿的逐日利息计息基数也应基于"保证金本金"自身,而非整笔交易的名义本金。
|
||||
///
|
||||
/// 根因:GetUnwindInterests 对全部腿统一用 orginPv = lastEod.NotionalValue ?? stockEqvNotional(整笔交易名义本金),
|
||||
/// 缺了"预付金腿用自身保证金"的分支;公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv
|
||||
/// 把交易名义本金(千万~亿级)当减项扣掉,使 InterestPrincipal 与计息基数变成巨负值。
|
||||
///
|
||||
/// 根因修复(SwapDealService.InitSwapDealInterest):对预付金腿(初始/追加)在利息计算前把
|
||||
/// orginPv 对齐为 position.InterestPrincipalFix,与日终路径(SwapEodPositionService)一致。
|
||||
/// 仅作用于 InterestMode 5/6;债券本金腿(标的期初全价=9)等仍用交易名义本金,不受影响。
|
||||
///
|
||||
/// 设计:标的名义本金 100万、预付金(保证金)本金 10万(维度不同,放大错配);
|
||||
/// 另含客户截图级 / 真实库 Trade1813 的精确复现用例。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SwapUnwindPrepayPrincipalBugTdd
|
||||
{
|
||||
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 UnderlyingNotional = 1_000_000m; // 标的名义本金(股票维度)
|
||||
private const decimal PrepayPrincipal = 100_000m; // 预付金/保证金本金(预付金维度)
|
||||
private const int AnnualDays = 365;
|
||||
private static readonly DateTime StartDate = new(2026, 4, 27);
|
||||
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
|
||||
private static readonly DateTime UnwindDate = new(2026, 4, 28);
|
||||
|
||||
private SwapDealService _svc;
|
||||
|
||||
[TestInitialize]
|
||||
public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindPrepayPrincipalBugTdd), OptUserFrom.UnitTest));
|
||||
|
||||
private static trade MakeTrade(decimal notional = UnderlyingNotional)
|
||||
{
|
||||
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-PREPAY-TDD", ClientId = 999998,
|
||||
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
|
||||
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
|
||||
StockEqvNotional = (double)notional, Notional = (double)notional,
|
||||
trade_extend = extend
|
||||
};
|
||||
}
|
||||
|
||||
private static swap_position MakePrepayPosition(decimal fix = PrepayPrincipal, decimal rate = 0.01m)
|
||||
{
|
||||
return new swap_position
|
||||
{
|
||||
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestRateDefault = rate, InterestPrincipalFix = fix,
|
||||
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
|
||||
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||||
IsAnnualized = true, interest_rest_days = 1,
|
||||
interest_rule = 0, FloatRateUnderlyingCode = null,
|
||||
InterestSwapInterval = "[]"
|
||||
};
|
||||
}
|
||||
|
||||
private swap_flow_event CalcUnwind(decimal closePercent, List<eod_swap_position> eodPositions)
|
||||
{
|
||||
eodPositions ??= new List<eod_swap_position>();
|
||||
var td = MakeTrade();
|
||||
var position = MakePrepayPosition();
|
||||
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
eodPositions, new List<swap_position> { position },
|
||||
UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, closePercent,
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
false, false, 0, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
|
||||
return interests[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户/真实库场景:自定义 标的名义本金(notional) 与 保证金本金(fix)。
|
||||
/// orginPv 用 notional(与 GetUnwindInterests 行为一致:lastEod.NotionalValue ?? stockEqvNotional)。
|
||||
/// </summary>
|
||||
private swap_flow_event CalcUnwindWith(decimal closePercent, List<eod_swap_position> eodPositions, decimal notional, decimal fix, decimal rate = 0.01m)
|
||||
{
|
||||
eodPositions ??= new List<eod_swap_position>();
|
||||
var td = MakeTrade(notional);
|
||||
var position = MakePrepayPosition(fix, rate);
|
||||
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
eodPositions, new List<swap_position> { position },
|
||||
notional, notional, notional, notional, closePercent,
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
|
||||
return interests[0];
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 无历史归档_全平_应返还本金应等于保证金本金()
|
||||
{
|
||||
var fe = CalcUnwind(1m, null); // 无 eod 归档 → preEod.id==0
|
||||
Console.WriteLine($"[TDD] 无归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})");
|
||||
Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal,
|
||||
"无归档全平: InterestPrincipal(应返还本金) 应=保证金本金(预付金本金),不应被利息公式改写为含 -orginPv 与 double 的怪值");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 有历史归档_全平_应返还本金应等于保证金本金()
|
||||
{
|
||||
var eod = new List<eod_swap_position>
|
||||
{
|
||||
new eod_swap_position
|
||||
{
|
||||
id = 1, SwapTradeId = 1, PositionId = 1001,
|
||||
ValueDate = new DateTime(2026, 4, 27),
|
||||
TdInterestPrincipal = PrepayPrincipal,
|
||||
PosiNotionalValue = PrepayPrincipal,
|
||||
InterestProfitSum = 0m
|
||||
}
|
||||
};
|
||||
var fe = CalcUnwind(1m, eod);
|
||||
Console.WriteLine($"[TDD] 有归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})");
|
||||
Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal,
|
||||
"有归档全平: 计息区间被跳过,InterestPrincipal 应保持初始正确值=保证金本金");
|
||||
}
|
||||
|
||||
// ---- 客户截图级 / 真实库场景(验证"前后是否真 Fix")----
|
||||
|
||||
[TestMethod]
|
||||
public void 客户截图级_全平_应返还本金应等于保证金本金()
|
||||
{
|
||||
// 生产铁证(用户提供真实交易):TradeAmount=3亿,StockEqvNotional=306,191,860.26,
|
||||
// StructureType=普通债券类收益互换;预付金腿 swap_position id=34009 InterestMode=5
|
||||
// InterestPrincipalFix=9,185,755.81。
|
||||
// swap_flow_event(该腿, mode5) 三条:
|
||||
// 9202 EventId=null dir2 IP=9,185,755.81 (建仓支付预付金 ✓)
|
||||
// 9489 EventId=15997 dir1 IP=-287,820,348.64 (平仓, 盘中路径 BUG ✗)
|
||||
// 9492 EventId=15998 dir1 IP=9,185,755.81 (平仓, EOD正确路径 ✓)
|
||||
// 同一腿出现"盘中错 / EOD对"两条平仓记录,恰好佐证修复方向(盘中 orginPv 对齐 EOD=Fix)正确。
|
||||
// 根因复现:2*Fix - Notional = 2*9,185,755.81 - 306,191,860.26 = -287,820,348.64(与生产 15997 精确 0 误差)。
|
||||
// 该预付金腿三条 event 的 InterestAmount 全=0(债券类预付金腿不计息),
|
||||
// 故本笔生产仅 InterestPrincipal 中招、计息基数未受影响 → rate=0 贴合生产。
|
||||
const decimal notional = 306_191_860.26m;
|
||||
const decimal fix = 9_185_755.81m;
|
||||
var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m);
|
||||
Console.WriteLine($"[TDD][客户] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})");
|
||||
Assert.AreEqual(fix, fe.InterestPrincipal,
|
||||
"客户级: 应返还本金应=保证金本金 9,185,755.81,不应被算成 -287,820,348.64");
|
||||
Assert.AreEqual(0m, fe.InterestAmount,
|
||||
"客户级: 该预付金腿不计息,InterestAmount 应=0(与生产三条 event 全为 0 一致);仅 InterestPrincipal 中招");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 真实库Trade1813_全平_应返还本金应等于保证金本金()
|
||||
{
|
||||
// 测试库 Trade=1813 / Pos=34204:Fix=35,140,Notional=12,100,000,
|
||||
// 实际存储 InterestPrincipal=-12,029,720.00(=2*35,140-12,100,000,公式精确 0 误差)。
|
||||
// 同属债券类预付金腿(与生产同模式,不计息),rate=0 贴合生产,仅验证 InterestPrincipal 修复。
|
||||
const decimal notional = 12_100_000m;
|
||||
const decimal fix = 35_140m;
|
||||
var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m);
|
||||
Console.WriteLine($"[TDD][Trade1813] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})");
|
||||
Assert.AreEqual(fix, fe.InterestPrincipal,
|
||||
"Trade1813: 应返还本金应=保证金本金 35,140,不应被算成 -12,029,720.00");
|
||||
Assert.AreEqual(0m, fe.InterestAmount,
|
||||
"Trade1813: 同属债券类预付金腿不计息,InterestAmount 应=0;仅 InterestPrincipal 中招");
|
||||
}
|
||||
|
||||
// ---- 多次部分平仓(验证最小修复是否覆盖"多次部分成交")----
|
||||
|
||||
[TestMethod]
|
||||
public void 多次部分平仓_显示值每次返回比例份额且总计等于保证金()
|
||||
{
|
||||
// 模拟分 3 次平仓:0.3 / 0.5 / 1.0(剩余)。每次传入的 fix = 该次剩余保证金本金
|
||||
// (真实系统中每次部分平仓后 position.InterestPrincipalFix 会被扣减,下一笔用剩余值)。
|
||||
// 根因修复后:InterestPrincipal 由利息公式基于 Fix 正确得出 = fix * closePercent。
|
||||
decimal total = 0;
|
||||
var r1 = CalcUnwindWith(0.3m, null, 306_191_860.26m, 100_000m);
|
||||
total += r1.InterestPrincipal;
|
||||
var r2 = CalcUnwindWith(0.5m, null, 306_191_860.26m, 70_000m); // 剩余 7万
|
||||
total += r2.InterestPrincipal;
|
||||
var r3 = CalcUnwindWith(1.0m, null, 306_191_860.26m, 35_000m); // 剩余 3.5万
|
||||
total += r3.InterestPrincipal;
|
||||
|
||||
Console.WriteLine($"[TDD][多次部分] r1={r1.InterestPrincipal} r2={r2.InterestPrincipal} r3={r3.InterestPrincipal} 合计={total}");
|
||||
Assert.AreEqual(30_000m, r1.InterestPrincipal, "第1次(30%)应返还 3万");
|
||||
Assert.AreEqual(35_000m, r2.InterestPrincipal, "第2次(50% of 剩余7万)应返还 3.5万");
|
||||
Assert.AreEqual(35_000m, r3.InterestPrincipal, "第3次(剩余全平)应返还 3.5万");
|
||||
Assert.AreEqual(100_000m, total, "多次部分平仓合计应=保证金本金 10万");
|
||||
}
|
||||
|
||||
// ---- 盘中路径 CalcDailySimpleInterest 的 closePercent^N 指数级缩小 bug ----
|
||||
// 生产铁证 GLMS-20260701-0006:预付金腿 Fix=9,180,000、interest_rest_days=7、单利、不计息。
|
||||
// 平仓弹窗(swaptrade2/GetUnwindInterestList → 盘中路径 CalcDailySimpleInterest)返回:
|
||||
// 100% → 9,180,000 (对) 50% → 71,718.75 (错) 10% → 0.918 (错)
|
||||
// 数学关系精确成立:9,180,000×0.5^7 = 71,718.75、9,180,000×0.1^7 = 0.918。
|
||||
// 根因:CalcDailySimpleInterest 非重置日 else 分支
|
||||
// flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent;
|
||||
// tdDynomicPrincipal = flowEvent.InterestPrincipal; // ★把"已×closePercent"的值回填
|
||||
// 使下一个非重置日再乘一次 closePercent → InterestPrincipal = Fix × closePercent^N(N=计息天数),
|
||||
// 而正确应为 Fix × closePercent(线性,与日终 CalcDailySimpleInterestByEod:1164-1165 只乘一次一致)。
|
||||
// 现有 6 个用例 interest_rest_days=1 且 UnwindDate=StartDate+1(calcDays=1),循环首尾都被 continue 跳过、
|
||||
// 从不进 else,故漏掉此 bug;本组用例用 restDays=7、跨多日、带 eod 归档触发 else 累积复现之。
|
||||
private const decimal ProdPrepayFix = 9_180_000m;
|
||||
private static readonly DateTime ProdPosiStart = new(2026, 7, 2);
|
||||
private static readonly DateTime ProdEodValueDate = new(2026, 7, 4);
|
||||
private static readonly DateTime ProdUnwindDate = new(2026, 7, 13);
|
||||
|
||||
/// <summary>
|
||||
/// 盘中路径复现:restDays=7、PosiStart→Unwind 跨 11 天、eod 归档到 07-04。
|
||||
/// 与生产 GLMS-20260701-0006 完全对齐,buggy 代码产出 Fix × closePercent^7。
|
||||
/// </summary>
|
||||
private swap_flow_event CalcUnwindMultiDay(decimal closePercent, decimal fix = ProdPrepayFix, int restDays = 7,
|
||||
decimal rate = 0m)
|
||||
{
|
||||
var extend = new trade_extend
|
||||
{
|
||||
TradeId = 1,
|
||||
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||||
{
|
||||
AnnualDays = AnnualDays,
|
||||
InterestCalcMode = "10", // 算头不算尾(与生产一致)
|
||||
SettlementRules = 0
|
||||
})
|
||||
};
|
||||
var td = new trade
|
||||
{
|
||||
id = 1, TradeNumber = "UT-PREPAY-EXP", ClientId = 999998,
|
||||
TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart,
|
||||
ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
|
||||
StockEqvNotional = (double)fix, Notional = (double)fix,
|
||||
trade_extend = extend
|
||||
};
|
||||
var position = new swap_position
|
||||
{
|
||||
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestRateDefault = rate, InterestPrincipalFix = fix,
|
||||
PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1),
|
||||
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||||
IsAnnualized = true, interest_rest_days = restDays,
|
||||
interest_rule = 0, FloatRateUnderlyingCode = null,
|
||||
InterestSwapInterval = "[]"
|
||||
};
|
||||
var eod = new List<eod_swap_position>
|
||||
{
|
||||
new eod_swap_position
|
||||
{
|
||||
id = 7, SwapTradeId = 1, PositionId = 1001,
|
||||
ValueDate = ProdEodValueDate,
|
||||
TdInterestPrincipal = fix, // 生产 eod_swap_position(35774) TdInterestPrincipal=9,180,000
|
||||
PosiNotionalValue = fix,
|
||||
InterestProfitSum = 0m, FloatRate = 0m
|
||||
}
|
||||
};
|
||||
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
|
||||
eod, new List<swap_position> { position },
|
||||
fix, fix, fix, fix, closePercent,
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
false, false, 0, fix, false, settment: false, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
|
||||
return interests[0];
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 部分平仓50_盘中重置周期7天_应返还本金应线性缩放而非指数级()
|
||||
{
|
||||
var fe = CalcUnwindMultiDay(0.5m);
|
||||
Console.WriteLine($"[TDD][盘中50%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=71,718.75, 期望=4,590,000)");
|
||||
// 正确:Fix × closePercent = 9,180,000 × 0.5 = 4,590,000(100%返 9,180,000 的一半)。
|
||||
// buggy:Fix × 0.5^7 = 71,718.75(生产实测),被指数级缩小 ~64 倍。
|
||||
Assert.AreEqual(4_590_000m, fe.InterestPrincipal,
|
||||
"50% 平仓: 应返还本金应=Fix×0.5=4,590,000,不应被 closePercent^7 缩成 71,718.75");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 部分平仓10_盘中重置周期7天_应返还本金应线性缩放而非指数级()
|
||||
{
|
||||
var fe = CalcUnwindMultiDay(0.1m);
|
||||
Console.WriteLine($"[TDD][盘中10%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=0.918, 期望=918,000)");
|
||||
// 正确:Fix × 0.1 = 918,000。buggy:Fix × 0.1^7 = 0.918(生产实测),缩小 100 万倍。
|
||||
Assert.AreEqual(918_000m, fe.InterestPrincipal,
|
||||
"10% 平仓: 应返还本金应=Fix×0.1=918,000,不应被 closePercent^7 缩成 0.918");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 全平_盘中重置周期7天_应返还本金应等于保证金本金()
|
||||
{
|
||||
// closePercent=1 → 1^N=1,指数 bug 对 100% 无影响(故用户看 100% 正常),此用例锚定不回归。
|
||||
var fe = CalcUnwindMultiDay(1m);
|
||||
Console.WriteLine($"[TDD][盘中100%] 实测 InterestPrincipal={fe.InterestPrincipal} (期望=9,180,000)");
|
||||
Assert.AreEqual(ProdPrepayFix, fe.InterestPrincipal,
|
||||
"100% 平仓: 应返还本金应=Fix=9,180,000(closePercent=1 时指数 bug 不显现,须保持正确)");
|
||||
}
|
||||
|
||||
// ---- 非预付金腿(标的期初全价=9)同样验证:证明修复对所有"单利盘中"腿通用且正确 ----
|
||||
// CalcDailySimpleInterest 是所有单利腿(InterestType=0)的盘中计息通用函数,非预付金专用。
|
||||
// 用户关切:修复会否波及非预付金腿?结论——
|
||||
// · closePercent=1(日常计息/全平)时 1^N=1=1^1,修复前后逐位恒等,零影响;
|
||||
// · closePercent<1(部分平仓)时,所有单利腿此前都被同一 bug 指数级缩小,修复后统一为
|
||||
// 正确的线性缩放(平仓 X% => 本金×X),这是修正而非破坏。
|
||||
// 本组用非预付金腿(标的期初全价=9,orginPv 不被对齐为 Fix、走交易名义本金)独立复现并锁定。
|
||||
private const decimal NonPrepayNotional = 1_000_000m;
|
||||
|
||||
private swap_flow_event CalcUnwindMultiDayNonPrepay(decimal closePercent, decimal notional = NonPrepayNotional,
|
||||
int restDays = 7, decimal rate = 0m)
|
||||
{
|
||||
var extend = new trade_extend
|
||||
{
|
||||
TradeId = 1,
|
||||
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||||
{
|
||||
AnnualDays = AnnualDays,
|
||||
InterestCalcMode = "10", // 算头不算尾(与生产一致)
|
||||
SettlementRules = 0
|
||||
})
|
||||
};
|
||||
var td = new trade
|
||||
{
|
||||
id = 1, TradeNumber = "UT-NONPREPAY-EXP", ClientId = 999998,
|
||||
TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart,
|
||||
ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
|
||||
StockEqvNotional = (double)notional, Notional = (double)notional,
|
||||
trade_extend = extend
|
||||
};
|
||||
var position = new swap_position
|
||||
{
|
||||
id = 2002, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.标的期初全价, // 非预付金腿(=9):orginPv 不会被对齐为 Fix
|
||||
InterestRateDefault = rate, InterestPrincipalFix = 0m,
|
||||
PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1),
|
||||
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||||
IsAnnualized = true, interest_rest_days = restDays,
|
||||
interest_rule = 0, FloatRateUnderlyingCode = null,
|
||||
InterestSwapInterval = "[]"
|
||||
};
|
||||
var eod = new List<eod_swap_position>
|
||||
{
|
||||
new eod_swap_position
|
||||
{
|
||||
id = 8, SwapTradeId = 1, PositionId = 2002,
|
||||
ValueDate = ProdEodValueDate,
|
||||
TdInterestPrincipal = notional, // 计息基数=名义本金 → dynomicPrincipal = eodTd + notional - orginPv = notional
|
||||
PosiNotionalValue = notional,
|
||||
InterestProfitSum = 0m, FloatRate = 0m
|
||||
}
|
||||
};
|
||||
// orginPv 传 notional:非预付金腿不走 877-881 的 Fix 对齐,dynomicPrincipal = notional + notional - notional = notional
|
||||
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
|
||||
eod, new List<swap_position> { position },
|
||||
notional, notional, notional, notional * closePercent, closePercent,
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count, "非预付金腿应生成 1 条 flow_event");
|
||||
return interests[0];
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 非预付金腿_部分平仓50_盘中重置周期7天_应线性缩放不受指数bug影响()
|
||||
{
|
||||
var fe = CalcUnwindMultiDayNonPrepay(0.5m);
|
||||
Console.WriteLine($"[TDD][非预付金50%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=7,812.5, 期望=500,000)");
|
||||
// 正确:N×0.5=500,000。buggy:N×0.5^7=7,812.5(同一指数 bug,证明非预付金腿此前也中招)。
|
||||
Assert.AreEqual(500_000m, fe.InterestPrincipal,
|
||||
"非预付金腿(标的期初全价) 50% 平仓应=名义本金×0.5=500,000,不应被 closePercent^7 缩小");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 非预付金腿_部分平仓10_盘中重置周期7天_应线性缩放不受指数bug影响()
|
||||
{
|
||||
var fe = CalcUnwindMultiDayNonPrepay(0.1m);
|
||||
Console.WriteLine($"[TDD][非预付金10%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=0.1, 期望=100,000)");
|
||||
Assert.AreEqual(100_000m, fe.InterestPrincipal,
|
||||
"非预付金腿(标的期初全价) 10% 平仓应=名义本金×0.1=100,000,不应被 closePercent^7 缩小");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 非预付金腿_全平_修复前后恒等_零影响()
|
||||
{
|
||||
// closePercent=1 时 1^N=1=1^1:这是"修复不波及非平仓/全平计息"的数学不变量证明。
|
||||
var fe = CalcUnwindMultiDayNonPrepay(1m);
|
||||
Console.WriteLine($"[TDD][非预付金100%] 实测 InterestPrincipal={fe.InterestPrincipal} (期望=1,000,000)");
|
||||
Assert.AreEqual(NonPrepayNotional, fe.InterestPrincipal,
|
||||
"非预付金腿 全平应=名义本金(closePercent=1 时修复前后恒等,日常计息/全平零影响)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 多次部分平仓_计息基数也被根因修复_利息基于保证金本金()
|
||||
{
|
||||
// 显式带息加固用例(合成,非用户那笔生产的真实症状):
|
||||
// 用户那笔生产(3亿债券类TRS)预付金腿不计息(InterestAmount 全=0),仅 InterestPrincipal 中招;
|
||||
// 本例用 rate=0.01 构造"若该腿计息"的场景,验证根因修复后计息基数也基于保证金本金自身
|
||||
// (而非交易名义本金):InterestAmount 为小额正、且 < fix。
|
||||
const decimal notional = 306_191_860.26m;
|
||||
const decimal fix = 9_185_755.81m;
|
||||
var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0.01m);
|
||||
|
||||
Assert.AreEqual(fix, fe.InterestPrincipal, "显示值(应返还本金)已=保证金本金");
|
||||
Console.WriteLine($"[TDD][计息基数] InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount}");
|
||||
Assert.IsTrue(fe.InterestAmount > 0,
|
||||
"根因修复后(显式带息): 预付金腿 InterestAmount 应基于保证金本金算出小额正值(约 fix*rate),不再是巨负");
|
||||
Assert.IsTrue(fe.InterestAmount < fix,
|
||||
"利息基数必须为保证金维度(远小于 fix),证明 orginPv 已用预付金自身 Fix,而非交易名义本金 notional");
|
||||
}
|
||||
|
||||
// ===== 覆盖完整性补强:所有单利腿模式 + 日终路径 =====
|
||||
// 调用链事实(已用代码确认):
|
||||
// CalcDailySimpleInterest 的唯一真实调用链 = GetInterests(settment=false) → CalcUnwindInterest → 本函数。
|
||||
// 日终(settment=true)走 CalcEodInterest → CalcDailySimpleInterestByEod(closePercent 硬编码 1m、
|
||||
// 且该函数从不改写 InterestPrincipal),根本不调用本函数。故"含日终"的正确命题是:
|
||||
// 日终不受本 bug 影响,且应有用例锁定这一不变量。
|
||||
// 本组用同一入口驱动各 InterestMode 在 closePercent<1 + rest_days=7 多天场景,断言
|
||||
// InterestPrincipal = closePrincipal(线性),捕捉任何指数级回归;并显式加日终(settment=true)用例,
|
||||
// 断言日终结果恒为线性 closePrincipal(证明日终不受盘中 bug 影响,与正确的 ByEod 变体对齐)。
|
||||
private swap_flow_event CalcByMode(int mode, decimal baseP, decimal closePercent, int restDays = 7, bool eodPath = false)
|
||||
{
|
||||
var extend = new trade_extend
|
||||
{
|
||||
TradeId = 1,
|
||||
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||||
{
|
||||
AnnualDays = AnnualDays,
|
||||
InterestCalcMode = "10", // 算头不算尾(与生产一致)
|
||||
SettlementRules = 0
|
||||
})
|
||||
};
|
||||
var td = new trade
|
||||
{
|
||||
id = 1, TradeNumber = "UT-MODE-COV", ClientId = 999998,
|
||||
TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart,
|
||||
ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
|
||||
StockEqvNotional = (double)baseP, Notional = (double)baseP,
|
||||
trade_extend = extend
|
||||
};
|
||||
bool isPrepayOrFixed = mode == (int)InterestModeEnum.初始预付金
|
||||
|| mode == (int)InterestModeEnum.追加预付金
|
||||
|| mode == (int)InterestModeEnum.固定值;
|
||||
var position = new swap_position
|
||||
{
|
||||
id = 3003, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = mode,
|
||||
InterestRateDefault = 0m,
|
||||
InterestPrincipalFix = isPrepayOrFixed ? baseP : 0m,
|
||||
PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1),
|
||||
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||||
IsAnnualized = true, interest_rest_days = restDays,
|
||||
interest_rule = 0, FloatRateUnderlyingCode = null,
|
||||
InterestSwapInterval = "[]"
|
||||
};
|
||||
// 使 dynomicPrincipal = posiPrincipal:eod.TdInterestPrincipal = orginPv(=baseP),
|
||||
// 非预付金腿 orginPv 传 baseP;预付金/固定值腿 orginPv 被内部对齐为 Fix=baseP(同样成立)。
|
||||
var eodPos = new List<eod_swap_position>
|
||||
{
|
||||
new eod_swap_position
|
||||
{
|
||||
id = 30, SwapTradeId = 1, PositionId = 3003,
|
||||
ValueDate = ProdEodValueDate,
|
||||
TdInterestPrincipal = baseP,
|
||||
PosiNotionalValue = baseP,
|
||||
InterestProfitSum = 0m, FloatRate = 0m
|
||||
}
|
||||
};
|
||||
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
|
||||
eodPos, new List<swap_position> { position },
|
||||
baseP, baseP, baseP, baseP * closePercent, closePercent,
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
false, false, 0, baseP, false, settment: eodPath, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count, $"mode={mode} 应生成 1 条 flow_event");
|
||||
return interests[0];
|
||||
}
|
||||
|
||||
// ---- 追加预付金(6):与初始预付金(5)同源修复,显式覆盖避免遗漏 ----
|
||||
[TestMethod]
|
||||
public void 追加预付金腿_盘中_部分平仓重置周期7天_应线性缩放()
|
||||
{
|
||||
var fe = CalcByMode((int)InterestModeEnum.追加预付金, ProdPrepayFix, 0.5m);
|
||||
Assert.AreEqual(4_590_000m, fe.InterestPrincipal, "追加预付金 50% 应=Fix×0.5(与初始预付金同源修复)");
|
||||
var fe1 = CalcByMode((int)InterestModeEnum.追加预付金, ProdPrepayFix, 0.1m);
|
||||
Assert.AreEqual(918_000m, fe1.InterestPrincipal, "追加预付金 10% 应=Fix×0.1");
|
||||
}
|
||||
|
||||
// ---- 多头/空头存续名义本金(7/8):经同一 CalcDailySimpleInterest,需证明修复通用 ----
|
||||
[TestMethod]
|
||||
public void 多头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放()
|
||||
{
|
||||
const decimal baseP = 2_000_000m;
|
||||
var fe = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.5m);
|
||||
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "多头存续 50% 应=posiLong×0.5");
|
||||
var fe1 = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.1m);
|
||||
Assert.AreEqual(200_000m, fe1.InterestPrincipal, "多头存续 10% 应=posiLong×0.1");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 空头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放()
|
||||
{
|
||||
const decimal baseP = 2_000_000m;
|
||||
var fe = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.5m);
|
||||
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "空头存续 50% 应=posiShort×0.5");
|
||||
var fe1 = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.1m);
|
||||
Assert.AreEqual(200_000m, fe1.InterestPrincipal, "空头存续 10% 应=posiShort×0.1");
|
||||
}
|
||||
|
||||
// ---- 合约名义本金规模(2):CalcNotionalByMode 默认分支(posiNotional×cp) ----
|
||||
[TestMethod]
|
||||
public void 合约名义本金规模腿_盘中_部分平仓重置周期7天_应线性缩放()
|
||||
{
|
||||
const decimal baseP = 2_000_000m;
|
||||
var fe = CalcByMode((int)InterestModeEnum.合约名义本金规模, baseP, 0.5m);
|
||||
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "合约名义本金规模 50% 应=posiNotional×0.5");
|
||||
}
|
||||
|
||||
// ---- 固定值(1):CalcNotionalByMode 强制 newClosePercent=1,对 closePercent 免疫(输入 0.5 也不缩放) ----
|
||||
[TestMethod]
|
||||
public void 固定值腿_盘中_部分平仓_对平仓比例免疫_返回Fix本金()
|
||||
{
|
||||
const decimal baseP = 2_000_000m;
|
||||
var fe = CalcByMode((int)InterestModeEnum.固定值, baseP, 0.5m);
|
||||
Assert.AreEqual(baseP, fe.InterestPrincipal, "固定值腿 newClosePercent=1,InterestPrincipal 恒=Fix,不随平仓比例缩放");
|
||||
}
|
||||
|
||||
// ---- 日终路径(settment=true):证明走 CalcDailySimpleInterestByEod,结果恒为线性 closePrincipal,不受盘中 bug 影响 ----
|
||||
[TestMethod]
|
||||
public void 日终_预付金腿_部分平仓_结果应线性且不受盘中bug影响()
|
||||
{
|
||||
var fe = CalcByMode((int)InterestModeEnum.初始预付金, ProdPrepayFix, 0.5m, eodPath: true);
|
||||
Console.WriteLine($"[TDD][EOD 预付金50%] InterestPrincipal={fe.InterestPrincipal} (期望={4_590_000m})");
|
||||
Assert.AreEqual(4_590_000m, fe.InterestPrincipal, "日终预付金 50% 应=Fix×0.5(ByEod 正确变体,closePercent 走 closePrincipal 线性)");
|
||||
var fe1 = CalcByMode((int)InterestModeEnum.初始预付金, ProdPrepayFix, 0.1m, eodPath: true);
|
||||
Assert.AreEqual(918_000m, fe1.InterestPrincipal, "日终预付金 10% 应=Fix×0.1");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 日终_非预付金腿_部分平仓_结果应线性且不受盘中bug影响()
|
||||
{
|
||||
const decimal baseP = 2_000_000m;
|
||||
var fe = CalcByMode((int)InterestModeEnum.标的期初全价, baseP, 0.5m, eodPath: true);
|
||||
Console.WriteLine($"[TDD][EOD 标的期初全价50%] InterestPrincipal={fe.InterestPrincipal} (期望={1_000_000m})");
|
||||
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "日终非预付金腿 50% 应=名义本金×0.5(ByEod 正确,不受影响)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 互换平仓全流程测试(SwapUnwind/ApproveSwapTrade/ApplySwapTrade/DealFloatPosition)
|
||||
/// ============================================================================
|
||||
/// 借鉴 testable 分支 SwapUnwindScenarioTest,基于当前分支 TestableSwapDealService 共享 stub。
|
||||
/// 命名规范说明(见《互换价格字段命名规范决策文档》):
|
||||
/// PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice
|
||||
/// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice
|
||||
/// ============================================================================
|
||||
[TestClass]
|
||||
public class SwapUnwindScenarioTest
|
||||
{
|
||||
// ================================================================
|
||||
// 场景1:SwapUnwind 全平仓 —— 持仓归零、TradeStatus=已平仓
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void UW_001_SwapUnwind_全平仓_持仓归零且资金流水正确()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
var service = new TestableSwapDealService(td);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(
|
||||
swapRealizedPnL: 5000m, swapMarginAmount: 0m,
|
||||
closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m,
|
||||
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
|
||||
|
||||
service.SwapUnwind(unwindData);
|
||||
|
||||
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水");
|
||||
Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action);
|
||||
Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓");
|
||||
Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind");
|
||||
Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0");
|
||||
Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0");
|
||||
Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)");
|
||||
Console.WriteLine($"UW_001: TradeStatus={td.TradeStatus}, StockEqvNotional={td.StockEqvNotional} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景2:SwapUnwind 部分平仓 —— HasPartialUnWind=1,TradeStatus 不变
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void UW_002_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
var service = new TestableSwapDealService(td);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(
|
||||
swapRealizedPnL: 3000m, swapMarginAmount: 0m,
|
||||
closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m,
|
||||
closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m);
|
||||
|
||||
service.SwapUnwind(unwindData);
|
||||
|
||||
Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1");
|
||||
Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变");
|
||||
Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000");
|
||||
Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000");
|
||||
Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水");
|
||||
Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL");
|
||||
Console.WriteLine($"UW_002: HasPartialUnWind={td.HasPartialUnWind}, TradeStatus={td.TradeStatus} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景3:SwapUnwind 含预付金 —— 两条资金流水
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void UW_003_SwapUnwind_含预付金_两条资金流水()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
var service = new TestableSwapDealService(td);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(
|
||||
swapRealizedPnL: 5000m, swapMarginAmount: 2000m,
|
||||
closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m,
|
||||
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
|
||||
|
||||
service.SwapUnwind(unwindData);
|
||||
|
||||
Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水");
|
||||
Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action);
|
||||
Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金");
|
||||
Assert.AreEqual(ClientCashInCashOut.系统操作_应付预付金, service.ClientCashCalls[1].action);
|
||||
Console.WriteLine($"UW_003: 平仓费={service.ClientCashCalls[0].amount}, 应付预付金={service.ClientCashCalls[1].amount} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景4:DealFloatPosition 含费价重算(后端唯二真做计算的地方)
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 平仓事件重算三字段(SwapDealService DealFloatPosition):
|
||||
/// TradingAmountFeeAvg(ExitDirtyFeePrice) = TradingAmountAvg(ExitDirtyPrice) + Fee/CloseQty × shortRatio
|
||||
/// TradingAmountNetFeeAvg(ExitCleanFeePrice) = TradingAmountNetAvg(ExitCleanPrice) + Fee/CloseQty × shortRatio
|
||||
/// TradingAmount = TradingAmountAvg × CloseQty
|
||||
/// 手算:ExitDirtyPrice=1.02, Fee=50, CloseQty=1000, Long(shortRatio=-1)
|
||||
/// ExitDirtyFeePrice = 1.02 + 50/1000×(-1) = 0.97
|
||||
/// ExitCleanFeePrice = 1.00 + 50/1000×(-1) = 0.95
|
||||
/// TradingAmount = 1.02 × 1000 = 1020
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void UW_004_DealFloatPosition_含费价重算正确()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
var service = new TestableSwapDealService(td);
|
||||
var closeEvent = new swap_flow_event
|
||||
{
|
||||
EventType = (int)SwapEventTypeEnum.平仓,
|
||||
PositionType = (int)PositionTypeFlag.Long,
|
||||
TradingAmountAvg = 1.02m, // ExitDirtyPrice
|
||||
TradingAmountNetAvg = 1.00m, // ExitCleanPrice
|
||||
TradingFeePending = 50m,
|
||||
};
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m);
|
||||
unwindData.FlowEvents.Add(closeEvent);
|
||||
|
||||
service.SwapUnwind(unwindData);
|
||||
|
||||
Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m,
|
||||
$"TradingAmountFeeAvg(ExitDirtyFeePrice)=ExitDirtyPrice+Fee/Qty×(-1)=0.97");
|
||||
Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m,
|
||||
$"TradingAmountNetFeeAvg(ExitCleanFeePrice)=ExitCleanPrice+Fee/Qty×(-1)=0.95");
|
||||
Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m,
|
||||
$"TradingAmount=ExitDirtyPrice×CloseQty=1020");
|
||||
Console.WriteLine($"UW_004: ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg}, TradingAmount={closeEvent.TradingAmount} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景5:ApproveSwapTrade 审核通过全平仓 —— 反序列化事件并记账
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void UW_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
td.ExerciseDate = new DateTime(2026, 12, 31);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 8000m,
|
||||
closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m,
|
||||
closeQty: 10000m, closeNotionalValue: 1000000m);
|
||||
var swapEvent = new swap_event
|
||||
{
|
||||
id = 1, SwapTradeId = SwapDealTestFactory.SwapTradeId,
|
||||
EventType = (int)SwapEventTypeEnum.平仓, Invalid = false,
|
||||
EventData = JsonConvert.SerializeObject(unwindData)
|
||||
};
|
||||
var flowEvents = new Dictionary<long, List<swap_flow_event>>
|
||||
{
|
||||
[1] = new List<swap_flow_event> { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } }
|
||||
};
|
||||
var service = new TestableSwapDealService(td,
|
||||
swapEvents: new Dictionary<int, swap_event> { [(int)SwapEventTypeEnum.平仓] = swapEvent },
|
||||
flowEventsByEventId: flowEvents);
|
||||
|
||||
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓);
|
||||
|
||||
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水");
|
||||
Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL");
|
||||
Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓");
|
||||
Console.WriteLine($"UW_005: 反序列化SwapRealizedPnL=8000, 资金流水={service.ClientCashCalls[0].amount}, TradeStatus={td.TradeStatus} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景6:ApplySwapTrade 提交审核 —— 前置校验与保存事件
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void UW_006_ApplySwapTrade_提交审核_前置校验与保存事件()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
var service = new TestableSwapDealService(td);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m);
|
||||
unwindData.SwapCloseAmount = 6000m;
|
||||
|
||||
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓);
|
||||
|
||||
Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次");
|
||||
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
|
||||
Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓");
|
||||
Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m,
|
||||
"SwapRealizedPnL 应=SwapCloseAmount(6000)");
|
||||
Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景7:前端传"占期初(A)"语义,后端入口转"占剩余(B)" —— 全平判定
|
||||
// 原始名义本金 100M / 剩余 60M,前端传 A=0.6(平掉原始 60M = 剩余全部)
|
||||
// B = A × Notional/Posi = 0.6 × 100/60 = 1.0 → 触发全平
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void UW_007_SwapUnwind_占期初A转占剩余B_全平判定正确()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
var service = new TestableSwapDealService(td);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(
|
||||
swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 0.6m,
|
||||
closeQty: 600000m, closeNotionalValue: 600000m, positionQty: 600000m);
|
||||
unwindData.NotionalValue = 1000000m; // 期初名义本金
|
||||
unwindData.PosiNotionalValue = 600000m; // 剩余名义本金
|
||||
|
||||
service.SwapUnwind(unwindData);
|
||||
|
||||
// 桩 SaveSwapDeal 收集的是转换后的 B(落库 A 还原在生产 SaveSwapDealInternal 中,桩跳过)
|
||||
Assert.AreEqual(1.0m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m,
|
||||
"入口 A=0.6 应转为 B=1.0(占剩余全平)");
|
||||
Assert.AreEqual("已平仓", td.TradeStatus, "B==1 触发全平 TradeStatus=已平仓");
|
||||
Console.WriteLine($"UW_007: A=0.6→B={service.SaveSwapDealCalls[0].data.ClosePercent}, TradeStatus={td.TradeStatus} ✅");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景8:占期初(A)转占剩余(B) —— 部分平仓
|
||||
// 原始 100M / 剩余 60M,前端传 A=0.3(平掉原始 30M = 剩余的 50%)
|
||||
// B = A × Notional/Posi = 0.3 × 100/60 = 0.5 → 部分平仓
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void UW_008_SwapUnwind_占期初A转占剩余B_部分平仓正确()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
var service = new TestableSwapDealService(td);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(
|
||||
swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.3m,
|
||||
closeQty: 300000m, closeNotionalValue: 300000m, positionQty: 600000m);
|
||||
unwindData.NotionalValue = 1000000m; // 期初名义本金
|
||||
unwindData.PosiNotionalValue = 600000m; // 剩余名义本金
|
||||
|
||||
service.SwapUnwind(unwindData);
|
||||
|
||||
Assert.AreEqual(0.5m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m,
|
||||
"入口 A=0.3 应转为 B=0.5(占剩余 50%)");
|
||||
Assert.AreEqual(1, td.HasPartialUnWind, "B≠1 应为部分平仓,设 HasPartialUnWind=1");
|
||||
Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变");
|
||||
Console.WriteLine($"UW_008: A=0.3→B={service.SaveSwapDealCalls[0].data.ClosePercent}, HasPartialUnWind={td.HasPartialUnWind} ✅");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// SwapDealService 的可测试化子类(共享 stub)。
|
||||
/// 继承 SwapDealService,override seam 把 DB/事务/外部服务替换为内存收集器。
|
||||
/// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用,避免重复。
|
||||
/// </summary>
|
||||
public class TestableSwapDealService : SwapDealService
|
||||
{
|
||||
private readonly trade _trade;
|
||||
private readonly Dictionary<int, swap_event> _swapEvents;
|
||||
private readonly Dictionary<long, List<swap_flow_event>> _flowEventsByEventId;
|
||||
|
||||
/// <summary>捕获 AddClientCash 的每次调用(金额, 操作, 日期)</summary>
|
||||
public List<(double amount, string action, DateTime date)> ClientCashCalls { get; } = new();
|
||||
|
||||
/// <summary>捕获 SaveSwapDeal 的每次调用(unwindData, eventType, clientCashId)</summary>
|
||||
public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls { get; } = new();
|
||||
|
||||
public int SaveAllChangesCount;
|
||||
public int CloseReCheckCallCount;
|
||||
|
||||
public TestableSwapDealService(trade td,
|
||||
Dictionary<int, swap_event> swapEvents = null,
|
||||
Dictionary<long, List<swap_flow_event>> flowEventsByEventId = null)
|
||||
: base(new OptUserInfo(0, nameof(TestableSwapDealService), OptUserFrom.UnitTest))
|
||||
{
|
||||
_trade = td;
|
||||
_swapEvents = swapEvents ?? new Dictionary<int, swap_event>();
|
||||
_flowEventsByEventId = flowEventsByEventId ?? new Dictionary<long, List<swap_flow_event>>();
|
||||
}
|
||||
|
||||
protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null;
|
||||
|
||||
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
|
||||
{
|
||||
ClientCashCalls.Add((amount, action, valueDate));
|
||||
return ClientCashCalls.Count; // 返回自增 id
|
||||
}
|
||||
|
||||
// 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库
|
||||
protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false)
|
||||
{
|
||||
SaveSwapDealCalls.Add((unwindData, eventType, clientCashId));
|
||||
return SaveSwapDealCalls.Count; // 返回自增 eventId
|
||||
}
|
||||
|
||||
// ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType)
|
||||
protected override swap_event FindSwapEvent(int tradeId, int eventType)
|
||||
{
|
||||
return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null;
|
||||
}
|
||||
|
||||
// ApproveSwapTrade 查事件关联流水:从内存字典取
|
||||
protected override List<swap_flow_event> FindFlowEventsByEventId(long eventId)
|
||||
{
|
||||
return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List<swap_flow_event>();
|
||||
}
|
||||
|
||||
// ApplySwapTrade 的前置校验:计数,不实际执行
|
||||
protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck)
|
||||
{
|
||||
CloseReCheckCallCount++;
|
||||
}
|
||||
|
||||
protected override void SaveAllChanges() { SaveAllChangesCount++; }
|
||||
protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行
|
||||
protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作
|
||||
protected override void TriggerRealtimeSwapPosition() { } // 空操作
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SwapDealService 测试的共享工厂方法(TestableSwapDealService + UnwindData 构造)。
|
||||
/// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用。
|
||||
/// </summary>
|
||||
public static class SwapDealTestFactory
|
||||
{
|
||||
public const int SwapTradeId = 7700;
|
||||
public static readonly DateTime ValueDate = new(2026, 6, 15);
|
||||
public static readonly DateTime UnwindDate = new(2026, 6, 16);
|
||||
|
||||
public static trade CreateTrade()
|
||||
{
|
||||
return new trade
|
||||
{
|
||||
id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888,
|
||||
TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5),
|
||||
ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用)
|
||||
TradeStatus = "确认成交", ValidState = "Valid",
|
||||
Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用)</summary>
|
||||
public static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m,
|
||||
decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m,
|
||||
decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m)
|
||||
{
|
||||
return new UnwindData
|
||||
{
|
||||
SwapTradeId = SwapTradeId,
|
||||
SwapRealizedPnL = swapRealizedPnL,
|
||||
SwapMarginRebatePnl = swapMarginRebatePnl,
|
||||
SwapMarginAmount = swapMarginAmount,
|
||||
SwapCloseAmount = swapRealizedPnL,
|
||||
CloseMethod = closeMethod,
|
||||
ClosePercent = closePercent,
|
||||
CloseQty = closeQty,
|
||||
CloseNotionalValue = closeNotionalValue,
|
||||
PositionQty = positionQty,
|
||||
ValueDate = ValueDate,
|
||||
UnwindDate = UnwindDate,
|
||||
StartDate = new DateTime(2026, 1, 5)
|
||||
};
|
||||
}
|
||||
|
||||
public static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "")
|
||||
{
|
||||
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
|
||||
$"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "两者都为负数应取绝对值后相乘");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"Scenario": "标的种类与数据来源",
|
||||
"Description": "合成 Mock:覆盖现券/贵金属/真期货/股票 的种类显示,以及债券来源(自动同步→系统/手工改过→人工)。回放守护'不再一律显示商品期货'+'路由键不变'+'债券来源=系统'。",
|
||||
"Source": "synthetic",
|
||||
"RecordedAt": null,
|
||||
"Rows": [
|
||||
{
|
||||
"UnderlyingCode": "019547.IB",
|
||||
"RouteKey": "CommodityFutures",
|
||||
"RealInstrumentType": "CreditBonds",
|
||||
"IsBond": true,
|
||||
"ExpectedTypeCn": "信用债",
|
||||
"ExpectedDataSource": "系统"
|
||||
},
|
||||
{
|
||||
"UnderlyingCode": "220210.IB",
|
||||
"RouteKey": "CommodityFutures",
|
||||
"RealInstrumentType": "TBonds",
|
||||
"IsBond": true,
|
||||
"ExpectedTypeCn": "利率债",
|
||||
"ExpectedDataSource": "系统"
|
||||
},
|
||||
{
|
||||
"UnderlyingCode": "AU9999.SGE",
|
||||
"RouteKey": "CommodityFutures",
|
||||
"RealInstrumentType": "GoldSpot",
|
||||
"IsBond": false,
|
||||
"JSID": null,
|
||||
"ExpectedTypeCn": "黄金现货",
|
||||
"ExpectedDataSource": null
|
||||
},
|
||||
{
|
||||
"UnderlyingCode": "IF2409",
|
||||
"RouteKey": "CommodityFutures",
|
||||
"RealInstrumentType": "CommodityFutures",
|
||||
"IsBond": false,
|
||||
"JSID": null,
|
||||
"ExpectedTypeCn": "商品期货",
|
||||
"ExpectedDataSource": null
|
||||
},
|
||||
{
|
||||
"UnderlyingCode": "600000.SH",
|
||||
"RouteKey": "Stock",
|
||||
"RealInstrumentType": "Stock",
|
||||
"IsBond": false,
|
||||
"JSID": null,
|
||||
"ExpectedTypeCn": "股票",
|
||||
"ExpectedDataSource": null
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user