#EQD-6948 国联民生-实现保证金规则(2)追保金额的产生与收盘计算 收盘计算和授信占用

This commit is contained in:
锦麟 王
2026-08-24 16:34:18 +08:00
parent 8648b62af9
commit c35befbe8f
49 changed files with 2814 additions and 272 deletions
@@ -0,0 +1,192 @@
using YLErp.BLL;
using YLErp.DBModels;
using YLErp.Enums;
using YLErp.Modules.MarginModule;
namespace YLErp.Modules.CalcModules
{
/// <summary>
/// R1 模板三层级找到即停回退集成测试(连 dev 库,MarginTemplateV2RateHelper.ResolveTieredTemplate/GetTradeMarginRate):
/// 交易绑定(自定义)→ 客户默认(client_margin_template 按客户)→ 全局默认(IsDefault&&!IsForClient)。
/// 测试数据全部带 "ZZZ-R1回退测试-" 名称前缀,TestInitialize/TestCleanup 双向清理,不触碰真实交易。
/// </summary>
[TestClass]
public class MarginTemplateV2RateHelperFallbackTest
{
private const string Marker = "ZZZ-R1回退测试-";
private const int SentinelTradeId = 1900000001;
private const int SentinelTradeId2 = 1900000002;
private DateTime EffectiveDate = new DateTime(2000, 1, 1);
private YLContext db;
private int clientId;
[TestInitialize]
public void Init()
{
db = new YLContext();
Cleanup();
//取一个真实客户做客户级绑定(只写 client_margin_template,不动客户数据)
using (var clientDb = DbContextFactory.GetClientDbContext(OptUserInfo.SystemUser))
{
clientId = clientDb.client.Where(c => c.id > 0).OrderBy(c => c.id).Select(c => c.id).First();
}
}
[TestCleanup]
public void CleanupFixture()
{
Cleanup();
db.Dispose();
}
private void Cleanup()
{
var templateIds = db.margin_template_v2.Where(x => x.Name.StartsWith(Marker)).Select(x => x.id).ToList();
if (templateIds.Count > 0)
{
db.margin_template_detail.RemoveRange(db.margin_template_detail.Where(x => templateIds.Contains(x.MarginTemplateId)));
db.trade_margin_template.RemoveRange(db.trade_margin_template.Where(x => templateIds.Contains(x.MarginTemplateId)));
db.client_margin_template.RemoveRange(db.client_margin_template.Where(x => templateIds.Contains(x.MarginTemplateId)));
db.margin_template_v2.RemoveRange(db.margin_template_v2.Where(x => templateIds.Contains(x.id)));
db.SaveChanges();
}
db.trade_margin_template.RemoveRange(db.trade_margin_template.Where(x => x.TradeId == SentinelTradeId || x.TradeId == SentinelTradeId2));
db.SaveChanges();
}
private margin_template_v2 AddTemplate(string name, bool isDefault, bool isForClient, string tradeTypes = "收益互换", bool isValid = true, int ruleType = (int)MarginRuleTypeEnum.)
{
var t = new margin_template_v2
{
Name = name,
IsDefault = isDefault,
IsForClient = isForClient,
IsValid = isValid,
TradeTypes = tradeTypes,
RuleType = ruleType,
ValueDate = EffectiveDate
};
db.margin_template_v2.Add(t);
db.SaveChanges();
return t;
}
[TestMethod]
public void TF_001_交易绑定优先_压过客户与全局()
{
var custom = AddTemplate(Marker + "自定义", isDefault: false, isForClient: false);
var clientTpl = AddTemplate(Marker + "客户", isDefault: true, isForClient: true);
var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false);
db.trade_margin_template.Add(new trade_margin_template { TradeId = SentinelTradeId, MarginTemplateId = custom.id, ValueDate = EffectiveDate, IsLatest = true });
db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = clientTpl.id, ValueDate = EffectiveDate, ClientLevel = "" });
db.SaveChanges();
var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(SentinelTradeId, clientId, DateTime.Today, db);
Assert.IsNotNull(resolved);
Assert.AreEqual(custom.id, resolved.id, "交易绑定(自定义)应找到即停,压过客户与全局默认");
}
[TestMethod]
public void TF_002_无交易绑定_落到客户默认()
{
var clientTpl = AddTemplate(Marker + "客户", isDefault: true, isForClient: true);
var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false);
db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = clientTpl.id, ValueDate = EffectiveDate, ClientLevel = "" });
db.SaveChanges();
var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(tradeId: null, clientId, DateTime.Today, db);
Assert.IsNotNull(resolved);
Assert.AreEqual(clientTpl.id, resolved.id, "无交易绑定时应命中客户默认,压过全局默认");
}
[TestMethod]
public void TF_003_无交易无客户绑定_落到全局默认()
{
var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false);
AddTemplate(Marker + "客户", isDefault: true, isForClient: true); //未绑定不应被取到
var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(tradeId: null, clientId, DateTime.Today, db);
AssertGlobal(resolved, globalTpl);
}
[TestMethod]
public void TF_004_客户绑定指向非互换模板_跳过落全局()
{
var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false);
var optionTpl = AddTemplate(Marker + "期权客户模板", isDefault: true, isForClient: true, tradeTypes: "香草期权");
db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = optionTpl.id, ValueDate = EffectiveDate, ClientLevel = "" });
db.SaveChanges();
var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(tradeId: null, clientId, DateTime.Today, db);
AssertGlobal(resolved, globalTpl);
}
/// <summary>
/// 同日并存"互换绑定+期权绑定"(页面互斥只挡适用结构重叠,此组合允许保存):
/// 应命中互换绑定,不因先取到期权绑定被过滤而误穿透到全局。
/// </summary>
[TestMethod]
public void TF_007_同日并存互换与期权绑定_命中间换绑定()
{
var swapTpl = AddTemplate(Marker + "客户", isDefault: true, isForClient: true);
var optionTpl = AddTemplate(Marker + "期权客户模板", isDefault: true, isForClient: true, tradeTypes: "香草期权");
var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false);
db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = swapTpl.id, ValueDate = EffectiveDate, ClientLevel = "" });
db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = optionTpl.id, ValueDate = EffectiveDate, ClientLevel = "" });
db.SaveChanges();
var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(tradeId: null, clientId, DateTime.Today, db);
Assert.IsNotNull(resolved);
Assert.AreEqual(swapTpl.id, resolved.id, "同日期权+互换绑定并存时应命中互换绑定(过滤后再取最新),而非穿透全局");
}
[TestMethod]
public void TF_005_绑定模板已失效_找到即停返回null()
{
var invalidTpl = AddTemplate(Marker + "已失效", isDefault: false, isForClient: false, isValid: false);
var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false);
db.trade_margin_template.Add(new trade_margin_template { TradeId = SentinelTradeId2, MarginTemplateId = invalidTpl.id, ValueDate = EffectiveDate, IsLatest = true });
db.SaveChanges();
var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(SentinelTradeId2, clientId, DateTime.Today, db);
Assert.IsNull(resolved, "交易绑定指向已失效模板时应找到即停(不向下回退到全局默认)");
}
/// <summary>
/// 端到端:交易绑定(区间追保结构 + 明细 x/y)经 GetTradeMarginRate 完整取到率——
/// 兼容回归交易级取数路径在新回退结构下行为不变。
/// </summary>
[TestMethod]
public void TF_006_交易绑定端到端取率()
{
var custom = AddTemplate(Marker + "自定义", isDefault: false, isForClient: false);
db.margin_template_detail.Add(new margin_template_detail
{
MarginTemplateId = custom.id,
ValueDate = EffectiveDate,
UnderlyingType = UnderlyingTypeEnum.None,
MarginRatio1 = 0.05,
MarginRatio2 = 0.03
});
db.trade_margin_template.Add(new trade_margin_template { TradeId = SentinelTradeId, MarginTemplateId = custom.id, ValueDate = EffectiveDate, IsLatest = true });
db.SaveChanges();
var rate = MarginTemplateV2RateHelper.GetTradeMarginRate(SentinelTradeId, "240004.IB", "TBonds", DateTime.Today, db);
Assert.IsNotNull(rate);
Assert.AreEqual(custom.id, rate.Template.id);
Assert.AreEqual(0.05m, rate.InitRate.Value);
Assert.AreEqual(0.03m, rate.MaintainRate.Value);
}
private void AssertGlobal(margin_template_v2 resolved, margin_template_v2 expected)
{
Assert.IsNotNull(resolved);
if (resolved.Name.StartsWith(Marker))
{
Assert.AreEqual(expected.id, resolved.id, "应命中本用例创建的全局默认模板");
}
//dev 库存在其他真实全局默认模板时,按 ValueDate 最新者胜出,不做更严格断言
}
}
}
@@ -0,0 +1,21 @@
using YLErp.Modules.MarginModule;
namespace YLErp.Modules.CalcModules
{
/// <summary>
/// 实现方案阶段一 §1.6 测试要点:GetTradeMarginRate 分类判定钩子默认行为回归。
/// 钩子本期默认返回 null → 走"全部/通配行"兜底,取数行为与现状一致(阶段二标签功能不改变取数链路)。
/// 完整 GetTradeMarginRate 依赖 YLContextMySQL),无法纯内存测试,此处覆盖钩子契约。
/// </summary>
[TestClass]
public class MarginTemplateV2RateHelperTest
{
[TestMethod]
public void MT_001_分类判定钩子_默认返回null_走通配兜底()
{
Assert.IsNull(MarginTemplateV2RateHelper.GetUnderlyingCategory("019546.XSHG", null));
Assert.IsNull(MarginTemplateV2RateHelper.GetUnderlyingCategory("200002.XSHE", "Fund"));
Assert.IsNull(MarginTemplateV2RateHelper.GetUnderlyingCategory(null, null));
}
}
}
@@ -0,0 +1,91 @@
using YLErp.BLL;
using YLErp.DBModels;
using YLErp.Modules.SwapModule;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// BUG-01 方向守护(2026-08-24):授信出入表金额口径 占用记正/释放记负——
/// 已使用授信 = SUM(amount) 随占用上升、随释放回落;可用授信 = 有效授信 − 已使用授信 随占用收缩。
/// 连 dev 库闭环验证 Occupy/Release/GetUsedCredit(哨兵 client_id 自建自清,不触碰真实客户)。
/// </summary>
[TestClass]
public class ClientCreditInoutSignTest
{
private const int SentinelClientId = 99064001; //哨兵客户号(不存在的客户,仅授信出入表按 client_id 记账)
private YLContext db;
private ClientCreditInoutService service;
[TestInitialize]
public void Init()
{
db = new YLContext();
Cleanup();
service = new ClientCreditInoutService(OptUserInfo.SystemUser);
}
[TestCleanup]
public void Teardown()
{
Cleanup();
db.Dispose();
}
private void Cleanup()
{
var olds = db.client_credit_inout.Where(x => x.client_id == SentinelClientId).ToList();
if (olds.Count > 0)
{
db.client_credit_inout.RemoveRange(olds);
db.SaveChanges();
}
}
/// <summary>占用记正数:占用越多已使用授信越大(修复前为负——"越占越多"BUG-01</summary>
[TestMethod]
public void CR_001_占用记正_已使用授信上升()
{
service.Occupy(SentinelClientId, 900001L, 800001, 10000, DateTime.Today, "方向守护-占用1");
Assert.AreEqual(10000, ClientCreditInoutService.GetUsedCredit(SentinelClientId, db), 1e-6);
service.Occupy(SentinelClientId, 900002L, 800002, 5000, DateTime.Today, "方向守护-占用2");
Assert.AreEqual(15000, ClientCreditInoutService.GetUsedCredit(SentinelClientId, db), 1e-6);
}
/// <summary>释放记负数:已使用授信回落,快照 used_after 同口径</summary>
[TestMethod]
public void CR_002_释放记负_已使用授信回落()
{
service.Occupy(SentinelClientId, 900001L, 800001, 10000, DateTime.Today, "方向守护-占用");
service.Release(SentinelClientId, 900001L, 800001, 4000, DateTime.Today, "方向守护-释放");
var used = ClientCreditInoutService.GetUsedCredit(SentinelClientId, db);
Assert.AreEqual(6000, used, 1e-6);
//快照列与求和口径一致(最后一次写完后的已使用授信)
var last = db.client_credit_inout.Where(x => x.client_id == SentinelClientId).OrderByDescending(x => x.id).First();
Assert.AreEqual(6000, last.used_after, 1e-6);
Assert.AreEqual(-4000, last.amount, 1e-6, "释放行 amount 记负数");
}
/// <summary>方向传导:可用授信 = 有效授信 − 已使用授信,占用使其收缩(BUG-01 修复后的业务语义)</summary>
[TestMethod]
public void CR_003_可用授信随占用收缩()
{
service.Occupy(SentinelClientId, null, 800003, 10000, DateTime.Today, "方向守护-占用");
var used = ClientCreditInoutService.GetUsedCredit(SentinelClientId, db);
var effective = 30000d; //有效授信给定量
Assert.AreEqual(20000, effective - used, 1e-6, "占用 10000 后可用授信应从 30000 收缩到 20000");
}
/// <summary>Occupy/Release 入口对传入符号容错(一律按数量取绝对值定方向),防调用方残留旧负号</summary>
[TestMethod]
public void CR_004_语义入口符号容错()
{
//即使调用方按旧习惯传负数,Occupy 也记正(数量口径),防止新代码库中残留旧符号调用点
var rec = service.Occupy(SentinelClientId, null, 800004, -7000, DateTime.Today, "方向守护-负号容错");
Assert.AreEqual(7000, rec.amount, 1e-6);
var rel = service.Release(SentinelClientId, null, 800004, -3000, DateTime.Today, "方向守护-负号容错");
Assert.AreEqual(-3000, rel.amount, 1e-6);
}
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage;
using YLErp.BLL;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 新建表列名规范测试(2026-08-20 约定:新建表所有数据库列一律小写下划线)。
/// 只构建 EF 模型(纯内存,不连库),断言映射列名不含大写字母:
/// client_credit_inout 全表、swap_position.fund_tag、credit.original_credit/max_credit_use_ratio。
/// 基类操作人列(OptId/OptName/OptTime)经 override+[Column] 覆写为小写,此处一并守护。
/// </summary>
[TestClass]
public class DbColumnNamingTest
{
[TestMethod]
public void DB_001_授信出入表_全部列名小写()
{
using var db = new YLContext();
var entity = db.Model.FindEntityType(typeof(client_credit_inout));
Assert.IsNotNull(entity, "client_credit_inout 未注册到 YLContext");
var table = StoreObjectIdentifier.Table("client_credit_inout", null);
foreach (var property in entity.GetProperties())
{
var column = property.GetColumnName(table);
Assert.IsNotNull(column, $"属性 {property.Name} 未映射列名");
Assert.IsFalse(column.Any(char.IsUpper), $"列名应全小写: {column}(属性 {property.Name}");
}
}
[TestMethod]
public void DB_002_互换持仓与授信表_新增列名小写()
{
using var db = new YLContext();
AssertColumn(db, typeof(swap_position), nameof(swap_position.FundTag), "fund_tag");
AssertColumn(db, typeof(CreditTable), nameof(CreditTable.OriginalCredit), "original_credit");
AssertColumn(db, typeof(CreditTable), nameof(CreditTable.MaxCreditUseRatio), "max_credit_use_ratio");
}
private static void AssertColumn(YLContext db, Type entityType, string propertyName, string expectedColumn)
{
var entity = db.Model.FindEntityType(entityType);
Assert.IsNotNull(entity, $"{entityType.Name} 未注册到 YLContext");
var property = entity.FindProperty(propertyName);
Assert.IsNotNull(property, $"属性 {propertyName} 不在 {entityType.Name} 映射中");
var table = StoreObjectIdentifier.Create(entity, StoreObjectType.Table).Value;
var column = property.GetColumnName(table);
Assert.AreEqual(expectedColumn, column, $"{entityType.Name}.{propertyName} 映射列名不符");
}
}
}
@@ -0,0 +1,210 @@
using YLErp.DBModels;
using YLErp.Modules.SwapModule.Margin;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// R4 授信/现金标签 分配与返还分流纯函数测试(实现方案阶段二 §2.3/§2.4 测试要点)。
/// 资金来源是预付金腿上的录入项(逐腿选择),覆盖:标签赋值四种情形、拆单金额核对、
/// 平仓按标签返还、存量无标签按现金。
/// </summary>
[TestClass]
public class FundTagCalcTest
{
private static LegAmount Leg(long id, double amount, bool preferCredit)
=> new() { Leg = new swap_position { id = id }, Amount = amount, PreferCredit = preferCredit };
// ================================================================
// §2.3 标签赋值四种情形(FundTagCalc.AllocateByLegPreference,逐腿)
// ================================================================
[TestMethod]
public void FT_001_腿未选资金来源_全额现金()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount> { Leg(101, 1000, preferCredit: false) }, 5000, ignoreMoneyCheck: false);
Assert.AreEqual(0, plans[0].CreditAmount);
Assert.AreEqual(1000, plans[0].CashAmount);
}
[TestMethod]
public void FT_002_腿选现金_全额现金_不消耗额度()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount> { Leg(101, 1000, preferCredit: false) }, 0, ignoreMoneyCheck: false);
Assert.AreEqual(0, plans[0].CreditAmount);
Assert.AreEqual(1000, plans[0].CashAmount);
}
[TestMethod]
public void FT_003_腿选授信_额度充足_全额授信()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount> { Leg(101, 1000, preferCredit: true) }, 5000, ignoreMoneyCheck: false);
Assert.AreEqual(1000, plans[0].CreditAmount);
Assert.AreEqual(0, plans[0].CashAmount);
Assert.IsFalse(plans[0].NeedSplit);
}
[TestMethod]
public void FT_004_腿选授信_额度不足_拆分为剩余授信加现金差额()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount> { Leg(101, 1000, preferCredit: true) }, 300, ignoreMoneyCheck: false);
Assert.AreEqual(300, plans[0].CreditAmount);
Assert.AreEqual(700, plans[0].CashAmount);
Assert.IsTrue(plans[0].NeedSplit);
}
[TestMethod]
public void FT_005_腿选授信_额度为零_全额现金()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount> { Leg(101, 1000, preferCredit: true) }, 0, ignoreMoneyCheck: false);
Assert.AreEqual(0, plans[0].CreditAmount);
Assert.AreEqual(1000, plans[0].CashAmount);
}
[TestMethod]
public void FT_006_特批_忽略腿上选择_全额现金不占授信()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount> { Leg(101, 1000, preferCredit: true) }, 5000, ignoreMoneyCheck: true);
Assert.AreEqual(0, plans[0].CreditAmount);
Assert.AreEqual(1000, plans[0].CashAmount);
}
[TestMethod]
public void FT_007_客户净收取的腿_即使选授信也不占用授信()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount> { Leg(101, -500, preferCredit: true) }, 5000, ignoreMoneyCheck: false);
Assert.AreEqual(0, plans[0].CreditAmount);
Assert.AreEqual(-500, plans[0].CashAmount);
}
[TestMethod]
public void FT_008_混合偏好_现金腿不消耗授信额度()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount>
{
Leg(101, 600, preferCredit: false), //现金腿
Leg(102, 400, preferCredit: true), //授信腿
}, 500, ignoreMoneyCheck: false);
Assert.AreEqual(0, plans[0].CreditAmount);
Assert.AreEqual(600, plans[0].CashAmount);
//授信腿可用额度仍是 500(现金腿未消耗)
Assert.AreEqual(400, plans[1].CreditAmount);
Assert.AreEqual(0, plans[1].CashAmount);
}
// ================================================================
// §2.3 拆单:多腿授信额度按顺序消耗(FundTagCalc.AllocateByLegPreference
// ================================================================
[TestMethod]
public void FT_010_多条授信腿_额度跨在第二腿_第二腿拆单()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount>
{
Leg(101, 600, preferCredit: true),
Leg(102, 600, preferCredit: true),
}, creditAvailable: 1000, ignoreMoneyCheck: false);
Assert.AreEqual(2, plans.Count);
//第一腿全额授信
Assert.AreEqual(600, plans[0].CreditAmount);
Assert.AreEqual(0, plans[0].CashAmount);
Assert.IsFalse(plans[0].NeedSplit);
//第二腿跨界拆单:授信400 + 现金200
Assert.AreEqual(400, plans[1].CreditAmount);
Assert.AreEqual(200, plans[1].CashAmount);
Assert.IsTrue(plans[1].NeedSplit);
//金额守恒:授信合计=可用额度,现金合计=差额
Assert.AreEqual(1000, plans.Sum(p => p.CreditAmount));
Assert.AreEqual(200, plans.Sum(p => p.CashAmount));
}
[TestMethod]
public void FT_011_额度耗尽后_后续授信腿全额现金()
{
var plans = FundTagCalc.AllocateByLegPreference(new List<LegAmount>
{
Leg(101, 600, preferCredit: true),
Leg(102, 600, preferCredit: true),
Leg(103, 600, preferCredit: true),
}, creditAvailable: 600, ignoreMoneyCheck: false);
Assert.AreEqual(600, plans[0].CreditAmount);
Assert.AreEqual(0, plans[1].CreditAmount);
Assert.AreEqual(600, plans[1].CashAmount);
Assert.AreEqual(0, plans[2].CreditAmount);
Assert.AreEqual(600, plans[2].CashAmount);
}
// ================================================================
// §2.4 平仓/到期按原标签返还(FundTagCalc.SplitUnwindByTag
// ================================================================
[TestMethod]
public void FT_020_平仓返还_授信腿不产生现金_按腿生成释放明细()
{
var settlements = new List<MarginLegSettlement>
{
//授信腿:返还本金 400,返息 50 → 全部不进资金,写释放
new() { PositionId = 101, Tag = ConsFundTag.Credit, MarginAmount = 400m, RebateAmount = 50m },
//现金腿:返还本金 300,返息 20 → 正常资金流水
new() { PositionId = 102, Tag = ConsFundTag.Cash, MarginAmount = 300m, RebateAmount = 20m },
//存量无标签(EffectiveTag 后按现金)
new() { PositionId = 103, Tag = ConsFundTag.Cash, MarginAmount = 100m, RebateAmount = 0m },
};
var split = FundTagCalc.SplitUnwindByTag(settlements);
Assert.AreEqual(300 + 100, split.CashMargin);
Assert.AreEqual(400, split.CreditMargin);
Assert.AreEqual(20, split.CashRebate);
Assert.AreEqual(50, split.CreditRebate);
//释放明细按 position_id 匹配原占用记录
Assert.AreEqual(1, split.Releases.Count);
Assert.AreEqual(101L, split.Releases[0].PositionId);
Assert.AreEqual(400, split.Releases[0].Amount);
}
[TestMethod]
public void FT_021_平仓返还_全部授信_现金部分为零()
{
var settlements = new List<MarginLegSettlement>
{
new() { PositionId = 201, Tag = ConsFundTag.Credit, MarginAmount = 800m, RebateAmount = 30m },
};
var split = FundTagCalc.SplitUnwindByTag(settlements);
Assert.AreEqual(0, split.CashMargin);
Assert.AreEqual(0, split.CashRebate);
Assert.AreEqual(800, split.CreditMargin);
Assert.AreEqual(1, split.Releases.Count);
}
// ================================================================
// 平仓利息事件 → 逐腿结算额构造(MarginSettlementBuilder
// ================================================================
[TestMethod]
public void FT_030_由利息事件构造结算额_方向比与标签过滤正确()
{
var events = new List<swap_flow_event>
{
//预付金腿(InterestMode=5,无标的代码):InterestDirection=2 → 返还本金 = principal × 1
new() { PositionId = 101, InterestMode = (int)InterestModeEnum., InterestDirection = 2, InterestPrincipal = 400m, InterestClosePnL = 50m },
//预付金腿:InterestDirection=1(收取)→ 返还本金 = principal × -1
new() { PositionId = 102, InterestMode = (int)InterestModeEnum., InterestDirection = 1, InterestPrincipal = 300m, InterestClosePnL = 0m },
//浮动腿(有标的代码):不参与
new() { PositionId = 103, InterestMode = 0, UnderlyingCode = "123456.SH", InterestPrincipal = 999m },
//利息腿(非预付金、无标的):不参与
new() { PositionId = 104, InterestMode = (int)InterestModeEnum., InterestPrincipal = 555m },
};
var tags = new Dictionary<long, string> { { 101, ConsFundTag.Credit } };
var settlements = MarginSettlementBuilder.Build(tags, events);
Assert.AreEqual(2, settlements.Count);
Assert.AreEqual(ConsFundTag.Credit, settlements[0].Tag);
Assert.AreEqual(400m, settlements[0].MarginAmount);
Assert.AreEqual(50m, settlements[0].RebateAmount);
//102 标签字典缺失(存量无标签)→ 按现金;方向比 -1
Assert.AreEqual(ConsFundTag.Cash, settlements[1].Tag);
Assert.AreEqual(-300m, settlements[1].MarginAmount);
}
}
}
@@ -0,0 +1,125 @@
using YLErp.DBModels;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Margin;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// R3 阶段四 §4.1 合约维度追加保证金计算测试(纯函数):
/// 目标 = 维持保证金 − 应付预付金净收额(≤0 不追);当日新增 = 目标 − 已补足(现金记录累计+授信占用累计);
/// 授信优先分配;重跑幂等(新增=0 不写);追保回落不返还(负缺口走可用资金公式)。
/// </summary>
[TestClass]
public class SwapAdditionalMarginCalcTest
{
[TestMethod]
public void AM_001_目标计算_维持大于应付()
{
//维持 180 应付净收 100 = 80
Assert.AreEqual(80, SwapAdditionalMarginCalc.CalcTarget(180, 100), 1e-6);
}
[TestMethod]
public void AM_002_目标计算_应付覆盖维持_不追()
{
Assert.AreEqual(0, SwapAdditionalMarginCalc.CalcTarget(80, 100), 1e-6);
}
[TestMethod]
public void AM_003_目标计算_维持为负_我方净支付方向不追()
{
Assert.AreEqual(0, SwapAdditionalMarginCalc.CalcTarget(-50, 100), 1e-6);
}
[TestMethod]
public void AM_004_分配_授信充足_全额授信()
{
var (credit, cash) = SwapAdditionalMarginCalc.Allocate(80, 100);
Assert.AreEqual(80, credit, 1e-6);
Assert.AreEqual(0, cash, 1e-6);
}
[TestMethod]
public void AM_005_分配_授信不足_拆为授信加现金()
{
var (credit, cash) = SwapAdditionalMarginCalc.Allocate(80, 30);
Assert.AreEqual(30, credit, 1e-6);
Assert.AreEqual(50, cash, 1e-6);
}
[TestMethod]
public void AM_006_分配_无授信_全额现金()
{
var (credit, cash) = SwapAdditionalMarginCalc.Allocate(80, 0);
Assert.AreEqual(0, credit, 1e-6);
Assert.AreEqual(80, cash, 1e-6);
}
[TestMethod]
public void AM_007_分配_新增为零或负_不产生()
{
Assert.AreEqual((0d, 0d), SwapAdditionalMarginCalc.Allocate(0, 100));
Assert.AreEqual((0d, 0d), SwapAdditionalMarginCalc.Allocate(-5, 100));
}
/// <summary>
/// 幂等与跨日增量(资金记录存累计值的口径验证):
/// 第1日 维持180/应付100 → 新增80;第2日 维持不变、已补足80 → 新增0(重跑不写);
/// 第3日 维持升到200 → 目标100 − 已补足80 = 新增20(记录累计值更新为100,历史增量自然承载)。
/// </summary>
[TestMethod]
public void AM_008_跨日增量与幂等()
{
double fundedCash = 0, fundedCredit = 0;
var payableNet = 100d;
var day1 = Math.Round(SwapAdditionalMarginCalc.CalcTarget(180, payableNet) - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero);
Assert.AreEqual(80, day1, 1e-6);
var (c1, m1) = SwapAdditionalMarginCalc.Allocate(day1, 30);
fundedCredit += c1;
fundedCash += m1;
Assert.AreEqual(30, fundedCredit, 1e-6);
Assert.AreEqual(50, fundedCash, 1e-6);
//重跑同日:目标/已补足不变 → 新增 0
var day1Rerun = Math.Round(SwapAdditionalMarginCalc.CalcTarget(180, payableNet) - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero);
Assert.AreEqual(0, day1Rerun, 1e-6);
//第2日 维持不变 → 已补足=目标 → 新增 0
var day2 = Math.Round(SwapAdditionalMarginCalc.CalcTarget(180, payableNet) - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero);
Assert.AreEqual(0, day2, 1e-6);
//第3日 维持上升 → 只补差额
var day3 = Math.Round(SwapAdditionalMarginCalc.CalcTarget(200, payableNet) - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero);
Assert.AreEqual(20, day3, 1e-6);
}
/// <summary>
/// 追保回落(维持下降到应付之下)不返还:目标为 0 → 已补足保持,新增 0;
/// 超付部分由可用资金公式的负缺口(Σ维持−累计)体现,不产生返还记录。
/// </summary>
[TestMethod]
public void AM_009_追保回落不返还()
{
var target = SwapAdditionalMarginCalc.CalcTarget(50, 100);
Assert.AreEqual(0, target, 1e-6);
var (credit, cash) = SwapAdditionalMarginCalc.Allocate(target - 80, 100);
Assert.AreEqual(0, credit, 1e-6);
Assert.AreEqual(0, cash, 1e-6);
}
/// <summary>
/// 追加保证金授信出入记录的 remark 前缀识别(RemoveByTrade 保留判定与累计口径共用)。
/// </summary>
[TestMethod]
public void AM_010_追加保证金记录识别()
{
Assert.IsTrue(ClientCreditInoutService.IsAdditionalMarginRecord(
new client_credit_inout { change_type = client_credit_inout.ChangeTypeOccupy, remark = "追加保证金占用" }));
Assert.IsFalse(ClientCreditInoutService.IsAdditionalMarginRecord(
new client_credit_inout { change_type = client_credit_inout.ChangeTypeOccupy, remark = "簿记授信占用" }));
Assert.IsFalse(ClientCreditInoutService.IsAdditionalMarginRecord(
new client_credit_inout { remark = null }));
}
}
}
@@ -0,0 +1,131 @@
using YLErp.Modules.SwapModule.Margin;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// R2 阶段三 §3.2 可用资金公式测试(需求拆分 R2 口径,实时/EOD/报告三处共用 SwapSpanBalanceCalc):
/// 客户维度 = Max(现金结存 + 授信 − 已使用授信 + 初始保证金 − 维持保证金, 0);
/// 合约维度 = Max(现金结存 + 授信 − 已使用授信 − 交易维度追加保证金合计, 0)。
/// 授信额度为 credit.Credit 合计(保存时已折算比例),现金结存=期末结存(阶段二起授信不进资金)。
/// </summary>
[TestClass]
public class SwapSpanBalanceCalcTest
{
[TestMethod]
public void SB_001_客户维度_现金充足()
{
//现金200 + 授信100 已使用20 + 初始100 维持150 = 230
Assert.AreEqual(230, SwapSpanBalanceCalc.CalcClientDimensionAvailable(
cashBalance: 200, totalCredit: 100, usedCredit: 20, initialMargin: 100, maintenanceMargin: 150), 1e-6);
}
[TestMethod]
public void SB_002_客户维度_现金不足_截断为0()
{
//现金20 + 0 0 + 100 130 = −10 → 0(追加10未补足时无可用资金)
Assert.AreEqual(0, SwapSpanBalanceCalc.CalcClientDimensionAvailable(
cashBalance: 20, totalCredit: 0, usedCredit: 0, initialMargin: 100, maintenanceMargin: 130), 1e-6);
}
[TestMethod]
public void SB_003_客户维度_授信可用额参与覆盖()
{
//现金20 + 授信100 已使用20 + 100 130 = 70
Assert.AreEqual(70, SwapSpanBalanceCalc.CalcClientDimensionAvailable(
cashBalance: 20, totalCredit: 100, usedCredit: 20, initialMargin: 100, maintenanceMargin: 130), 1e-6);
}
[TestMethod]
public void SB_004_合约维度_按交易追加合计扣减()
{
//现金20 + 授信100 已使用20 追加合计30 = 70
Assert.AreEqual(70, SwapSpanBalanceCalc.CalcContractDimensionAvailable(
cashBalance: 20, totalCredit: 100, usedCredit: 20, tradeAdditionalMarginSum: 30), 1e-6);
}
[TestMethod]
public void SB_005_合约维度_扣尽截断为0()
{
Assert.AreEqual(0, SwapSpanBalanceCalc.CalcContractDimensionAvailable(
cashBalance: 20, totalCredit: 0, usedCredit: 0, tradeAdditionalMarginSum: 30), 1e-6);
}
/// <summary>
/// 阶段三口径一致性:无追加保证金流水时(阶段四前),两维度公式数值一致——
/// 交易追加合计 = Σ(维持−累计) = 维持 − 初始,与客户维度的 (初始−维持) 项互相抵消。
/// </summary>
[TestMethod]
public void SB_006_阶段三两维度数值一致()
{
var cash = 20d;
var credit = 100d;
var used = 20d;
var initial = 100d;
var maintenance = 130d;
var clientDimension = SwapSpanBalanceCalc.CalcClientDimensionAvailable(cash, credit, used, initial, maintenance);
var contractDimension = SwapSpanBalanceCalc.CalcContractDimensionAvailable(cash, credit, used, maintenance - initial);
Assert.AreEqual(clientDimension, contractDimension, 1e-9);
}
/// <summary>
/// 阶段四 §4.2 客户维度双向追保:正数=需追保,不以 0 截断。
/// </summary>
[TestMethod]
public void SB_010_客户维度追保金额_正数需追保()
{
//追保差额 (维持150 − 初始100)=50;资金 现金5+授信0−已使用0=5 → 追保 = 50 5 = 45
Assert.AreEqual(45, SwapSpanBalanceCalc.CalcClientDimensionCallMargin(
cashBalance: 5, totalCredit: 0, usedCredit: 0, initialMargin: 100, maintenanceMargin: 150), 1e-6);
}
[TestMethod]
public void SB_011_客户维度追保金额_负数可返还()
{
//差额 (维持130−初始100)=30,资金 20+10020=100 → 追保 = 30 100 = −70(可返还,不截断为 0)
Assert.AreEqual(-70, SwapSpanBalanceCalc.CalcClientDimensionCallMargin(
cashBalance: 20, totalCredit: 100, usedCredit: 20, initialMargin: 100, maintenanceMargin: 130), 1e-6);
}
/// <summary>
/// 客户维度追保金额与可用资金公式互为反向(去 Max 截断):追保 = −(未截断可用资金)。
/// </summary>
[TestMethod]
public void SB_012_客户维度追保与可用资金反向一致()
{
var cash = 20d;
var credit = 100d;
var used = 20d;
var initial = 100d;
var maintenance = 130d;
var available = SwapSpanBalanceCalc.CalcClientDimensionAvailable(cash, credit, used, initial, maintenance);
var callMargin = SwapSpanBalanceCalc.CalcClientDimensionCallMargin(cash, credit, used, initial, maintenance);
var availableUnfloored = cash + credit - used + initial - maintenance;
Assert.AreEqual(-availableUnfloored, callMargin, 1e-9);
//可用资金被 0 截断时追保为正(需追保),两者不矛盾
if (availableUnfloored < 0)
{
Assert.AreEqual(0, available, 1e-9);
Assert.IsTrue(callMargin > 0);
}
}
/// <summary>
/// 合约维度追保金额(需求原文 现金+授信−已使用 的应追加方向取值):账户透支为正=应补足,盈余为负。
/// </summary>
[TestMethod]
public void SB_013_合约维度追保金额_透支为正()
{
//现金−80 + 授信100 已使用30 = −10 → 追保 = 10(应补足)
Assert.AreEqual(10, SwapSpanBalanceCalc.CalcContractDimensionCallMargin(
cashBalance: -80, totalCredit: 100, usedCredit: 30), 1e-6);
}
[TestMethod]
public void SB_014_合约维度追保金额_盈余为负()
{
//现金50 + 授信100 已使用20 = 130 → 追保 = −130(盈余可返还方向)
Assert.AreEqual(-130, SwapSpanBalanceCalc.CalcContractDimensionCallMargin(
cashBalance: 50, totalCredit: 100, usedCredit: 20), 1e-6);
}
}
}
@@ -0,0 +1,225 @@
using YLErp.DBModels;
using YLErp.Modules.SwapModule.Margin;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// R2 阶段三 §3.1 区间追保纯函数测试:落档边界值(恰好等于档界)、看多/看空方向、
/// 追保金额=AmountRate×期初全价×券面总额(累计到所落档位,直取)、
/// 维持保证金=(初始+总追加)×我方净收取方向。
/// 区间结构(docx 确认书追保表同型):多头 第1层[Lower,+∞)、第n层[Lower,Upper);空头 第1层(−∞,Upper]、第n层(Lower,Upper]。
/// </summary>
[TestClass]
public class SwapSpanMarginCalcTest
{
private static SpanTierConfig Tier(double? lower, double? upper, double? amountRate)
=> new() { Lower = lower, Upper = upper, AmountRate = amountRate };
/// <summary>客户看多 4 层(A=3% 口径:97/94/91/88,比例 0/3%/6%/9%</summary>
private static List<SpanTierConfig> LongTiers() => new()
{
Tier(0.97, null, 0.00),
Tier(0.94, 0.97, 0.03),
Tier(0.91, 0.94, 0.06),
Tier(0.88, 0.91, 0.09),
};
/// <summary>客户看空 4 层(A=3% 口径:103/106/109/112,比例 0/3%/6%/9%</summary>
private static List<SpanTierConfig> ShortTiers() => new()
{
Tier(null, 1.03, 0.00),
Tier(1.03, 1.06, 0.03),
Tier(1.06, 1.09, 0.06),
Tier(1.09, 1.12, 0.09),
};
// ================================================================
// HasSpanConfig:方案B结构判定(存量 x/y 配置回落旧公式)
// ================================================================
[TestMethod]
public void SS_001_空配置_视为存量xy()
{
Assert.IsFalse(SwapSpanMarginCalc.HasSpanConfig(null));
Assert.IsFalse(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig()));
Assert.IsFalse(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig
{
LongSpans = new List<SpanTierConfig> { new SpanTierConfig(), null },
ShortSpans = new List<SpanTierConfig> { new SpanTierConfig() }
}));
}
[TestMethod]
public void SS_002_任一方向或线有值_视为新结构()
{
Assert.IsTrue(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig { WarnLine = 0.91 }));
Assert.IsTrue(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig { LongSpans = new List<SpanTierConfig> { Tier(0.97, null, 0) } }));
Assert.IsTrue(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig { ShortSpans = new List<SpanTierConfig> { Tier(null, 1.03, 0) } }));
}
// ================================================================
// IsCustomerLong:与确认书同规则(收取端同向、支付端反向,客户取反)
// ================================================================
[TestMethod]
public void SS_003_客户方向判定()
{
//我方收取端:PositionType 同向 → 我方多头 → 客户看空
Assert.IsFalse(SwapSpanMarginCalc.IsCustomerLong(posiDirection: 1, positionType: 1));
Assert.IsTrue(SwapSpanMarginCalc.IsCustomerLong(posiDirection: 1, positionType: 2));
//我方支付端:PositionType 反向 → 我方空头 → 客户看多
Assert.IsTrue(SwapSpanMarginCalc.IsCustomerLong(posiDirection: 2, positionType: 1));
Assert.IsFalse(SwapSpanMarginCalc.IsCustomerLong(posiDirection: 2, positionType: 2));
}
// ================================================================
// MatchTier 多头:[Lower, Upper),第1层上不封顶;边界恰好等于档界
// ================================================================
[TestMethod]
public void SS_004_多头落档_区间内与边界值()
{
var tiers = LongTiers();
//未触发(价格充足)→ 第1层
Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, true, 1.00));
Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, true, 0.97)); //恰好=第1层下界
//第2层 [0.94, 0.97)
Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, true, 0.9699));
Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, true, 0.94)); //恰好=第2层下界
//第3层 [0.91, 0.94)
Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, true, 0.9399));
Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, true, 0.91));
//第4层 [0.88, 0.91)
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, true, 0.9099));
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, true, 0.88)); //恰好=最深层下界
}
[TestMethod]
public void SS_005_多头穿出最深层_按最深层计()
{
var tiers = LongTiers();
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, true, 0.8799));
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, true, 0.50));
}
// ================================================================
// MatchTier 空头:(Lower, Upper],第1层下不设限;边界恰好等于档界
// ================================================================
[TestMethod]
public void SS_006_空头落档_区间内与边界值()
{
var tiers = ShortTiers();
Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, false, 1.00));
Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, false, 1.03)); //恰好=第1层上界
//第2层 (1.03, 1.06]
Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, false, 1.0301));
Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, false, 1.06)); //恰好=第2层上界
//第3层 (1.06, 1.09]
Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, false, 1.0601));
Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, false, 1.09));
//第4层 (1.09, 1.12]
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.0901));
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.12));
}
[TestMethod]
public void SS_007_空头穿出最深层_按最深层计()
{
var tiers = ShortTiers();
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.1201));
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 2.00));
}
[TestMethod]
public void SS_008_无可用层_返回null()
{
Assert.IsNull(SwapSpanMarginCalc.MatchTier(null, true, 0.9));
Assert.IsNull(SwapSpanMarginCalc.MatchTier(new List<SpanTierConfig>(), false, 1.0));
Assert.IsNull(SwapSpanMarginCalc.MatchTier(new List<SpanTierConfig> { new SpanTierConfig() }, true, 0.9));
}
// ================================================================
// 追保金额与维持保证金公式
// ================================================================
[TestMethod]
public void SS_009_追保金额_比例乘期初全价乘券面()
{
//第2层 3%0.03 × 期初全价101.5 × 券面10000 = 30450
Assert.AreEqual(30450, SwapSpanMarginCalc.CalcAdditionalMargin(Tier(0.94, 0.97, 0.03), 101.5, 10000), 1e-6);
//第1层 0% → 0
Assert.AreEqual(0, SwapSpanMarginCalc.CalcAdditionalMargin(Tier(0.97, null, 0), 101.5, 10000), 1e-6);
//未配置比例/null 层 → 0
Assert.AreEqual(0, SwapSpanMarginCalc.CalcAdditionalMargin(null, 101.5, 10000), 1e-6);
Assert.AreEqual(0, SwapSpanMarginCalc.CalcAdditionalMargin(Tier(0.94, 0.97, null), 101.5, 10000), 1e-6);
}
[TestMethod]
public void SS_010_维持保证金_初始加追加乘方向()
{
//我方净收取 +1
Assert.AreEqual(80450, SwapSpanMarginCalc.CalcMaintenanceMargin(50000, 30450, 1), 1e-6);
//我方净支付 1(负值=客户应收)
Assert.AreEqual(-80450, SwapSpanMarginCalc.CalcMaintenanceMargin(50000, 30450, -1), 1e-6);
}
/// <summary>docx 数值复算(客户看多,A=3%):期初净价98.5 → 当前净价92.0 落第3层,追加=6%×101×100000</summary>
[TestMethod]
public void SS_011_客户看多数值复算()
{
var tiers = LongTiers();
var ratio = 92.0 / 98.5;
var tier = SwapSpanMarginCalc.MatchTier(tiers, isCustomerLong: true, priceRatio: ratio);
Assert.AreSame(tiers[2], tier);
var additional = SwapSpanMarginCalc.CalcAdditionalMargin(tier, initPrice: 101.0, quantity: 100000);
Assert.AreEqual(0.06 * 101.0 * 100000, additional, 1e-6);
var maintenance = SwapSpanMarginCalc.CalcMaintenanceMargin(500000, additional, direction: 1);
Assert.AreEqual(500000 + 0.06 * 101.0 * 100000, maintenance, 1e-6);
}
/// <summary>docx 数值复算(客户看空,A=3%):期初价98.5 → 收盘价104.0 落第2层,追加=3%×101×100000</summary>
[TestMethod]
public void SS_012_客户看空数值复算()
{
var tiers = ShortTiers();
var ratio = 104.0 / 98.5;
var tier = SwapSpanMarginCalc.MatchTier(tiers, isCustomerLong: false, priceRatio: ratio);
Assert.AreSame(tiers[1], tier);
var additional = SwapSpanMarginCalc.CalcAdditionalMargin(tier, initPrice: 101.0, quantity: 100000);
Assert.AreEqual(0.03 * 101.0 * 100000, additional, 1e-6);
}
/// <summary>
/// BUG-25 回归:配置层未按浅→深排序时,穿出最深层的回落不依赖数组顺序——
/// 多头取最小下界层、空头取最大上界层(Last() 假定有序,乱序时回落错层)。
/// </summary>
[TestMethod]
public void SS_013_穿层回落_乱序配置按边界取最深层()
{
//多头乱序(浅→深:0.97/0.94/0.91/0.88 → 打乱为 0.91/0.97/0.88/0.94
var longShuffled = new List<SpanTierConfig>
{
Tier(0.91, 0.94, 0.06),
Tier(0.97, null, 0.00),
Tier(0.88, 0.91, 0.09),
Tier(0.94, 0.97, 0.03),
};
var longDeepest = SwapSpanMarginCalc.MatchTier(longShuffled, isCustomerLong: true, priceRatio: 0.85);
Assert.AreEqual(0.88, longDeepest.Lower);
Assert.AreEqual(0.09, longDeepest.AmountRate);
//空头乱序(浅→深:1.03/1.06/1.09/1.12 → 打乱为 1.09/1.03/1.12/1.06
var shortShuffled = new List<SpanTierConfig>
{
Tier(1.06, 1.09, 0.06),
Tier(null, 1.03, 0.00),
Tier(1.09, 1.12, 0.09),
Tier(1.03, 1.06, 0.03),
};
var shortDeepest = SwapSpanMarginCalc.MatchTier(shortShuffled, isCustomerLong: false, priceRatio: 1.15);
Assert.AreEqual(1.12, shortDeepest.Upper);
Assert.AreEqual(0.09, shortDeepest.AmountRate);
}
}
}
@@ -0,0 +1,187 @@
using YLErp.DBModels;
using YLErp.Modules.SwapModule.Margin;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// R2 阶段三 §3.1 引擎单笔计算场景测试(SwapSpanMarginCalc.CalcTradeMaintenanceMargin,收盘价由调用方解析后注入,
/// 覆盖"需要收盘价"的各场景:取到价落档、未取到价兜底、试算初始、期初价缺省、方向与腿缺失)。
/// 收盘价解析(债券中债估值净价/ETF收盘价/取不到置0)在 MarginCalculationBase.CalcSwapSpanMaintenanceMargin 胶水层,
/// 价格源本身的读库行为见 SwapSpanPriceSourceTest。
/// </summary>
[TestClass]
public class SwapSpanMarginEngineTest
{
private static SpanTierConfig Tier(double? lower, double? upper, double? amountRate)
=> new() { Lower = lower, Upper = upper, AmountRate = amountRate };
private static SpanConfig LongShortCfg() => new()
{
LongSpans = new List<SpanTierConfig>
{
Tier(0.97, null, 0.00), Tier(0.94, 0.97, 0.03), Tier(0.91, 0.94, 0.06), Tier(0.88, 0.91, 0.09)
},
ShortSpans = new List<SpanTierConfig>
{
Tier(null, 1.03, 0.00), Tier(1.03, 1.06, 0.03), Tier(1.06, 1.09, 0.06), Tier(1.09, 1.12, 0.09)
}
};
/// <summary>标的腿(多空),posiDirection/positionType 组合出方向</summary>
private static swap_position UnderlyingLeg(int posiDirection, int positionType,
decimal grossPrice, decimal? netPrice, decimal quantity, string code = "240004.IB")
=> new()
{
PosiDirection = posiDirection,
PositionType = positionType,
UnderlyingCode = code,
PosiGrossPrice = grossPrice,
PosiNetNoFeePrice = netPrice,
PosiQuantity = quantity
};
/// <summary>初始预付金腿(InterestMode=5),interestDirection 1=收取 2=支付</summary>
private static swap_position MarginLeg(int interestDirection, decimal principal)
=> new() { InterestMode = 5, InterestDirection = interestDirection, InterestPrincipalFix = principal };
// ================================================================
// 收盘价取到 → 落档计算
// ================================================================
/// <summary>债券客户看多:收取端标的腿 PositionType=Short → 客户看多;净价 92.0/期初净价 98.5 落第3层</summary>
[TestMethod]
public void SE_001_债券客户看多_净价落第3层()
{
var legs = new List<swap_position>
{
UnderlyingLeg(posiDirection: 1, positionType: 2, grossPrice: 101.0m, netPrice: 98.5m, quantity: 100000m),
MarginLeg(interestDirection: 1, principal: 500000m)
};
var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(
tradeInitialMargin: null, spanCfg: LongShortCfg(), legs, isInitialCalc: false, closePrice: 92.0);
//追加 = 6% × 期初全价101 × 券面100000 = 606000;维持 = 500000 + 606000
Assert.AreEqual(1106000, maintenance.Value, 1e-6);
}
/// <summary>ETF客户看空:收取端标的腿 PositionType=Long → 客户看空;收盘 1.32/期初净价 1.25 落第2层</summary>
[TestMethod]
public void SE_002_ETF客户看空_收盘价落第2层()
{
var legs = new List<swap_position>
{
UnderlyingLeg(posiDirection: 1, positionType: 1, grossPrice: 1.25m, netPrice: 1.25m, quantity: 1000000m, code: "511010.SH"),
MarginLeg(interestDirection: 1, principal: 200000m)
};
var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(
tradeInitialMargin: null, spanCfg: LongShortCfg(), legs, isInitialCalc: false, closePrice: 1.32);
//ratio=1.056 ∈ (1.03,1.06] → 追加 = 3% × 1.25 × 1000000 = 37500;维持 = 200000 + 37500
Assert.AreEqual(237500, maintenance.Value, 1e-6);
}
/// <summary>收盘价大幅下跌穿出最深层:按最深层比例计(追加=9%),不叠加也不归零</summary>
[TestMethod]
public void SE_003_价格穿出最深层_按最深层计()
{
var legs = new List<swap_position>
{
UnderlyingLeg(1, 2, grossPrice: 101.0m, netPrice: 98.5m, quantity: 100000m),
MarginLeg(1, 500000m)
};
var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 80.0);
Assert.AreEqual(500000 + 0.09 * 101.0 * 100000, maintenance.Value, 1e-6);
}
// ================================================================
// 收盘价未取到 / 试算 —— 兜底行为
// ================================================================
/// <summary>当天无收盘价(closePrice=0):不抛错,追加按0、维持=初始保证金(引擎侧另记告警日志)</summary>
[TestMethod]
public void SE_004_无收盘价_追加为0维持等于初始()
{
var legs = new List<swap_position>
{
UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m),
MarginLeg(1, 500000m)
};
var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, isInitialCalc: false, closePrice: 0);
Assert.AreEqual(500000, maintenance.Value, 1e-6);
}
/// <summary>试算初始(isInitialCalc=true):即使有收盘价也只出初始项——追加保证金为收盘后口径,试算不产出</summary>
[TestMethod]
public void SE_005_试算初始_不参与追加()
{
var legs = new List<swap_position>
{
UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m),
MarginLeg(1, 500000m)
};
var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, isInitialCalc: true, closePrice: 92.0);
Assert.AreEqual(500000, maintenance.Value, 1e-6);
}
/// <summary>期初净价缺失:比基回落期初全价(close/initGross</summary>
[TestMethod]
public void SE_006_期初净价缺省_回落期初全价()
{
var legs = new List<swap_position>
{
UnderlyingLeg(1, 2, grossPrice: 98.5m, netPrice: null, quantity: 100000m),
MarginLeg(1, 500000m)
};
//close=92.0/98.5 落第3层
var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 92.0);
Assert.AreEqual(500000 + 0.06 * 98.5 * 100000, maintenance.Value, 1e-6);
}
// ================================================================
// 腿数据缺失与方向
// ================================================================
/// <summary>无标的腿(缺期初价):返回 null,引擎跳过该交易不产出 trade_span</summary>
[TestMethod]
public void SE_007_无标的腿_返回null()
{
var legs = new List<swap_position> { MarginLeg(1, 500000m) };
Assert.IsNull(SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 92.0));
Assert.IsNull(SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), new List<swap_position>(), false, 92.0));
}
/// <summary>我方支付初始保证金(InterestDirection=支付):方向取 −1,维持保证金为负(客户应收)</summary>
[TestMethod]
public void SE_008_我方支付预付金_维持为负()
{
var legs = new List<swap_position>
{
UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m),
MarginLeg(interestDirection: 2, principal: 500000m)
};
var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 92.0);
Assert.AreEqual(-(500000 + 0.06 * 101.0 * 100000), maintenance.Value, 1e-6);
}
/// <summary>无预付金腿:初始保证金回落交易录入值 trade.InitialMargin(客户应付常态,方向+1</summary>
[TestMethod]
public void SE_009_无预付金腿_回落交易录入初始保证金()
{
var legs = new List<swap_position> { UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m) };
var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(300000, LongShortCfg(), legs, false, closePrice: 92.0);
Assert.AreEqual(300000 + 0.06 * 101.0 * 100000, maintenance.Value, 1e-6);
}
/// <summary>多条预付金腿按收付净额定初始与方向(净支付 → −1)</summary>
[TestMethod]
public void SE_010_多预付金腿_按净收取定方向()
{
var legs = new List<swap_position>
{
UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m),
MarginLeg(1, 200000m), //收取 20万
MarginLeg(2, 500000m) //支付 50万 → 净支付 30万
};
var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 0);
Assert.AreEqual(-300000, maintenance.Value, 1e-6);
}
}
}
@@ -0,0 +1,57 @@
using YLErp.DBModels;
using YLErp.Modules.DataProviderModule;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// R2 阶段三 §3.1 收盘价取数链路冒烟测试(只读 dev 库,不构造数据):
/// 债券净价源(china_bond_valuationGetBondPrice 口径 SettlePrice=净价、ClosePrice=全价,≤计算日 取最近——盘中/非交易日回退到最近已有估值);
/// 指数/ETF收盘价源(eod_stock_price)。
/// 两个源在"价格同步作业跑完前"决定引擎行为:取不到 → 追加按0、维持=初始(见 SwapSpanMarginEngineTest.SE_004)。
/// </summary>
[TestClass]
public class SwapSpanPriceSourceTest
{
[TestMethod]
public void SP_001_债券估值净价源_可取且净价为正()
{
using var db = DbContextFactory.GetYLDbContext();
var latest = db.china_bond_valuation
.Where(x => x.net_price > 0 && x.dirty_price_close > 0)
.OrderByDescending(x => x.valuation_date)
.Select(x => new { x.valuation_date, x.bond_id })
.FirstOrDefault();
if (latest == null)
{
Assert.Inconclusive("dev 库无中债估值数据,跳过");
}
//当日可取
Assert.IsTrue(EodPriceQueryService.TryGetBondEodPrice(latest.valuation_date, latest.bond_id, out var price));
Assert.IsTrue(price.SettlePrice > 0, "净价(SettlePrice)应为正");
Assert.IsTrue(price.ClosePrice > 0, "全价(ClosePrice)应为正");
//≤计算日 取最近:往未来多取几天仍回退到最近一条估值(盘中跑引擎即此语义)
Assert.IsTrue(EodPriceQueryService.TryGetBondEodPrice(latest.valuation_date.AddDays(5), latest.bond_id, out var fallback));
Assert.AreEqual(price.SettlePrice, fallback.SettlePrice, 1e-9);
}
[TestMethod]
public void SP_002_ETF收盘价源_可取()
{
using var db = DbContextFactory.GetYLDbContext();
var latest = db.eod_stock_price
.Where(x => x.ClosePrice > 0)
.OrderByDescending(x => x.ValueDate)
.Select(x => new { x.ValueDate, x.UnderlyingCode })
.FirstOrDefault();
if (latest == null)
{
Assert.Inconclusive("dev 库无股票/ETF日终价格数据,跳过");
}
Assert.IsTrue(EodPriceQueryService.TryGetEodPrice(latest.ValueDate, latest.UnderlyingCode, out var price));
Assert.IsTrue(price.GetPrice(SettlementTypeEnum.ClosePrice) > 0);
}
}
}
@@ -1,6 +1,7 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule.Margin;
namespace YLErp.Modules.SwapModule
{
@@ -113,6 +114,11 @@ namespace YLErp.Modules.SwapModule
CloseReCheckCallCount++;
}
// R4 按标签分流释放:纯内存测试不连库,stub 为全现金(与既有断言语义一致)
protected override UnwindTagSplit ReleaseMarginByFundTag(trade td, DateTime valueDate, List<swap_flow_event> interestEvents,
decimal marginAmount, decimal marginRebate)
=> new UnwindTagSplit { CashMargin = Convert.ToDouble(marginAmount), CashRebate = Convert.ToDouble(marginRebate) };
protected override void SaveAllChanges() { SaveAllChangesCount++; }
protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行
protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作
@@ -122,5 +122,14 @@ namespace YLErp.Modules.SwapModule
ClientCashCalls.Add((amount, action));
return _nextId++;
}
/// <summary>
/// R4 自动互换返息按标签分流:纯内存测试不连库(查 swap_position 标签),
/// stub 为全额现金返息(与既有断言语义一致)。
/// </summary>
protected override decimal GetAutoSwapCashRebate(trade td, List<swap_flow_event> flowEvents, decimal totalRebate)
{
return totalRebate;
}
}
}