#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
@@ -259,6 +259,11 @@ namespace YLErp.DBModels
public const string _互换 = "系统操作-互换";
public const string _应付预付金 = "系统操作-应付预付金";
public const string _预付金返息 = "系统操作-预付金返息";
/// <summary>
/// 阶段四 §4.1 合约维度(MarginWatchRule==0EOD 结算产生的追加保证金资金记录:
/// 交易级单条累计值(TradeId+Action+Deal=0 幂等 upsert),负数=客户应付追加;写入见 SwapAdditionalMarginService。
/// </summary>
public const string _追加保证金 = "系统操作-追加保证金";
public const string _其他 = "人工操作-其他";
public const string _预付金 = "人工操作-预付金";
public const string = "系统";
@@ -319,6 +324,10 @@ namespace YLErp.DBModels
{
action = "预付金返息";
}
else if (Action == "系统操作-追加保证金")
{
action = "追加保证金";
}
if (action != "")
{
return $"{ action}{(Money > 0 ? "" : "")}";
@@ -372,6 +381,10 @@ namespace YLErp.DBModels
{
action = "预付金返息";
}
else if (Action == "系统操作-追加保证金")
{
action = "追加保证金";
}
if (action != "")
{
return $"{ action}{(Money > 0 ? "" : "")}";
@@ -57,17 +57,17 @@ namespace YLErp.DBModels
}
/// <summary>
/// 获取期限的显示名称
/// 获取期限的显示名称(与设置页 bondTermLabel 同口径的开闭区间文案:≤5y / (5y-10y] / (10y-30y] / >30y;空=全部兜底档)
/// </summary>
public static string GetDisplayName(string term)
{
return term switch
{
UnderFiveYear => "<5y",
FiveToTenYear => "5y-10y",
TenToThirtyYear => "10y-30y",
UnderFiveYear => "5y",
FiveToTenYear => "(5y-10y]",
TenToThirtyYear => "(10y-30y]",
OverThirtyYear => ">30y",
_ => term ?? "<5y"
_ => string.IsNullOrEmpty(term) ? "全部" : term
};
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ namespace YLErp.DBModels
public double? MaxCreditUseRatio { get; set; }
/// <summary>
/// 已使用授信(R4,非持久化:授信出入表 SUM(占用)SUM(释放)+SUM(调整),列表展示用)
/// 已使用授信(R4,非持久化:授信出入表 Σ(amount),占用记正/释放记负,列表展示用)
/// </summary>
[DisplayName("已使用授信")]
[NotMapped]
@@ -223,8 +223,22 @@ namespace YLErp.Models
}
/// <summary>
/// 已使用授信(R2/R4,阶段三 §3.2):授信出入表 client_credit_inout 按 Σ(amount) 汇总(与资金流水同号口径),
/// 估值报告展示与可用资金公式共用。
/// </summary>
public double UsedCredit { get; set; }
/// <summary>
/// 互换初始保证金(R2,阶段三 §3.2,净收取为正):客户 应付预付金 流水收付净额(平仓返还自动冲减)。
/// </summary>
public double SwapInitMargin { get; set; }
/// <summary>
/// 交易维度追加保证金合计(R2/R3 合约维度公式输入):Σ(维持保证金 − 累计保证金),
/// 累计保证金=该交易 应付预付金+追加保证金 流水收付净额;阶段四产生追加保证金资金记录后口径自然完整。
/// </summary>
public double SwapAdditionalMarginTotal { get; set; }
private double _AvailableAmount;
@@ -1,4 +1,4 @@
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Linq;
using YLErp.Core.Helpers;
using YLErp.DBModels;
using YLErp.DBModels.Consts;
@@ -63,6 +63,53 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
return !isOurLong;
}
/// <summary>
/// 阶段三 §3.3 决策①:方案B手工录入结构直接赋值(不再用 100∓n×(x−y) 推导)。
/// 模板追保表 4 行 ↔ SpanConfig 4 层:多头 第1层=[Lower,+∞)(模板行0只用下限)、第n层=[Lower,Upper)
/// 空头 第1层=(−∞,Upper](模板行0只用上限)、第n层=(Lower,Upper]
/// 追保金额比例 = AmountRate×100(累计到该层的追保比例,直接取值),金额文字口径 n%×期初全价×券面总额 由模板静态文字承载;
/// 预警线/平仓线直接取录入值,未录时按 docx 口径取边界(平仓线=最深层边界、预警线=次深层边界);
/// 配置层不足 4 层时余下行置空;返还追保事件表与追保事件同值(模板静态文字区分方向措辞)。
/// </summary>
private static void FillSpanConfigLines(Dictionary<string, JToken> dic, SpanConfig spanCfg, bool posiLong)
{
var tiers = (posiLong ? spanCfg.LongSpans : spanCfg.ShortSpans) ?? new List<SpanTierConfig>();
var valid = tiers.Where(t => t != null && (t.Lower.HasValue || t.Upper.HasValue)).ToList();
var last = valid.LastOrDefault();
var warnLine = spanCfg.WarnLine;
var closeLine = spanCfg.CloseLine;
if (last != null)
{
closeLine ??= posiLong ? last.Lower : last.Upper;
warnLine ??= posiLong ? last.Upper : last.Lower;
}
dic["预警线"] = warnLine.HasValue ? (warnLine.Value * 100).ToString("0.##") : "";
dic["平仓线"] = closeLine.HasValue ? (closeLine.Value * 100).ToString("0.##") : "";
//档位n = 第n层触发线(多头取各层 Lower、空头取各层 Upper),现用模板无档位占位符,保留赋值兼容旧模板
for (int n = 1; n <= 4; n++)
{
var tier = n <= valid.Count ? valid[n - 1] : null;
var line = tier == null ? null : (posiLong ? tier.Lower : tier.Upper);
dic[$"档位{n}"] = line.HasValue ? (line.Value * 100).ToString("0.##") : "";
}
//追保事件/返还追保事件区间表:行0=第1层,行1..3=第2..4层;
//与上方档位循环同用过滤后的 valid 列表(BUG-26:配置中间夹空层时两类占位符不错位)
for (int i = 0; i <= 3; i++)
{
var tier = i < valid.Count ? valid[i] : null;
var hasTier = tier != null && (tier.Lower.HasValue || tier.Upper.HasValue);
dic[$"追保区间{i}下限"] = hasTier && tier.Lower.HasValue ? (tier.Lower.Value * 100).ToString("0.##") : "";
dic[$"追保区间{i}上限"] = hasTier && tier.Upper.HasValue ? (tier.Upper.Value * 100).ToString("0.##") : "";
dic[$"追保金额比例{i}"] = hasTier && tier.AmountRate.HasValue ? (tier.AmountRate.Value * 100).ToString("0.##") : "";
dic[$"返还区间{i}下限"] = dic[$"追保区间{i}下限"];
dic[$"返还区间{i}上限"] = dic[$"追保区间{i}上限"];
dic[$"返还金额比例{i}"] = dic[$"追保金额比例{i}"];
}
}
/// <summary>
/// 获取收益互换模板路径
/// </summary>
@@ -254,158 +301,142 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
: swapRatePosition != null ? "" : "0.0000";
// 获取客户适用的保证金率
//保证金模板V2迁移:优先按交易绑定的预付金模板V2(区间追保结构)取 初始率x/维持率y;
//取不到(未绑定/模板无效/无匹配明细)时回退旧的互换预付金率维护(client_margin_config)链路
//阶段三 §3.3 决策①:档位/预警线/平仓线/追保区间统一按交易绑定的预付金模板V2参数赋值——
//方案B手工录入结构(SpanConfig.WarnLine/CloseLine/LongSpans/ShortSpans)直接取录入值,不再用 100∓n×(x−y) 推导;
//旧互换预付金率维护(client_margin_config / UnderlyingHelper.GetApplicableMarginRate)回退链路已移除
//(本注释保留一个观察周期后删除):取不到模板参数时率显示 0、档位与区间置空。
var templateRate = MarginTemplateV2RateHelper.GetTradeMarginRate(
trade.id, swapPosition.UnderlyingCode, swapPosition.UnderlyingInstrumentType, trade.TradeDate ?? DateTime.Now);
var clientMarginRate = templateRate != null
? new client_margin_detail { init_rate = templateRate.InitRate ?? 0, maintain_rate = templateRate.MaintainRate ?? 0 }
: UnderlyingHelper.GetApplicableMarginRate(
client.id, swapPosition.UnderlyingCode, trade.TradeDate ?? DateTime.Now);
var initRate = templateRate?.InitRate ?? 0m;
var maintainRate = templateRate?.MaintainRate ?? 0m;
var spanCfg = templateRate?.Detail?.SpanConfig;
if (clientMarginRate != null)
{
dic["初始保障金率"] = ((double)clientMarginRate.init_rate * 100).ToString("N4");
dic["维持保障金率"] = ((double)clientMarginRate.maintain_rate * 100).ToString("N4");
dic["初始保障金率"] = ((double)initRate * 100).ToString("N4");
dic["维持保障金率"] = ((double)maintainRate * 100).ToString("N4");
// 期初预付比例和金额
dic["期初预付比例"] = ((double)clientMarginRate.init_rate * 100).ToString("0.##");
dic["期初预付金额"] = ((trade.OriginalStockEqvNotional ?? 0) * (double)clientMarginRate.init_rate).ToString("N2");
}
else
{
dic["初始保障金率"] = "0.0000";
dic["维持保障金率"] = "0.0000";
dic["期初预付比例"] = "0";
dic["期初预付金额"] = "0.00";
}
// 期初预付比例和金额
dic["期初预付比例"] = ((double)initRate * 100).ToString("0.##");
dic["期初预付金额"] = ((trade.OriginalStockEqvNotional ?? 0) * (double)initRate).ToString("N2");
bool posiLong = IsCustomerLong(swapPosition);
// 计算平仓线、预警线、档位值(利率债TRS,固定4档)
// A = 维持保证金率(%)B = 初始保证金率(%)diff = B - A
var maintainRatePercent = (double)(clientMarginRate?.maintain_rate ?? 0) * 100; // A(%)
var initRatePercent = (double)(clientMarginRate?.init_rate ?? 0) * 100; // B(%)
var diff = initRatePercent - maintainRatePercent; // B - A
if (maintainRatePercent > 0)
var maintainRatePercent = (double)maintainRate * 100; // A(%)
if (YLErp.Modules.SwapModule.Margin.SwapSpanMarginCalc.HasSpanConfig(spanCfg))
{
if (posiLong)
{
// 客户看多:档位n = 100 - n * (B-A)
dic["档位1"] = (100 - 1 * diff).ToString("0.##");
dic["档位2"] = (100 - 2 * diff).ToString("0.##");
dic["档位3"] = (100 - 3 * diff).ToString("0.##");
dic["档位4"] = (100 - 4 * diff).ToString("0.##");
dic["预警线"] = dic["档位4"]; // 预警线 = 最高档位
dic["平仓线"] = (100 - 5 * diff).ToString("0.##"); // 平仓线 = 最高档位 - (B-A)
}
else
{
// 客户看空:档位n = 100 + n * (B-A)
dic["档位1"] = (100 + 1 * diff).ToString("0.##");
dic["档位2"] = (100 + 2 * diff).ToString("0.##");
dic["档位3"] = (100 + 3 * diff).ToString("0.##");
dic["档位4"] = (100 + 4 * diff).ToString("0.##");
dic["预警线"] = dic["档位4"]; // 预警线 = 最高档位
dic["平仓线"] = (100 + 5 * diff).ToString("0.##"); // 平仓线 = 最高档位 + (B-A)
}
//方案B:直接取录入的预警/平仓线与多空区间(未录线时按 docx 口径取最深/次深档位边界)
FillSpanConfigLines(dic, spanCfg, posiLong);
}
else
{
dic["平仓线"] = "";
dic["预警线"] = "";
dic["档位1"] = "";
dic["档位2"] = "";
dic["档位3"] = "";
dic["档位4"] = "";
}
// 追保事件/返还追保事件 价格区间和金额比例
if (maintainRatePercent > 0)
{
if (posiLong)
//存量 x/y 配置:维持 100∓n×(B−A) 推导(B=初始率,A=维持率)
var initRatePercent = (double)initRate * 100; // B(%)
var diff = initRatePercent - maintainRatePercent; // B - A
if (maintainRatePercent > 0)
{
// === 客户看多 追保事件 ===
// 区间0:当前净价 ≥ {100-(B-A)}% × 期初净价,追保0%
dic["追保区间0下限"] = (100 - 1 * diff).ToString("0.##");
dic["追保金额比例0"] = "0";
// 区间1{100-2(B-A)}% ≤ 当前净价 < {100-(B-A)}%,追保{B-A}%
dic["追保区间1下限"] = (100 - 2 * diff).ToString("0.##");
dic["追保区间1上限"] = (100 - 1 * diff).ToString("0.##");
dic["追保金额比例1"] = (1 * diff).ToString("0.##");
// 区间2{100-3(B-A)}% ≤ 当前净价 < {100-2(B-A)}%,追保{2(B-A)}%
dic["追保区间2下限"] = (100 - 3 * diff).ToString("0.##");
dic["追保区间2上限"] = (100 - 2 * diff).ToString("0.##");
dic["追保金额比例2"] = (2 * diff).ToString("0.##");
// 区间3{100-4(B-A)}% ≤ 当前净价 < {100-3(B-A)}%,追保{3(B-A)}%
dic["追保区间3下限"] = (100 - 4 * diff).ToString("0.##");
dic["追保区间3上限"] = (100 - 3 * diff).ToString("0.##");
dic["追保金额比例3"] = (3 * diff).ToString("0.##");
// === 客户看多 返还追保事件 ===
// 区间0:当前净价 ≥ {100-(B-A)}% × 期初净价,返还0%
dic["返还区间0下限"] = (100 - 1 * diff).ToString("0.##");
dic["返还金额比例0"] = "0";
// 区间1{100-2(B-A)}% ≤ 当前净价 < {100-(B-A)}%,返还{B-A}%
dic["返还区间1下限"] = (100 - 2 * diff).ToString("0.##");
dic["返还区间1上限"] = (100 - 1 * diff).ToString("0.##");
dic["返还金额比例1"] = (1 * diff).ToString("0.##");
// 区间2{100-3(B-A)}% ≤ 当前净价 < {100-2(B-A)}%,返还{2(B-A)}%
dic["返还区间2下限"] = (100 - 3 * diff).ToString("0.##");
dic["返还区间2上限"] = (100 - 2 * diff).ToString("0.##");
dic["返还金额比例2"] = (2 * diff).ToString("0.##");
// 区间3{100-4(B-A)}% ≤ 当前净价 < {100-3(B-A)}%,返还{3(B-A)}%
dic["返还区间3下限"] = (100 - 4 * diff).ToString("0.##");
dic["返还区间3上限"] = (100 - 3 * diff).ToString("0.##");
dic["返还金额比例3"] = (3 * diff).ToString("0.##");
if (posiLong)
{
// 客户看多:档位n = 100 - n * (B-A)
dic["档位1"] = (100 - 1 * diff).ToString("0.##");
dic["档位2"] = (100 - 2 * diff).ToString("0.##");
dic["档位3"] = (100 - 3 * diff).ToString("0.##");
dic["档位4"] = (100 - 4 * diff).ToString("0.##");
dic["预警线"] = dic["档位4"]; // 预警线 = 最高档位
dic["平仓线"] = (100 - 5 * diff).ToString("0.##"); // 平仓线 = 最高档位 - (B-A)
}
else
{
// 客户看空:档位n = 100 + n * (B-A)
dic["档位1"] = (100 + 1 * diff).ToString("0.##");
dic["档位2"] = (100 + 2 * diff).ToString("0.##");
dic["档位3"] = (100 + 3 * diff).ToString("0.##");
dic["档位4"] = (100 + 4 * diff).ToString("0.##");
dic["预警线"] = dic["档位4"]; // 预警线 = 最高档位
dic["平仓线"] = (100 + 5 * diff).ToString("0.##"); // 平仓线 = 最高档位 + (B-A)
}
}
else
{
// === 客户看空 追保事件 ===
// 区间0:当前净价 ≤ {100+(B-A)}% × 期初净价,追保0%
dic["追保区间0上限"] = (100 + 1 * diff).ToString("0.##");
dic["追保金额比例0"] = "0";
// 区间1{100+(B-A)}% < 当前净价 ≤ {100+2(B-A)}%,追保{B-A}%
dic["追保区间1下限"] = (100 + 1 * diff).ToString("0.##");
dic["追保区间1上限"] = (100 + 2 * diff).ToString("0.##");
dic["追保金额比例1"] = (1 * diff).ToString("0.##");
// 区间2{100+2(B-A)}% < 当前净价 ≤ {100+3(B-A)}%,追保{2(B-A)}%
dic["追保区间2下限"] = (100 + 2 * diff).ToString("0.##");
dic["追保区间2上限"] = (100 + 3 * diff).ToString("0.##");
dic["追保金额比例2"] = (2 * diff).ToString("0.##");
// 区间3{100+3(B-A)}% < 当前净价 ≤ {100+4(B-A)}%,追保{3(B-A)}%
dic["追保区间3下限"] = (100 + 3 * diff).ToString("0.##");
dic["追保区间3上限"] = (100 + 4 * diff).ToString("0.##");
dic["追保金额比例3"] = (3 * diff).ToString("0.##");
// === 客户看空 返还追保事件 ===
// 区间0:当前净价 ≤ {100+(B-A)}% × 期初净价,返还0%
dic["返还区间0上限"] = (100 + 1 * diff).ToString("0.##");
dic["返还金额比例0"] = "0";
// 区间1{100+(B-A)}% < 当前净价 ≤ {100+2(B-A)}%,返还{B-A}%
dic["返还区间1下限"] = (100 + 1 * diff).ToString("0.##");
dic["返还区间1上限"] = (100 + 2 * diff).ToString("0.##");
dic["返还金额比例1"] = (1 * diff).ToString("0.##");
// 区间2{100+2(B-A)}% < 当前净价 ≤ {100+3(B-A)}%,返还{2(B-A)}%
dic["返还区间2下限"] = (100 + 2 * diff).ToString("0.##");
dic["返还区间2上限"] = (100 + 3 * diff).ToString("0.##");
dic["返还金额比例2"] = (2 * diff).ToString("0.##");
// 区间3{100+3(B-A)}% < 当前净价 ≤ {100+4(B-A)}%,返还{3(B-A)}%
dic["返还区间3下限"] = (100 + 3 * diff).ToString("0.##");
dic["返还区间3上限"] = (100 + 4 * diff).ToString("0.##");
dic["返还金额比例3"] = (3 * diff).ToString("0.##");
dic["平仓线"] = "";
dic["预警线"] = "";
dic["档位1"] = "";
dic["档位2"] = "";
dic["档位3"] = "";
dic["档位4"] = "";
}
}
else
{
// 清空追保/返还相关字典值
for (int i = 0; i <= 3; i++)
if (maintainRatePercent > 0)
{
dic[$"追保区间{i}下限"] = "";
dic[$"追保区间{i}上限"] = "";
dic[$"追保金额比例{i}"] = "";
dic[$"返还区间{i}下限"] = "";
dic[$"返还区间{i}上限"] = "";
dic[$"返还金额比例{i}"] = "";
if (posiLong)
{
// === 客户看多 追保事件 ===
// 区间0:当前净价 ≥ {100-(B-A)}% × 期初净价,追保0%
dic["追保区间0下限"] = (100 - 1 * diff).ToString("0.##");
dic["追保金额比例0"] = "0";
// 区间1{100-2(B-A)}% ≤ 当前净价 < {100-(B-A)}%,追保{B-A}%
dic["追保区间1下限"] = (100 - 2 * diff).ToString("0.##");
dic["追保区间1上限"] = (100 - 1 * diff).ToString("0.##");
dic["追保金额比例1"] = (1 * diff).ToString("0.##");
// 区间2{100-3(B-A)}% ≤ 当前净价 < {100-2(B-A)}%,追保{2(B-A)}%
dic["追保区间2下限"] = (100 - 3 * diff).ToString("0.##");
dic["追保区间2上限"] = (100 - 2 * diff).ToString("0.##");
dic["追保金额比例2"] = (2 * diff).ToString("0.##");
// 区间3{100-4(B-A)}% ≤ 当前净价 < {100-3(B-A)}%,追保{3(B-A)}%
dic["追保区间3下限"] = (100 - 4 * diff).ToString("0.##");
dic["追保区间3上限"] = (100 - 3 * diff).ToString("0.##");
dic["追保金额比例3"] = (3 * diff).ToString("0.##");
// === 客户看多 返还追保事件 ===
for (int n = 1; n <= 3; n++)
{
dic[$"返还区间{n}下限"] = (100 - (n + 1) * diff).ToString("0.##");
dic[$"返还区间{n}上限"] = (100 - n * diff).ToString("0.##");
dic[$"返还金额比例{n}"] = (n * diff).ToString("0.##");
}
dic["返还区间0下限"] = (100 - 1 * diff).ToString("0.##");
dic["返还金额比例0"] = "0";
}
else
{
// === 客户看空 追保事件 ===
// 区间0:当前净价 ≤ {100+(B-A)}% × 期初净价,追保0%
dic["追保区间0上限"] = (100 + 1 * diff).ToString("0.##");
dic["追保金额比例0"] = "0";
// 区间1{100+(B-A)}% < 当前净价 ≤ {100+2(B-A)}%,追保{B-A}%
dic["追保区间1下限"] = (100 + 1 * diff).ToString("0.##");
dic["追保区间1上限"] = (100 + 2 * diff).ToString("0.##");
dic["追保金额比例1"] = (1 * diff).ToString("0.##");
// 区间2{100+2(B-A)}% < 当前净价 ≤ {100+3(B-A)}%,追保{2(B-A)}%
dic["追保区间2下限"] = (100 + 2 * diff).ToString("0.##");
dic["追保区间2上限"] = (100 + 3 * diff).ToString("0.##");
dic["追保金额比例2"] = (2 * diff).ToString("0.##");
// 区间3{100+3(B-A)}% < 当前净价 ≤ {100+4(B-A)}%,追保{3(B-A)}%
dic["追保区间3下限"] = (100 + 3 * diff).ToString("0.##");
dic["追保区间3上限"] = (100 + 4 * diff).ToString("0.##");
dic["追保金额比例3"] = (3 * diff).ToString("0.##");
// === 客户看空 返还追保事件 ===
for (int n = 1; n <= 3; n++)
{
dic[$"返还区间{n}下限"] = (100 + n * diff).ToString("0.##");
dic[$"返还区间{n}上限"] = (100 + (n + 1) * diff).ToString("0.##");
dic[$"返还金额比例{n}"] = (n * diff).ToString("0.##");
}
dic["返还区间0上限"] = (100 + 1 * diff).ToString("0.##");
dic["返还金额比例0"] = "0";
}
}
else
{
// 清空追保/返还相关字典值
for (int i = 0; i <= 3; i++)
{
dic[$"追保区间{i}下限"] = "";
dic[$"追保区间{i}上限"] = "";
dic[$"追保金额比例{i}"] = "";
dic[$"返还区间{i}下限"] = "";
dic[$"返还区间{i}上限"] = "";
dic[$"返还金额比例{i}"] = "";
}
}
}
@@ -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;
}
}
}
@@ -11,6 +11,8 @@ using YLErp.Model;
using YLErp.Models;
using YLErp.Modules;
using YLErp.Modules.ClientModule;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Margin;
namespace YLErp.BLL.EodSettlement
{
@@ -296,6 +298,12 @@ namespace YLErp.BLL.EodSettlement
var endDatas = endQuery.ToArray();
//阶段三 §3.2 估值报告口径输入:已使用授信(授信出入表)、互换初始保证金(应付预付金净额)、
//交易维度追加保证金合计(Σ(维持−累计),规则15交易)——与实时口径共用同一查询(SwapSpanBalanceQueryService
var usedCreditDic = ClientCreditInoutService.GetUsedCreditByClients(clientIdS, db);
var swapInitMarginDic = SwapSpanBalanceQueryService.GetSwapInitMarginByClients(clientIdS, lastDate, db);
var swapAdditionalDic = SwapSpanBalanceQueryService.GetTradeAdditionalMarginByClients(clientIdS, lastDate, db);
foreach (var data in endDatas)
{
if (dic.TryGetValue(data.ClientId, out var balance))
@@ -360,12 +368,49 @@ namespace YLErp.BLL.EodSettlement
balance.VmOutFundSum = data.VmOutFundSum ?? 0;
//预付金金额=期末结存-初始预付金金额
balance.MarginBalance = balance.AmountFund - balance.MySideMargin;
// 可用资金 = 期末结存 - 追保账户余额 - 初始保证金
balance.AvailableAmount = balance.MarginBalance - (balance.VmInFundSum - balance.VmOutFundSum);
// 是否追保=盯市金额小于维持保证金额
balance.NeedAddMargin = balance.SwapMarketAmount < balance.MaintenanceMargin;
// 追保金额=初始保证金金额-盯市金额
balance.MarginByPayableMarginTotal = balance.NeedAddMargin ? (balance.MySideMargin - balance.SwapMarketAmount) : 0;
//阶段三 §3.2:已使用授信/互换初始保证金/交易维度追加保证金(估值报告字段与可用资金公式共用输入)
balance.UsedCredit = usedCreditDic.TryGetValue(data.ClientId, out var usedCredit) ? usedCredit : 0;
balance.SwapInitMargin = swapInitMarginDic.TryGetValue(data.ClientId, out var swapInit) ? swapInit : 0;
balance.SwapAdditionalMarginTotal = swapAdditionalDic.TryGetValue(data.ClientId, out var swapAdd) ? swapAdd : 0;
// 可用资金:配置了追保规则维度的客户按维度分流(R2公式,实时/EOD三处一致);
// 未配置(MarginWatchRule=NULL,存量客户)维持旧口径:期末结存-追保账户余额
if (client?.MarginWatchRule == 0)
{
balance.AvailableAmount = SwapSpanBalanceCalc.CalcContractDimensionAvailable(
balance.AmountFund, balance.TotalCredit, balance.UsedCredit, balance.SwapAdditionalMarginTotal);
}
else if (client?.MarginWatchRule == 1)
{
//维持保证金(净收取为正)= -MySideMarginclient_span 维持保证金反号聚合)
balance.AvailableAmount = SwapSpanBalanceCalc.CalcClientDimensionAvailable(
balance.AmountFund, balance.TotalCredit, balance.UsedCredit,
balance.SwapInitMargin, -balance.MySideMargin);
}
else
{
balance.AvailableAmount = balance.MarginBalance - (balance.VmInFundSum - balance.VmOutFundSum);
}
// 是否追保/追保金额(阶段四 §4.2 按维度分流,允许负值=双向,不以 0 截断):
// 客户维度(==1)= (维持−初始) − (现金+授信−已使用),负=可返还;
// 合约维度(==0)= −(现金+授信−已使用)(需求原文公式的应追加方向取值,开放问题2,两值均有产出);
// 未配置(NULL 存量)维持旧口径:盯市低于维持时 = 初始保证金金额−盯市金额,否则 0
if (client?.MarginWatchRule == 1)
{
balance.MarginByPayableMarginTotal = SwapSpanBalanceCalc.CalcClientDimensionCallMargin(
balance.AmountFund, balance.TotalCredit, balance.UsedCredit, balance.SwapInitMargin, -balance.MySideMargin);
balance.NeedAddMargin = balance.MarginByPayableMarginTotal > 0;
}
else if (client?.MarginWatchRule == 0)
{
balance.MarginByPayableMarginTotal = SwapSpanBalanceCalc.CalcContractDimensionCallMargin(
balance.AmountFund, balance.TotalCredit, balance.UsedCredit);
balance.NeedAddMargin = balance.MarginByPayableMarginTotal > 0;
}
else
{
balance.NeedAddMargin = balance.SwapMarketAmount < balance.MaintenanceMargin;
balance.MarginByPayableMarginTotal = balance.NeedAddMargin ? (balance.MySideMargin - balance.SwapMarketAmount) : 0;
}
// 可取资金=Math.Max(期末结存-min(持仓盈亏,0)-初始保证金,0)
balance.DesirableFund = Math.Max(balance.MarginBalance + Math.Min(balance.RoundedPositionPnl, 0), 0);
@@ -234,6 +234,14 @@ namespace YLErp.BLL.Eod
Dictionary<int, DateTime?> dicIntradayTradePosition = DbContext.intraday_trade_position.Where(x => clientIds.Contains(x.ClientId)).AsEnumerable().GroupBy(p => p.ClientId).Select(p => new { p.Key, OptDate = p.Max(d => d.OptDate) })
.ToDictionary(p => p.Key, p => p.OptDate);
Dictionary<int, DateTime?> dicCashInOut = DbContext.ClientCashInCashOut.Where(x => clientIds.Contains((int)x.ClientId)).AsEnumerable().GroupBy(p => (int)p.ClientId).Select(p => new { p.Key, OptDate = p.Max(d => d.OptDate) }).ToDictionary(p => p.Key, p => p.OptDate);
//阶段三 §3.2 估值报告口径输入(与 ClientBalanceUtility EOD/报告路径共用同一查询,保证三处一致):
//已使用授信(授信出入表)、互换初始保证金(应付预付金净额)、交易维度追加保证金合计(Σ(维持−累计))
var clientIdList = clientIds.ToList();
var usedCreditDic = Modules.SwapModule.ClientCreditInoutService.GetUsedCreditByClients(clientIdList, DbContext);
var swapInitMarginDic = SwapSpanBalanceQueryService.GetSwapInitMarginByClients(clientIdList, calcDate.Value, DbContext);
var swapAdditionalDic = SwapSpanBalanceQueryService.GetTradeAdditionalMarginByClients(clientIdList, calcDate.Value, DbContext);
foreach (var item in _clientBalanceDic.Values)
{
#region
@@ -269,12 +277,51 @@ namespace YLErp.BLL.Eod
item.AvailableStockEqvNotional = item.TotalCreditStockEqvNotional - item.PositionNotionalPrincipal;
//预付金金额=期末结存-初始预付金金额
item.MarginBalance = item.AmountFund - item.MySideMargin;
// 可用资金 = 期末结存 - 追保账户余额 - 初始保证金
item.AvailableAmount = item.MarginBalance - item.FrozenMarginMoney;
// 是否追保=盯市金额小于维持保证金额
item.NeedAddMargin = item.SwapMarketAmount < item.MaintenanceMargin;
// 追保金额=初始保证金金额-盯市金额
item.MarginByPayableMarginTotal = item.NeedAddMargin ? (item.MySideMargin - item.SwapMarketAmount) : 0;
//阶段三 §3.2:已使用授信/互换初始保证金/交易维度追加保证金(与 EOD/报告口径一致)
item.UsedCredit = usedCreditDic.TryGetValue(clientId, out var usedCredit) ? usedCredit : 0;
item.SwapInitMargin = swapInitMarginDic.TryGetValue(clientId, out var swapInit) ? swapInit : 0;
item.SwapAdditionalMarginTotal = swapAdditionalDic.TryGetValue(clientId, out var swapAdd) ? swapAdd : 0;
// 可用资金:配置了追保规则维度的客户按维度分流(R2公式,实时/EOD三处一致);
// 未配置(MarginWatchRule=NULL,存量客户)维持旧口径:期末结存-冻结资金
var ruleClient = DataCacheProvider.GetClientDataSource().GetData(clientId);
if (ruleClient?.MarginWatchRule == 0)
{
item.AvailableAmount = SwapSpanBalanceCalc.CalcContractDimensionAvailable(
item.AmountFund, item.TotalCredit, item.UsedCredit, item.SwapAdditionalMarginTotal);
}
else if (ruleClient?.MarginWatchRule == 1)
{
//维持保证金(净收取为正)= -MySideMarginclient_span 维持保证金反号聚合)
item.AvailableAmount = SwapSpanBalanceCalc.CalcClientDimensionAvailable(
item.AmountFund, item.TotalCredit, item.UsedCredit,
item.SwapInitMargin, -item.MySideMargin);
}
else
{
item.AvailableAmount = item.MarginBalance - item.FrozenMarginMoney;
}
// 是否追保/追保金额(阶段四 §4.2 按维度分流,与 ClientBalanceUtility 报告口径一致):
// 客户维度(==1)双向追保 = (维持−初始) − (现金+授信−已使用),允许负值(负=可返还);
// 合约维度(==0)= −(现金+授信−已使用);NULL 存量维持旧口径
if (ruleClient?.MarginWatchRule == 1)
{
item.MarginByPayableMarginTotal = SwapSpanBalanceCalc.CalcClientDimensionCallMargin(
item.AmountFund, item.TotalCredit, item.UsedCredit, item.SwapInitMargin, -item.MySideMargin);
item.NeedAddMargin = item.MarginByPayableMarginTotal > 0;
}
else if (ruleClient?.MarginWatchRule == 0)
{
item.MarginByPayableMarginTotal = SwapSpanBalanceCalc.CalcContractDimensionCallMargin(
item.AmountFund, item.TotalCredit, item.UsedCredit);
item.NeedAddMargin = item.MarginByPayableMarginTotal > 0;
}
else
{
// 是否追保=盯市金额小于维持保证金额
item.NeedAddMargin = item.SwapMarketAmount < item.MaintenanceMargin;
// 追保金额=初始保证金金额-盯市金额
item.MarginByPayableMarginTotal = item.NeedAddMargin ? (item.MySideMargin - item.SwapMarketAmount) : 0;
}
// 可取资金=max(期末结存+min(持仓盈亏,0)-初始保证金,0)
item.DesirableFund =Math.Max( item.MarginBalance - item.FrozenMarginMoney + Math.Min(item.RoundedPositionPnl, 0),0);
}
@@ -798,7 +845,9 @@ namespace YLErp.BLL.Eod
{
clientEntryexitsSwap.ForEach(clientEntryexit =>
{
if (ClientCashInCashOut._期权费.Equals(clientEntryexit.Action) || ClientCashInCashOut._应付预付金.Equals(clientEntryexit.Action) || ClientCashInCashOut._预付金返息.Equals(clientEntryexit.Action))
if (ClientCashInCashOut._期权费.Equals(clientEntryexit.Action) || ClientCashInCashOut._应付预付金.Equals(clientEntryexit.Action) || ClientCashInCashOut._预付金返息.Equals(clientEntryexit.Action)
//阶段四 §4.1:追加保证金(EOD 结算产生的合约维度追保入金)与应付预付金同口径计入当日资金变动
|| ClientCashInCashOut._追加保证金.Equals(clientEntryexit.Action))
{
//CurrPnl += clientEntryexit.Money ?? 0.0;
CurrChangeAmount += clientEntryexit.Money ?? 0.0;
@@ -1227,9 +1276,12 @@ namespace YLErp.BLL.Eod
{ //资金净流入 + 权利金收支 + 期权费收支
var balance = _clientBalanceDic[data.ClientId];
if (data.action == "应收^" + ClientCashInCashOut._期权费 || data.action == "应收^" + ClientCashInCashOut._应付预付金 || data.action == "应收^" + ClientCashInCashOut._预付金返息)
if (data.action == "应收^" + ClientCashInCashOut._期权费 || data.action == "应收^" + ClientCashInCashOut._应付预付金 || data.action == "应收^" + ClientCashInCashOut._预付金返息
//阶段四 §4.1:追加保证金与应付预付金同桶(互换应收),当日已结算/已确认记录计入
|| data.action == "应收^" + ClientCashInCashOut._追加保证金)
{
balance.OptionPremiumSwap = data.moneySum;
//同桶多 Action(应付预付金+追加保证金等)并存须累加——与多币种分支(OptionPremiumSwap +=)一致(BUG-08
balance.OptionPremiumSwap += data.moneySum;
}
else if (data.action == "应收^" + ClientCashInCashOut._互换)
{
@@ -4,9 +4,11 @@ using YLErp.Enums;
using YLErp.Helpers;
using YLErp.Model.Enum;
using YLErp.Modules;
using YLErp.DBModels.Enums;
using YLErp.Modules.CalculationModule;
using YLErp.Modules.DataProviderModule;
using YLErp.Modules.MarginModule;
using YLErp.Modules.SwapModule.Margin;
using YLErp.Modules.TradeModule;
namespace YLErp.BLL.MarginCalculation
@@ -366,32 +368,34 @@ namespace YLErp.BLL.MarginCalculation
}
//收益互换预付金计算
//预付金模板V2迁移:绑定了模板的互换交易按模板规则计算——无预付金=0;区间追保结构=名义本金×x(初始)/名义本金×y(持仓),
//x/y 由 MarginTemplateV2RateHelper 按 期限档+资产类型 匹配明细取得。未绑定或绑定其他规则的互换交易维持现状(不产出 trade_span)。
//预付金模板V2迁移:按 R1 三层级(交易绑定→客户默认→全局默认,ResolveTieredTemplates 找到即停)解析模板——
//无预付金=0;区间追保结构=名义本金×x(初始)/名义本金×y(持仓),x/y 由 MarginTemplateV2RateHelper 按 期限档+资产类型 匹配明细取得。
//三级均未命中或命中其他规则的互换交易维持现状(不产出 trade_span),与确认书/取数口径一致(BUG-02 修正)。
//阶段三 §3.1:区间追保结构明细按方案B录入新区间结构(SpanConfig.LongSpans/ShortSpans)时切换新引擎——
//每日取标的收盘价落档(债券净价/指数及ETF收盘价),维持保证金 = (初始保证金 + 总追加保证金) × 我方净收取方向;
//存量 x/y 配置(无新区间结构)维持 名义×y 公式不变。
private static List<trade_span> SwapTradeMarginCalculation(RunMarginCalculationReq req)
{
var result = new List<trade_span>();
var swapTradeIds = req.tradeList.Where(t => t.ParentTradeId == 0 && t.id > 0).Select(t => t.id).ToList();
if (swapTradeIds.Count == 0)
var calcTrades = req.tradeList.Where(t => t.ParentTradeId == 0 && t.id > 0).ToList();
if (calcTrades.Count == 0)
{
return result;
}
var swapTradeIds = calcTrades.Select(t => t.id).ToList();
using (var db = new YLContext())
{
var bindings = db.trade_margin_template.AsNoTracking()
.Where(x => swapTradeIds.Contains(x.TradeId) && x.ValueDate <= req.settleDate)
.ToList();
if (bindings.Count == 0)
{
return result;
}
//R1 三层级找到即停(BUG-02 修正):交易绑定 → 客户默认 → 全局默认 统一解析,
//只配了客户/全局默认模板的交易与交易级绑定同权生效,与确认书/取数口径一致
var templatesByTrade = MarginTemplateV2RateHelper.ResolveTieredTemplates(calcTrades, req.settleDate, db);
var templateIds = bindings.Select(x => x.MarginTemplateId).Distinct().ToList();
var templates = db.margin_template_v2.AsNoTracking()
.Where(x => templateIds.Contains(x.id) && x.IsValid)
//期初腿数据:标的腿(多空)取 期初价格/数量/方向,初始预付金腿取 初始保证金与收取方向
var swapPositions = db.swap_position.AsNoTracking()
.Where(x => swapTradeIds.Contains(x.SwapTradeId) && !x.Invalid && x.IsInitial)
.ToList();
var positionsByTrade = swapPositions.GroupBy(x => x.SwapTradeId).ToDictionary(g => g.Key, g => g.ToList());
foreach (var trade in req.tradeList)
{
@@ -401,29 +405,40 @@ namespace YLErp.BLL.MarginCalculation
continue;
}
var binding = bindings.Where(x => x.TradeId == trade.id).OrderByDescending(x => x.ValueDate).FirstOrDefault();
var template = binding == null ? null : templates.FirstOrDefault(x => x.id == binding.MarginTemplateId);
if (template == null)
if (!templatesByTrade.TryGetValue(trade.id, out var template))
{
continue;
}
double rate;
double? margin;
if (template.RuleType == (int)MarginRuleTypeEnum.)
{
rate = 0;
margin = 0;
}
else if (template.RuleType == (int)MarginRuleTypeEnum.)
{
var rateResult = MarginTemplateV2RateHelper.GetTradeMarginRate(trade.id, trade.UnderlyingCode, trade.UnderlyingInstrumentType, req.settleDate, db);
var rateResult = MarginTemplateV2RateHelper.GetRateByTemplate(template, trade.UnderlyingCode, trade.UnderlyingInstrumentType, req.settleDate, db);
if (rateResult == null)
{
continue;
}
//初始预付金=名义本金×x;持仓预付金=名义本金×y
rate = (double)(req.CalcMarginType == CalcMarginTypeEnum.InitialMargin
? rateResult.InitRate ?? 0m
: rateResult.MaintainRate ?? 0m);
var spanCfg = rateResult.Detail?.SpanConfig;
if (SwapSpanMarginCalc.HasSpanConfig(spanCfg))
{
//方案B新结构:收盘价落档 → 维持保证金 = (初始 + 总追加) × 方向
margin = CalcSwapSpanMaintenanceMargin(trade, spanCfg,
positionsByTrade.TryGetValue(trade.id, out var legs) ? legs : new List<swap_position>(),
req.CalcMarginType == CalcMarginTypeEnum.InitialMargin, req.settleDate);
}
else
{
//存量 x/y 配置:初始预付金=名义本金×x;持仓预付金=名义本金×y
var rate = (double)(req.CalcMarginType == CalcMarginTypeEnum.InitialMargin
? rateResult.InitRate ?? 0m
: rateResult.MaintainRate ?? 0m);
margin = rate * trade.StockEqvNotional;
}
}
else
{
@@ -431,7 +446,12 @@ namespace YLErp.BLL.MarginCalculation
continue;
}
var margin = rate * trade.StockEqvNotional;
if (margin == null)
{
//新引擎无法计算(缺标的腿等),不产出 trade_span,与未匹配明细行为一致
continue;
}
result.Add(new trade_span()
{
TradeId = trade.id,
@@ -442,16 +462,16 @@ namespace YLErp.BLL.MarginCalculation
UnderlyingId = trade.UnderlyingId,
UnderlyingCode = trade.UnderlyingCode,
ValueDate = req.settleDate,
Spv1 = margin,
Spv2 = margin,
Spv3 = margin,
Spv4 = margin,
Spv5 = margin,
Spv6 = margin,
Spv7 = margin,
Spv8 = margin,
Spv = margin,
WorstCastClientPayable = margin,
Spv1 = margin.Value,
Spv2 = margin.Value,
Spv3 = margin.Value,
Spv4 = margin.Value,
Spv5 = margin.Value,
Spv6 = margin.Value,
Spv7 = margin.Value,
Spv8 = margin.Value,
Spv = margin.Value,
WorstCastClientPayable = margin.Value,
Margin = 0
});
}
@@ -459,5 +479,59 @@ namespace YLErp.BLL.MarginCalculation
return result;
}
/// <summary>
/// 阶段三 §3.1 新引擎:规则15 方案B配置的维持保证金(DB 胶水层——只负责解析收盘价,计算在 SwapSpanMarginCalc.CalcTradeMaintenanceMargin 纯函数,单测覆盖)。
/// 收盘价口径:债券取中债估值净价(GetBondPrice 口径 SettlePrice=净价;取 ≤计算日 最近一条,盘中跑用最近已有估值),
/// 指数/ETF取收盘价(InnerGetEodPrice 将日期规整到最近交易日);未取到 → closePrice=0(追加按0、维持=初始),仅记日志不抛错。
/// </summary>
private static double? CalcSwapSpanMaintenanceMargin(trade trade, SpanConfig spanCfg, List<swap_position> legs, bool isInitialCalc, DateTime settleDate)
{
//标的腿(多空):期初价格、数量、客户方向
var underlyingLeg = legs.FirstOrDefault(x => x.PositionType == (int)PositionTypeFlag.Long || x.PositionType == (int)PositionTypeFlag.Short);
if (underlyingLeg == null || underlyingLeg.PosiGrossPrice <= 0)
{
logger.Info($"【警告】规则15新引擎:交易{trade.id}无有效标的腿(期初价),跳过新公式计算");
return null;
}
double closePrice;
if (isInitialCalc)
{
//试算初始不依赖收盘价
closePrice = 0;
}
else
{
var underlyingCode = !string.IsNullOrEmpty(underlyingLeg.UnderlyingCode) ? underlyingLeg.UnderlyingCode : trade.UnderlyingCode;
var isBond = DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode)?.IsBond() ?? false;
if (isBond)
{
if (!EodPriceQueryService.TryGetBondEodPrice(settleDate, underlyingCode, out var bondPrice))
{
logger.Info($"【警告】规则15新引擎:交易{trade.id}标的{underlyingCode}无债券估值净价,追加保证金按0计(维持=初始)");
closePrice = 0;
}
else
{
closePrice = bondPrice.GetPrice(SettlementTypeEnum.SettlePrice);
}
}
else
{
if (!EodPriceQueryService.TryGetEodPrice(settleDate, underlyingCode, out var eodPrice))
{
logger.Info($"【警告】规则15新引擎:交易{trade.id}标的{underlyingCode}无收盘价,追加保证金按0计(维持=初始)");
closePrice = 0;
}
else
{
closePrice = eodPrice.GetPrice(SettlementTypeEnum.ClosePrice);
}
}
}
return SwapSpanMarginCalc.CalcTradeMaintenanceMargin(trade.InitialMargin, spanCfg, legs, isInitialCalc, closePrice);
}
}
}
@@ -23,6 +23,11 @@ namespace YLErp.Modules.ClientModule
//原始授信值 × 最大授信可用比例(NULL按1) 折算出最终授信值;
//OriginalCredit 为空时不触碰 Credit,维持手工授信值(存量兼容)
//比例服务端校验 0-1(BUG-10:此前仅前端校验,绕过前端可存负值或>1放大授信)
if (req.MaxCreditUseRatio.HasValue && (req.MaxCreditUseRatio.Value < 0 || req.MaxCreditUseRatio.Value > 1))
{
throw new ServiceException("最大授信可用比例须在0-1之间");
}
if (req.OriginalCredit.HasValue)
{
req.Credit = req.OriginalCredit.Value * (req.MaxCreditUseRatio ?? 1);
@@ -37,6 +37,10 @@ namespace YLErp.Modules.EodModule.SettlementModule
var balanceDate = _context.SettleDate;
var reqClientIds = _context.Request.ClientIds;
//阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易按 维持−累计 产生"追加保证金"资金记录(授信优先,幂等)。
//必须在下方出入金查询之前执行:当日新记录计入当日结算窗口并翻"已结算",重跑时 目标/已补足 不变→同值覆盖,首跑与重跑口径一致
new SwapAdditionalMarginService(this).SettleAdditionalMargin(balanceDate, reqClientIds?.ToList());
var clientDb = DbContextFactory.GetClientDbContext(OptUser);
var currencyCodes = _context.CurrencyCodes;
@@ -654,7 +658,9 @@ namespace YLErp.Modules.EodModule.SettlementModule
}
}
if (ClientCashInCashOut._期权费.Equals(clientEntryexit.Action) || ClientCashInCashOut._应付预付金.Equals(clientEntryexit.Action) || ClientCashInCashOut._预付金返息.Equals(clientEntryexit.Action))
if (ClientCashInCashOut._期权费.Equals(clientEntryexit.Action) || ClientCashInCashOut._应付预付金.Equals(clientEntryexit.Action) || ClientCashInCashOut._预付金返息.Equals(clientEntryexit.Action)
//阶段四 §4.1:追加保证金(EOD 结算产生的合约维度追保入金)与应付预付金同口径计入当日资金变动
|| ClientCashInCashOut._追加保证金.Equals(clientEntryexit.Action))
{
CurrChangeAmount += clientEntryexit.Money ?? 0.0;
OptionPremiumSwap += clientEntryexit.Money ?? 0.0;
@@ -4,6 +4,8 @@ using System.Linq;
using YLErp.BLL;
using YLErp.DBModels;
using YLErp.Enums;
using YLErp.Helpers;
using YLErp.Models;
using YLErp.Modules.UnderlyingModule;
namespace YLErp.Modules.MarginModule
@@ -15,6 +17,8 @@ namespace YLErp.Modules.MarginModule
/// </summary>
public static class MarginTemplateV2RateHelper
{
private static readonly IYcLogger logger = LogFactory.GetLogger("预付金模板取数");
/// <summary>
/// 取数结果。率为小数(如 0.1 表示 10%,与数据库一致),调用方直接用。
/// </summary>
@@ -59,25 +63,29 @@ namespace YLErp.Modules.MarginModule
/// <summary>
/// 按交易绑定的模板取 初始/维持预付金率(调用方传入 DbContext,供批量场景复用连接)。
/// R1 三层级找到即停(2026-08-21 补齐客户/全局两级):交易绑定(自定义)→ 客户默认(client_margin_template
/// 按客户或客户等级)→ 全局默认(margin_template_v2 IsDefault&&!IsForClient,适用结构需含收益互换);
/// 三级均未命中返回 null(由调用方兜底,此处留日志:视为无预付金要求)。
/// </summary>
public static MarginRateResult GetTradeMarginRate(int tradeId, string underlyingCode, string underlyingInstrumentType, DateTime valueDate, YLContext db)
{
//1.交易绑定(ValueDate 最新)
var binding = db.trade_margin_template.AsNoTracking()
.Where(x => x.TradeId == tradeId && x.ValueDate <= valueDate)
.OrderByDescending(x => x.ValueDate)
.FirstOrDefault();
if (binding == null)
{
return null;
}
var template = db.margin_template_v2.AsNoTracking().FirstOrDefault(x => x.id == binding.MarginTemplateId);
if (template == null || !template.IsValid)
//R1 三层级找到即停:交易绑定(自定义)→ 客户默认 → 全局默认
var clientId = db.trade.AsNoTracking().Where(t => t.id == tradeId).Select(t => (int?)t.ClientId).FirstOrDefault() ?? 0;
var template = ResolveTieredTemplate(tradeId, clientId, valueDate, db);
if (template == null)
{
logger.Info($"【警告】预付金模板取数:交易{tradeId}(客户{clientId})三级(交易绑定/客户默认/全局默认)均未命中有效模板,视为无预付金要求");
return null;
}
return GetRateByTemplate(template, underlyingCode, underlyingInstrumentType, valueDate, db);
}
/// <summary>
/// 已解析模板的取率(GetTradeMarginRate 去掉三层级解析的部分;引擎批量解析后逐交易调用,避免二次解析)。
/// 规则非 无预付金/区间追保结构 或明细无匹配行时返回 null(由调用方兜底)。
/// </summary>
public static MarginRateResult GetRateByTemplate(margin_template_v2 template, string underlyingCode, string underlyingInstrumentType, DateTime valueDate, YLContext db)
{
//2.无预付金规则:率直接为 0
if (template.RuleType == (int)MarginRuleTypeEnum.)
{
@@ -149,6 +157,196 @@ namespace YLErp.Modules.MarginModule
};
}
/// <summary>
/// R1 模板三层级找到即停解析(public 供集成测试与后续消费方复用):
/// 1.交易绑定 trade_margin_template(自定义/录入时显式选择,ValueDate 最新 ≤ 业务日)——找到即停,
/// 绑定指向已失效模板时同样停止(不向下回退,避免"改绑定失效后悄悄换成别的规则"),返回 null 并留日志;
/// 2.客户默认 client_margin_template(按客户 ClientId 或按客户等级 ClientId=0+ClientLevel
/// 客户等级经 client.LevelId → clientlevel.LevelName 解析;ValueDate 最新 ≤ 业务日),
/// 模板须有效且适用结构含收益互换;
/// 3.全局默认 margin_template_v2IsDefault && !IsForClient && IsValid && 适用收益互换,ValueDate 最新)。
/// </summary>
public static margin_template_v2 ResolveTieredTemplate(int? tradeId, int clientId, DateTime valueDate, YLContext db)
{
//1.交易绑定(找到即停)
if (tradeId > 0)
{
var bindingTemplateId = db.trade_margin_template.AsNoTracking()
.Where(x => x.TradeId == tradeId.Value && x.ValueDate <= valueDate)
.OrderByDescending(x => x.ValueDate)
.ThenByDescending(x => x.id)
.Select(x => (int?)x.MarginTemplateId)
.FirstOrDefault();
if (bindingTemplateId > 0)
{
var bound = db.margin_template_v2.AsNoTracking().FirstOrDefault(x => x.id == bindingTemplateId.Value && x.IsValid);
if (bound != null)
{
return bound;
}
logger.Info($"【警告】预付金模板取数:交易{tradeId}绑定的模板{bindingTemplateId}已失效或不存在,按找到即停不向下回退");
return null;
}
}
//2.客户默认(client_margin_template:按客户 或 按客户等级;客户表在独立客户库)
//先按 有效+适用收益互换 过滤再取绑定日期最新——同日并存"互换绑定+期权绑定"(页面互斥只挡适用结构重叠)时,
//若先取绑定再过滤会随机命中期权绑定而漏掉互换绑定、误穿透到全局
if (clientId > 0)
{
string levelName = null;
using (var clientDb = DbContextFactory.GetClientDbContext(OptUserInfo.SystemUser))
{
var levelId = clientDb.client.AsNoTracking().Where(c => c.id == clientId).Select(c => c.LevelId).FirstOrDefault();
if (levelId > 0)
{
levelName = clientDb.clientlevel.AsNoTracking().Where(l => l.id == levelId).Select(l => l.LevelName).FirstOrDefault();
}
}
var clientTemplate = (from cmt in db.client_margin_template.AsNoTracking()
join mt in db.margin_template_v2.AsNoTracking() on cmt.MarginTemplateId equals mt.id
where cmt.ValueDate <= valueDate && cmt.MarginTemplateId > 0
&& (cmt.ClientId == clientId || (cmt.ClientId == 0 && cmt.ClientLevel != "" && cmt.ClientLevel == levelName))
&& mt.IsValid && mt.TradeTypes.Contains("收益互换")
orderby cmt.ValueDate descending, cmt.id descending
select mt).FirstOrDefault();
if (clientTemplate != null)
{
return clientTemplate;
}
}
//3.全局默认
return db.margin_template_v2.AsNoTracking()
.Where(x => x.IsDefault && !x.IsForClient && x.IsValid && x.TradeTypes.Contains("收益互换") && x.ValueDate <= valueDate)
.OrderByDescending(x => x.ValueDate)
.ThenByDescending(x => x.id)
.FirstOrDefault();
}
/// <summary>
/// R1 三层级找到即停解析(批量版,BUG-02 修正:EOD 引擎与结算查询按交易列表解析,
/// 只配了客户默认/全局默认模板的交易与交易级绑定同权生效):
/// 语义与单笔 ResolveTieredTemplate 一致——
/// 1.交易绑定(ValueDate 最新 ≤ 业务日,同日多条按 id 最新兜底)找到即停,绑定指向失效模板同样停止(不回退);
/// 2.客户默认(client_margin_template 按客户或按客户等级,模板须有效且适用结构含收益互换,先过滤有效再取绑定最新);
/// 3.全局默认(IsDefault&&!IsForClient&&IsValid&&适用收益互换,ValueDate/id 最新)。
/// 返回 tradeId → 命中模板;未命中或因失效绑定停止的交易不在结果中。
/// </summary>
public static Dictionary<int, margin_template_v2> ResolveTieredTemplates(List<trade> trades, DateTime valueDate, YLContext db)
{
var result = new Dictionary<int, margin_template_v2>();
if (trades == null || trades.Count == 0)
{
return result;
}
var tradeIds = trades.Select(t => t.id).Distinct().ToList();
//1.交易绑定(批量取回后内存取每交易最新一条,同日多条按 id 最新兜底——与单笔解析一致)
var bindingRows = db.trade_margin_template.AsNoTracking()
.Where(x => tradeIds.Contains(x.TradeId) && x.ValueDate <= valueDate)
.Select(x => new { x.TradeId, x.MarginTemplateId, x.ValueDate, x.id })
.ToList();
var bindingByTrade = bindingRows
.GroupBy(x => x.TradeId)
.ToDictionary(g => g.Key,
g => g.OrderByDescending(x => x.ValueDate).ThenByDescending(x => x.id).First().MarginTemplateId);
var boundTemplates = new Dictionary<int, margin_template_v2>();
if (bindingByTrade.Values.Distinct().Any())
{
var boundTemplateIds = bindingByTrade.Values.Distinct().ToList();
boundTemplates = db.margin_template_v2.AsNoTracking()
.Where(x => boundTemplateIds.Contains(x.id) && x.IsValid)
.ToDictionary(x => x.id);
}
//2.客户默认(client_margin_template:按客户 或 按客户等级;客户表在独立客户库,等级名批量解析)
var clientIds = trades.Select(t => t.ClientId).Where(c => c > 0).Distinct().ToList();
var clientTemplateByClient = new Dictionary<int, margin_template_v2>();
if (clientIds.Any())
{
var levelNames = new Dictionary<int, string>();
using (var clientDb = DbContextFactory.GetClientDbContext(OptUserInfo.SystemUser))
{
var clientLevels = clientDb.client.AsNoTracking()
.Where(c => clientIds.Contains(c.id) && c.LevelId > 0)
.Select(c => new { c.id, LevelId = c.LevelId ?? 0 })
.ToList();
var levelIds = clientLevels.Select(c => c.LevelId).Distinct().ToList();
var levelNameById = clientDb.clientlevel.AsNoTracking()
.Where(l => levelIds.Contains(l.id))
.Select(l => new { l.id, l.LevelName })
.ToList()
.ToDictionary(l => l.id, l => l.LevelName);
foreach (var c in clientLevels)
{
levelNames[c.id] = levelNameById.TryGetValue(c.LevelId, out var name) ? name : null;
}
}
//先按 有效+适用收益互换 过滤再按绑定取最新(与单笔解析一致,同日互换/期权并存不误穿透)
var clientBindingRows = (from cmt in db.client_margin_template.AsNoTracking()
join mt in db.margin_template_v2.AsNoTracking() on cmt.MarginTemplateId equals mt.id
where cmt.ValueDate <= valueDate && cmt.MarginTemplateId > 0
&& (clientIds.Contains(cmt.ClientId) || (cmt.ClientId == 0 && cmt.ClientLevel != ""))
&& mt.IsValid && mt.TradeTypes.Contains("收益互换")
orderby cmt.ValueDate descending, cmt.id descending
select new { cmt.ClientId, cmt.ClientLevel, Template = mt }).ToList();
foreach (var clientId in clientIds)
{
var levelName = levelNames.TryGetValue(clientId, out var name) ? name : null;
//每客户找到即停:按客户 或 按客户等级 同查询取绑定最新(与单笔解析一致;
//直接绑定与等级绑定并存的优先级方案未定义(BUG-16),维持"绑定日期最新"现状不改语义)
var hit = clientBindingRows
.FirstOrDefault(r => r.ClientId == clientId || (r.ClientId == 0 && r.ClientLevel == levelName));
if (hit != null)
{
clientTemplateByClient[clientId] = hit.Template;
}
}
}
//3.全局默认(全部交易共享,一次查询)
var globalDefault = db.margin_template_v2.AsNoTracking()
.Where(x => x.IsDefault && !x.IsForClient && x.IsValid && x.TradeTypes.Contains("收益互换") && x.ValueDate <= valueDate)
.OrderByDescending(x => x.ValueDate)
.ThenByDescending(x => x.id)
.FirstOrDefault();
foreach (var trade in trades)
{
if (result.ContainsKey(trade.id))
{
continue;
}
if (bindingByTrade.TryGetValue(trade.id, out var templateId))
{
//一级找到即停:绑定指向失效模板时不向下回退(与单笔解析一致,仅留日志)
if (boundTemplates.TryGetValue(templateId, out var bound))
{
result[trade.id] = bound;
}
else
{
logger.Info($"【警告】预付金模板取数:交易{trade.id}绑定的模板{templateId}已失效或不存在,按找到即停不向下回退");
}
continue;
}
if (clientTemplateByClient.TryGetValue(trade.ClientId, out var clientTemplate))
{
result[trade.id] = clientTemplate;
continue;
}
if (globalDefault != null)
{
result[trade.id] = globalDefault;
}
}
return result;
}
/// <summary>
/// 标的细分分类判定钩子(转债ETF/科创债ETF/中债指数等),public 供单元测试回归默认行为。
/// 本期默认返回 null → 走通配行兜底,行为与现状一致;后续需求按业务给的判定规则(代码段/标的维护字段)实现。
@@ -319,6 +319,19 @@ namespace YLErp.Modules.ReportModule
}
}
/// <summary>
/// 已使用授信(R2 阶段三 §3.2):授信出入表 Σ(amount)
/// </summary>
public double? UsedCredit { get; set; }
public string UsedCreditString
{
get
{
return ((UsedCredit == null || UsedCredit == -0) ? 0 : UsedCredit.Value).ToString("0.00");
}
}
/// <summary>
/// 授信占用
/// </summary>
@@ -107,6 +107,10 @@ namespace YLErp.Modules.ReportModule
AvailableFund = clientBalance?.AvailableAmount ?? 0,
TotalMargin = clientBalance?.TotalMarginTotal,
Credit = clientBalance?.TotalCredit ?? 0,
UsedCredit = clientBalance?.UsedCredit ?? 0,
//阶段三 §3.2 R2 口径:初始保证金=应付预付金流水净额(净收取为正);维持保证金=client_span 维持保证金(−MySideMargin
MySideMargin = clientBalance?.SwapInitMargin ?? 0,
MaintenanceMargin = -(clientBalance?.MySideMargin ?? 0),
CreditRatio = clientBalance?.CreditUsed ?? 0,
Margin = clientBalance?.MarginByPayableMarginTotal ?? 0,
Amount = (PS.Config.IsPVRounded ? clientBalance?.RoundedTotalAmountTotal : clientBalance?.TotalAmountTotal) ?? 0,
@@ -408,6 +412,10 @@ namespace YLErp.Modules.ReportModule
case "预付金占用": return report.FundReportModel.WorstCastClientPayableString;
case "可用资金": return report.FundReportModel.AvailableFundString;
case "授信额度": return report.FundReportModel.CreditString;
//阶段三 §3.2 R2 估值报告字段口径
case "已使用授信": return report.FundReportModel.UsedCreditString;
case "初始保证金金额": return report.FundReportModel.MySideMarginString;
case "维持保证金金额": return report.FundReportModel.MaintenanceMarginString;
case "应付了结交易款": return report.FundReportModel.ClosedTradePayableFundString;
case "应付存续交易款": return report.FundReportModel.PositionTradePayableFundString;
case "存续交易期权费净额": return report.FundReportModel.PositionPremiumNetCashString;
@@ -6,12 +6,20 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// 客户授信出入服务(R4 授信/现金标签体系,决策④)。
/// 授信出入表的写入口集中在 标签赋值(占用)与 平仓/到期返还(释放)两个链路内,禁止散落调用。
/// 金额符号口径(2026-08-20 业务确认):与资金流水同号——入金为负、出金为正;
/// 已使用授信 = SUM(amount) 直接求和(入金使已使用授信下降、可用授信=有效授信已使用授信 上升;出金反之)。
/// 金额符号口径(BUG-01 修正,2026-08-24):**占用记正数、释放记负数**(与 §2.2 原表"占用/调整为正"一致)——
/// 已使用授信 = SUM(amount) 直接求和(占用使其上升、释放使其回落),可用授信 = 有效授信 已使用授信(占用使其收缩)。
/// 2026-08-20 曾裁定"与资金流水同号(入金负/出金正)"——但授信不入资金流水(§2.1),同号对齐的对手方记录并不存在,
/// 且该口径下"占用越多已使用授信越负、可用授信越大"(越占越多),已废弃;历史同号行由迁移脚本翻符号。
/// change_type(占用/释放/调整)仅作分类审计,不参与求和方向。
/// </summary>
public class ClientCreditInoutService : YLBaseService
{
/// <summary>
/// 追加保证金相关授信出入记录的 remark 前缀(阶段四 §4.1 EOD 占用写入"追加保证金占用";
/// 累计保证金/缺口口径与清理链路以此识别,后续如写释放类记录沿用同一前缀)。
/// </summary>
public const string AdditionalMarginRemark = "追加保证金";
public ClientCreditInoutService(OptUserInfo userInfo) : base(userInfo)
{
}
@@ -21,9 +29,19 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 客户已使用授信 = SUM(amount)(金额与资金流水同号:入金负、出金正)。
/// 可用授信 = 有效授信 − 本值;入金使可用授信增长、出金使其收缩。
/// 供交易确认校验(RealtimePnlCalc.TradeCanBeConfirm)等静态上下文直接调用。
/// 是否追加保证金相关记录(remark 前缀标识,阶段四 EOD 写入)。
/// </summary>
public static bool IsAdditionalMarginRecord(client_credit_inout record)
{
return record != null && !string.IsNullOrEmpty(record.remark)
&& record.remark.StartsWith(AdditionalMarginRemark);
}
/// <summary>
/// 客户已使用授信 = SUM(amount)(占用为正、释放为负)。
/// 可用授信 = 有效授信 − 本值:占用使其收缩、释放使其回升。
/// 供交易确认校验(RealtimePnlCalc.TradeCanBeConfirm)、拆单额度(GetAvailableCredit)、
/// 估值报告可用资金(阶段三 §3.2)等消费方统一调用。
/// </summary>
public static double GetUsedCredit(int clientId, YLContext db)
{
@@ -40,8 +58,8 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 写入一条授信变化记录(占用/释放/调整统一入口),并冗余记录变更后已使用授信。
/// amount 带符号:入金(客户付)为负、出金(客户收)为正,与资金流水 Money 同号
/// 写入一条授信变化记录(统一入口),并冗余记录变更后已使用授信。
/// amount 带符号直写(人工调整类可正可负);占用/释放请走 Occupy/Release 语义入口(内部定方向)
/// </summary>
public client_credit_inout Record(int clientId, long? positionId, int? tradeId, int changeType,
double amount, DateTime happenDate, string remark)
@@ -51,7 +69,7 @@ namespace YLErp.Modules.SwapModule
return null;
}
var amountRounded = Math.Round(amount, 2, MidpointRounding.AwayFromZero);
//变更后已使用授信快照:直接求和口径(符号已含方向)
//变更后已使用授信快照:直接求和口径(占用正/释放负,符号已含方向)
var usedAfter = Math.Round(GetUsedCredit(clientId) + amountRounded, 2, MidpointRounding.AwayFromZero);
var record = new client_credit_inout
{
@@ -74,31 +92,37 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// 占用:预付金腿标 Credit 的簿记入金(含拆单的授信部分;交易级占用 positionId 为空)。
/// amount 传数(入金方向,与资金流水同号)
/// amount 传数(占用数量),内部记正——已使用授信随之上升
/// </summary>
public client_credit_inout Occupy(int clientId, long? positionId, int tradeId, double amount, DateTime happenDate, string remark)
{
return Record(clientId, positionId, tradeId, client_credit_inout.ChangeTypeOccupy, amount, happenDate, remark);
return Record(clientId, positionId, tradeId, client_credit_inout.ChangeTypeOccupy, Math.Abs(amount), happenDate, remark);
}
/// <summary>
/// 释放:平仓/到期按原标签返还授信部分(按腿的 position_id 匹配原占用记录)。
/// amount 传正数(出金方向,与资金流水同号)
/// amount 传正数(释放数量),内部记负——已使用授信随之回落
/// </summary>
public client_credit_inout Release(int clientId, long? positionId, int tradeId, double amount, DateTime happenDate, string remark)
{
return Record(clientId, positionId, tradeId, client_credit_inout.ChangeTypeRelease, amount, happenDate, remark);
return Record(clientId, positionId, tradeId, client_credit_inout.ChangeTypeRelease, -Math.Abs(amount), happenDate, remark);
}
/// <summary>
/// 删除某交易的全部授信出入记录:与资金记录同生命周期——
/// 删除某交易的授信出入记录:与资金记录同生命周期——
/// 交易回退到开仓(DeleteTradeCashInCashOut)、修改清除(ClearSwapPositions)、删除交易时同步清理,
/// 重新确认/重补时按最新标签重写,避免占用悬挂。
/// 注意:阶段四追加保证金占用落地后,此处需区分保留追加部分。
/// keepAdditionalMargin=true 时保留追加保证金部分(阶段四落地):重确认自愈/修改清除只重写 应付预付金 相关占用,
/// 追加部分由 EOD 幂等维护(该两处不删除追加资金记录,授信占用须同步保留,否则已使用授信被低估);
/// 删除交易(资金记录全删)传 false 全清。
/// </summary>
public void RemoveByTrade(int tradeId)
public void RemoveByTrade(int tradeId, bool keepAdditionalMargin = false)
{
var records = DbContext.client_credit_inout.Where(x => x.trade_id == tradeId).ToList();
if (keepAdditionalMargin)
{
records = records.Where(x => !IsAdditionalMarginRecord(x)).ToList();
}
DbContext.client_credit_inout.RemoveRange(records);
}
@@ -0,0 +1,43 @@
namespace YLErp.Modules.SwapModule.Margin
{
/// <summary>
/// R3 阶段四 §4.1 合约维度追加保证金的纯函数(金额判定与授信/现金分配,DB 胶水在 SwapAdditionalMarginService)。
/// 口径(实现方案 §0 基线 + 需求拆分 R3):
/// - 累计保证金(净收取为正)= 该交易 应付预付金+追加保证金 资金记录收付净额 + 追加保证金的授信占用部分(授信不进资金、无流水);
/// - 目标追加保证金(累计到当日)= 维持保证金 − 应付预付金净收额,≤0 时为 0(追保回落不返还,负缺口在可用资金公式体现);
/// - 已补足额 = 追加资金记录累计值(单条 upsert 存累计值,Money 为负)+ 授信占用累计(amount 为负);
/// - 当日新增 = 目标 − 已补足,&gt;0 时按阶段二 §2.3 授信优先逻辑分配:授信部分只写授信出入表,差额走资金记录。
/// 幂等:同一结算日重跑时 目标/已补足 均不变 → 新增=0,不产生任何写入。
/// </summary>
public static class SwapAdditionalMarginCalc
{
/// <summary>
/// 目标追加保证金(累计到当日,净收取为正)= 维持保证金 − 应付预付金净收额;差值 ≤0 返回 0(无需追加;
/// 维持为负=我方净支付方向,同样不追)。
/// </summary>
public static double CalcTarget(double maintenanceMargin, double payableMarginNet)
{
return Round(Math.Max(maintenanceMargin - payableMarginNet, 0));
}
/// <summary>
/// 当日新增追加保证金的授信/现金分配(阶段二 §2.3 授信优先的交易级版本):
/// 授信部分 = min(新增, 剩余可用授信),差额为现金部分;新增 ≤0 时两者皆 0(不产生)。
/// </summary>
public static (double CreditAmount, double CashAmount) Allocate(double increment, double creditAvailable)
{
increment = Round(increment);
if (increment <= 0)
{
return (0, 0);
}
var credit = Math.Min(increment, Round(Math.Max(creditAvailable, 0)));
return (credit, Round(increment - credit));
}
private static double Round(double value)
{
return Math.Round(value, 2, MidpointRounding.AwayFromZero);
}
}
}
@@ -0,0 +1,163 @@
using YLErp.BLL;
using YLErp.DBModels;
using YLErp.Enums;
using YLErp.Modules.MarginModule;
using YLErp.Modules.TradeModule;
namespace YLErp.Modules.SwapModule.Margin
{
/// <summary>
/// R3 阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易日终结算产生"追加保证金"资金记录。
/// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 累计保证金(应付预付金+追加保证金 流水净额 + 追加授信占用);
/// 现金部分为逐结算日增量记录(BUG-03 修正:每结算日一条、Money=increment,键 TradeId+Action+Deal+HappenDate 幂等),
/// 需求上升只增不减;授信优先(阶段二规则):授信部分只写授信出入表(remark 前缀=追加保证金,position_id 空、冗余 trade_id)。
/// 由 EOD 在客户资金计算之前调用:当日新记录计入当日出入金窗口并翻"已结算",重跑时 目标/已补足 不变 → 新增为 0 不重复写。
/// 客户维度(MarginWatchRule=1/NULL)不产生资金记录(§0 占用口径),不在本服务范围。
/// </summary>
public class SwapAdditionalMarginService : YLBaseService
{
public SwapAdditionalMarginService(OptUserInfo userInfo) : base(userInfo)
{
}
public SwapAdditionalMarginService(YLBaseService baseService) : base(baseService)
{
}
/// <summary>
/// 结算日逐客户逐交易产生追加保证金(clientFilter 为部分结算的客户过滤,与 EOD 请求一致)。
/// </summary>
public void SettleAdditionalMargin(DateTime settleDate, List<int> clientFilter = null)
{
//合约维度盯市客户
var watchClientIds = DbContextFactory.GetClientDbContext(OptUser).client.AsNoTracking()
.Where(t => t.ProcessStatus != "未提交" && t.MarginWatchRule == 0)
.Select(t => t.id)
.ToList();
if (clientFilter != null && clientFilter.Any())
{
watchClientIds = watchClientIds.Where(t => clientFilter.Contains(t)).ToList();
}
if (watchClientIds.Count == 0)
{
return;
}
//存续中的互换交易(状态口径与 eodSwapQuery 一致,含当日已了结)
var tradeStatuses = ConsTrade.TradeStatusAfterConfirmed;
var trades = DbContext.trade.AsNoTracking()
.Where(t => tradeStatuses.Contains(t.TradeStatus)
&& t.ValidState != ConsGlobal.InValid
&& t.TradeType == "收益互换"
&& t.TradeDate <= settleDate
&& watchClientIds.Contains(t.ClientId))
.ToList();
if (trades.Count == 0)
{
return;
}
//规则15(区间追保结构)交易:R1 三层级解析(BUG-02 修正,与引擎/确认书同口径)——
//交易绑定→客户默认→全局默认 找到即停,只配客户/全局默认模板的交易同样纳入追保结算
var templatesByTrade = MarginTemplateV2RateHelper.ResolveTieredTemplates(trades, settleDate, DbContext);
trades = trades.Where(t => templatesByTrade.TryGetValue(t.id, out var tpl)
&& tpl.RuleType == (int)MarginRuleTypeEnum.).ToList();
var tradeIds = trades.Select(t => t.id).ToList();
if (tradeIds.Count == 0)
{
return;
}
//当日维持保证金(引擎产出,我方净收取为正),按交易合计(与 SwapSpanBalanceQueryService 缺口口径一致)
var maintenanceByTrade = DbContext.trade_span.AsNoTracking()
.Where(x => x.ValueDate == settleDate && tradeIds.Contains(x.TradeId) && x.Spv != null)
.GroupBy(x => x.TradeId)
.Select(g => new { TradeId = g.Key, Spv = g.Sum(x => x.Spv ?? 0d) })
.ToDictionary(x => x.TradeId, x => x.Spv);
//应付预付金净收额(客户付钱记负 → 取反为正;平仓返还自动冲减;口径与 EOD canonical 一致:非作废+已确认/已结算)
var payableNetByTrade = DbContext.ClientCashInCashOut.AsNoTracking()
.Where(x => x.TradeId != null && tradeIds.Contains(x.TradeId ?? 0)
&& x.Action == ClientCashInCashOut._应付预付金
&& x.HappenDate <= settleDate
&& x.ValidState != ConsGlobal.InValid
&& (x.State == ClientCashInCashOut. || x.State == ClientCashInCashOut.)
&& x.Money != null)
.GroupBy(x => x.TradeId)
.Select(g => new { TradeId = g.Key ?? 0, Sum = -g.Sum(x => x.Money ?? 0d) })
.ToDictionary(x => x.TradeId, x => x.Sum);
//追加保证金资金记录累计值(逐日增量记录求和即累计,BUG-03;Deal=0 交易级,口径与 EOD canonical 一致:非作废+已确认/已结算)
var addRecordByTrade = DbContext.ClientCashInCashOut.AsNoTracking()
.Where(x => x.TradeId != null && tradeIds.Contains(x.TradeId ?? 0)
&& x.Action == ClientCashInCashOut._追加保证金
&& x.ValidState != ConsGlobal.InValid
&& (x.State == ClientCashInCashOut. || x.State == ClientCashInCashOut.)
&& x.Deal == 0
&& x.Money != null)
.GroupBy(x => x.TradeId)
.Select(g => new { TradeId = g.Key ?? 0, Funded = -g.Sum(x => x.Money ?? 0d) })
.ToDictionary(x => x.TradeId, x => x.Funded);
//追加保证金授信占用累计(amount 占用记正 → 直接求和(BUG-01 修正口径);remark 前缀标识,见 ClientCreditInoutService
var addCreditByTrade = DbContext.client_credit_inout.AsNoTracking()
.Where(x => x.trade_id != null && tradeIds.Contains(x.trade_id ?? 0)
&& x.remark.StartsWith(ClientCreditInoutService.AdditionalMarginRemark))
.GroupBy(x => x.trade_id)
.Select(g => new { TradeId = g.Key ?? 0, Funded = g.Sum(x => x.amount) })
.ToDictionary(x => x.TradeId, x => x.Funded);
var fundTagService = new SwapFundTagService(this);
var cashService = new ClientCashInCashOutService(this);
var creditService = new ClientCreditInoutService(this);
//客户剩余可用授信逐笔扣减缓存(同一次结算内多笔追加按顺序消耗额度,与阶段二逐腿分配同语义)
var creditRemaining = new Dictionary<int, double>();
foreach (var clientGroup in trades.GroupBy(t => t.ClientId).OrderBy(g => g.Key))
{
foreach (var td in clientGroup.OrderBy(t => t.id))
{
if (!maintenanceByTrade.TryGetValue(td.id, out var maintenance) || maintenance <= 0)
{
continue;
}
var target = SwapAdditionalMarginCalc.CalcTarget(maintenance,
payableNetByTrade.TryGetValue(td.id, out var payableNet) ? payableNet : 0);
if (target <= 0)
{
continue;
}
var fundedCash = addRecordByTrade.TryGetValue(td.id, out var cash) ? cash : 0;
var fundedCredit = addCreditByTrade.TryGetValue(td.id, out var credit) ? credit : 0;
var increment = Math.Round(target - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero);
if (increment <= 0)
{
//已补足;追保回落(目标下降)不返还——负缺口在可用资金公式(Σ维持−累计)体现
continue;
}
if (!creditRemaining.TryGetValue(td.ClientId, out var remain))
{
remain = fundTagService.GetAvailableCredit(td.ClientId, settleDate);
creditRemaining[td.ClientId] = remain;
}
var (creditPart, cashPart) = SwapAdditionalMarginCalc.Allocate(increment, remain);
if (creditPart > 0)
{
//授信部分不产生资金流水,只写授信出入表占用(占用记正数,BUG-01 修正口径)
creditService.Occupy(td.ClientId, null, td.id, creditPart, settleDate,
ClientCreditInoutService.AdditionalMarginRemark + "占用");
creditRemaining[td.ClientId] = Math.Round(remain - creditPart, 2, MidpointRounding.AwayFromZero);
}
if (cashPart > 0)
{
//现金部分按结算日逐笔增量记录(BUG-03 修正:每结算日一条、Money=−increment、键含日期幂等),
//避免单条累计值覆盖 + HappenDate 前移使 EOD 差分窗口跨日全额重复计入;负数=客户应付追加
cashService.SaveSwapTradeClientCash(td, -cashPart, settleDate, 0,
ClientCashInCashOut._追加保证金, matchDate: true);
}
}
}
}
}
}
@@ -0,0 +1,58 @@
namespace YLErp.Modules.SwapModule.Margin
{
/// <summary>
/// R2 阶段三 §3.2 可用资金公式(需求拆分 R2 口径,实时/EOD/报告三处共用保证一致)。
/// 授信额度取 credit.Credit 合计(阶段一 §1.1 已在保存时折算 原始授信值×最大授信可用比例,消费方不再乘比例);
/// 现金结存 = 期末结存 AmountFund(阶段二起授信不进资金,流水天然不含授信部分,无需排除);
/// 已使用授信 = 授信出入表 Σ(amount);
/// 初始保证金(净收取为正)= 应付预付金流水收付净额(平仓返还自动冲减);
/// 维持保证金(净收取为正)= MySideMarginclient_span 维持保证金写入 trade_span 后经 CalcClientMargin 反号聚合)。
/// 公式整体待业务校验(EQD-6948),参数化集中在此便于校验后调整。
/// </summary>
public static class SwapSpanBalanceCalc
{
/// <summary>
/// 可用资金(客户维度,MarginWatchRule=1):
/// Max(现金结存 + 授信额度 − 已使用授信 + 初始保证金 − 维持保证金, 0)(现金结存已扣初始保证金,故加回)。
/// maintenanceMargin/initialMargin 均为净收取为正;未配置维度的存量客户不走本公式(调用方保证)。
/// </summary>
public static double CalcClientDimensionAvailable(double cashBalance, double totalCredit, double usedCredit,
double initialMargin, double maintenanceMargin)
{
return Math.Max(cashBalance + totalCredit - usedCredit + initialMargin - maintenanceMargin, 0);
}
/// <summary>
/// 可用资金(合约维度,MarginWatchRule=0):
/// Max(现金结存 + 授信额度 − 已使用授信 − 交易维度追加保证金合计, 0);
/// 交易维度追加保证金合计 = Σ(维持保证金 − 累计保证金)(阶段三:累计=应付预付金净额;阶段四含追加保证金流水)。
/// </summary>
public static double CalcContractDimensionAvailable(double cashBalance, double totalCredit, double usedCredit,
double tradeAdditionalMarginSum)
{
return Math.Max(cashBalance + totalCredit - usedCredit - tradeAdditionalMarginSum, 0);
}
/// <summary>
/// 追保金额(客户维度,MarginWatchRule=1,阶段四 §4.2 双向——不以 0 截断):
/// (维持保证金 − 初始保证金) − (现金结存 + 授信额度 − 已使用授信),正=需追保、负=可返还。
/// 即客户维度可用资金公式的反向值(去 Max 截断)——与需求原文"现金结存+授信额度−(维持−初始)"数值互为相反数,
/// 此处按 MarginByPayableMarginTotal 字段既有口径(正数=应追加,估值报告"应追加预付金X元")定向。
/// </summary>
public static double CalcClientDimensionCallMargin(double cashBalance, double totalCredit, double usedCredit,
double initialMargin, double maintenanceMargin)
{
return Math.Round(maintenanceMargin - initialMargin - (cashBalance + totalCredit - usedCredit), 2, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 追保金额(合约维度,MarginWatchRule=0,阶段四 §4.2):−(现金结存 + 授信额度 − 已使用授信)。
/// 需求原文公式为 现金结存+授信额度−已使用授信(与"交易维度追加保证金"的关系为开放问题2,两值均有产出),
/// 此处取负对齐字段"正数=应追加"口径:账户透支(现金+授信不足)为正=应补足,盈余为负。
/// </summary>
public static double CalcContractDimensionCallMargin(double cashBalance, double totalCredit, double usedCredit)
{
return Math.Round(-(cashBalance + totalCredit - usedCredit), 2, MidpointRounding.AwayFromZero);
}
}
}
@@ -0,0 +1,115 @@
using YLErp.BLL;
using YLErp.DBModels;
using YLErp.Enums;
using YLErp.Modules.MarginModule;
namespace YLErp.Modules.SwapModule.Margin
{
/// <summary>
/// R2 阶段三 §3.2 估值报告/可用资金查询输入(静态查询,供 ClientBalanceUtility 与 RealTimeClientBanlanceService 共用,保证三处口径一致)。
/// 口径:
/// - 互换初始保证金(净收取为正)= 客户 应付预付金 流水收付净额取反(客户应付入金记负、平仓返还为正,取负号后净收取为正);
/// - 交易维度追加保证金(合约维度)= Σ(维持保证金 − 累计保证金):
/// 维持保证金取当日 trade_span.Spv(区间追保结构引擎产出,我方净收取为正);
/// 累计保证金 = 该交易 应付预付金+追加保证金 流水收付净额 + 追加保证金授信占用净额(§0 口径,阶段四 §4.1 起);
/// 仅统计规则15(区间追保结构,R1 三层级解析,与引擎同口径)且有当日 trade_span 的交易。
/// </summary>
public static class SwapSpanBalanceQueryService
{
/// <summary>
/// 客户维度输入:互换初始保证金(净收取为正,按客户汇总)。
/// </summary>
public static Dictionary<int, double> GetSwapInitMarginByClients(List<int> clientIds, DateTime valueDate, YLContext db)
{
if (clientIds == null || clientIds.Count == 0)
{
return new Dictionary<int, double>();
}
var flows = db.ClientCashInCashOut.AsNoTracking()
.Where(x => clientIds.Contains(x.ClientId ?? 0)
&& x.Action == ClientCashInCashOut._应付预付金
&& x.HappenDate <= valueDate
&& x.ValidState != ConsGlobal.InValid
&& (x.State == ClientCashInCashOut. || x.State == ClientCashInCashOut.)
&& x.Money != null)
.Select(x => new { ClientId = x.ClientId ?? 0, Money = x.Money ?? 0d })
.ToList();
return flows.GroupBy(x => x.ClientId)
.ToDictionary(g => g.Key, g => -g.Sum(x => x.Money));
}
/// <summary>
/// 合约维度输入:交易维度追加保证金合计 = Σ(维持保证金 − 累计保证金),按客户汇总。
/// </summary>
public static Dictionary<int, double> GetTradeAdditionalMarginByClients(List<int> clientIds, DateTime valueDate, YLContext db)
{
var result = new Dictionary<int, double>();
if (clientIds == null || clientIds.Count == 0)
{
return result;
}
//当日维持保证金(引擎产出:我方净收取为正)——先取当日有 span 的客户交易,再按规则15过滤
var maintenance = db.trade_span.AsNoTracking()
.Where(x => x.ValueDate == valueDate && x.Spv != null
&& x.ClientId != null && clientIds.Contains(x.ClientId.Value))
.Select(x => new { x.TradeId, x.ClientId, Spv = x.Spv ?? 0d })
.ToList();
if (maintenance.Count == 0)
{
return result;
}
var spanTradeIds = maintenance.Select(x => x.TradeId).Distinct().ToList();
//规则15(区间追保结构)交易:R1 三层级解析(BUG-02 修正,与引擎/结算判定同口径)——
//交易绑定→客户默认→全局默认 找到即停;无预付金等其他规则产出/留存的 span 行不计入追保缺口
var spanTrades = db.trade.AsNoTracking().Where(t => spanTradeIds.Contains(t.id)).ToList();
var templatesByTrade = MarginTemplateV2RateHelper.ResolveTieredTemplates(spanTrades, valueDate, db);
var rule15TradeIds = spanTrades
.Where(t => templatesByTrade.TryGetValue(t.id, out var tpl)
&& tpl.RuleType == (int)MarginRuleTypeEnum.)
.Select(t => t.id)
.ToHashSet();
maintenance = maintenance.Where(x => rule15TradeIds.Contains(x.TradeId)).ToList();
if (maintenance.Count == 0)
{
return result;
}
//累计保证金:该交易 应付预付金+追加保证金 流水收付净额取反(收取为正);
//追加保证金的授信占用部分不产生资金流水(阶段二口径),阶段四 §4.1 起一并计入——
//否则结算后缺口残留(=授信部分),与可用资金公式里的 −已使用授信 形成双扣
var marginActions = new List<string> { ClientCashInCashOut._应付预付金, ClientCashInCashOut._追加保证金 };
var accumulated = db.ClientCashInCashOut.AsNoTracking()
.Where(x => x.TradeId != null && spanTradeIds.Contains(x.TradeId ?? 0)
&& marginActions.Contains(x.Action)
&& x.HappenDate <= valueDate
&& x.ValidState != ConsGlobal.InValid
&& (x.State == ClientCashInCashOut. || x.State == ClientCashInCashOut.)
&& x.Money != null)
.GroupBy(x => x.TradeId)
.Select(g => new { TradeId = g.Key ?? 0, Sum = -g.Sum(x => x.Money ?? 0d) })
.ToDictionary(x => x.TradeId, x => x.Sum);
//追加保证金的授信占用净额(amount 占用记正 → 直接求和(BUG-01 修正口径);remark 前缀标识)
var addCreditOccupied = db.client_credit_inout.AsNoTracking()
.Where(x => x.trade_id != null && spanTradeIds.Contains(x.trade_id ?? 0)
&& x.remark.StartsWith(ClientCreditInoutService.AdditionalMarginRemark))
.GroupBy(x => x.trade_id)
.Select(g => new { TradeId = g.Key ?? 0, Sum = g.Sum(x => x.amount) })
.ToDictionary(x => x.TradeId, x => x.Sum);
foreach (var group in maintenance.GroupBy(x => x.ClientId ?? 0))
{
var total = group.Sum(x => x.Spv
- (accumulated.TryGetValue(x.TradeId, out var acc) ? acc : 0d)
- (addCreditOccupied.TryGetValue(x.TradeId, out var occupied) ? occupied : 0d));
result[group.Key] = total;
}
return result;
}
}
}
@@ -0,0 +1,159 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule.Margin
{
/// <summary>
/// R2 区间追保结构(规则15)维持保证金纯函数计算(实现方案阶段三 §3.1)。
/// 数据来源约定(与阶段一 SpanConfig 结构、docx 确认书模板一致):
/// - 多头(客户看多):第1层 [Lower, +∞)(上不封顶),第n层 [Lower, Upper);各层 Lower 逐层严格递减;
/// - 空头(客户看空):第1层 (−∞, Upper](下不设限),第n层 (Lower, Upper];各层 Upper 逐层严格递增;
/// - 区间边界为"×期初净价的百分比"小数(0.95=95%),价格口径:债券用净价,指数/ETF 用收盘价;
/// - AmountRate 为累计到该层的追保金额比例(与确认书追保表每行"合计追保金额"同口径,直取不求和),
/// 追保金额 = AmountRate × 期初全价 × 券面总额(债券)/ × 期初价格 × 名义份额(指数/ETF);
/// - 维持保证金 = (初始保证金 + 总追加保证金) × 方向(我方净收取 +1 / 净支付 −1);
/// - 价格跌破最深一层(平仓线外)按最深层计(追保金额不再上升)。
/// 公式参数化(EQD-6948 待业务校验),所有口径集中在本类便于校验后调整。
/// </summary>
public static class SwapSpanMarginCalc
{
/// <summary>
/// 判断 SpanConfig 是否已按方案B录入新区间结构(任一方向有边界/比例或预警/平仓线)。
/// 全空视为存量 x/y 配置,引擎回落旧 名义×y 公式。
/// </summary>
public static bool HasSpanConfig(SpanConfig cfg)
{
if (cfg == null)
{
return false;
}
return cfg.WarnLine.HasValue || cfg.CloseLine.HasValue
|| HasTierValue(cfg.LongSpans) || HasTierValue(cfg.ShortSpans);
}
private static bool HasTierValue(List<SpanTierConfig> tiers)
{
return tiers != null && tiers.Any(t => t != null && (t.Lower.HasValue || t.Upper.HasValue || t.AmountRate.HasValue));
}
/// <summary>
/// 客户方向判定(与确认书 IsCustomerLong 同规则):
/// 我方方向 = 收取端(PosiDirection=收取)与 PositionType 同向、支付端反向;客户方向取反。
/// </summary>
public static bool IsCustomerLong(int posiDirection, int positionType)
{
var isOurLong = posiDirection == (int)SwapDirectionEnum.
? positionType == (int)PositionTypeFlag.Short
: positionType == (int)PositionTypeFlag.Long;
return !isOurLong;
}
/// <summary>
/// 按当前价格相对期初价的比例落档。返回命中的层;价格在所有层区间之外(平仓线外)返回最深一层,无可用层返回 null。
/// 多头:priceRatio ∈ [Lower, Upper)(第1层无上界);空头:priceRatio ∈ (Lower, Upper](第1层无下界)。
/// </summary>
public static SpanTierConfig MatchTier(List<SpanTierConfig> tiers, bool isCustomerLong, double priceRatio)
{
if (tiers == null)
{
return null;
}
var valid = tiers.Where(t => t != null && (t.Lower.HasValue || t.Upper.HasValue)).ToList();
if (valid.Count == 0)
{
return null;
}
foreach (var tier in valid)
{
var inLower = !tier.Lower.HasValue || (isCustomerLong ? priceRatio >= tier.Lower.Value : priceRatio > tier.Lower.Value);
var inUpper = !tier.Upper.HasValue || (isCustomerLong ? priceRatio < tier.Upper.Value : priceRatio <= tier.Upper.Value);
if (inLower && inUpper)
{
return tier;
}
}
//未落任何层:价格已穿出最深一层边界(低于多头最深层下界/高于空头最深层上界),按最深层计;
//最深层按边界值取(多头=最小下界、空头=最大上界),不依赖配置数组顺序(BUG-25 引擎侧防御)
return isCustomerLong
? valid.OrderBy(t => t.Lower ?? double.MinValue).First()
: valid.OrderByDescending(t => t.Upper ?? double.MaxValue).First();
}
/// <summary>
/// 总追加保证金 = 命中层 AmountRate × 期初全价 × 券面总额(债券)/ × 期初价格 × 名义份额(指数/ETF)。
/// 数据上两者同为 期初价(PosiGrossPrice) × 数量(PosiQuantity),口径差异由调用方注释说明。
/// </summary>
public static double CalcAdditionalMargin(SpanTierConfig tier, double initPrice, double quantity)
{
if (tier == null || !tier.AmountRate.HasValue)
{
return 0;
}
return tier.AmountRate.Value * initPrice * quantity;
}
/// <summary>
/// 维持保证金 = (初始保证金 + 总追加保证金) × 方向(我方净收取 +1 / 净支付 −1)。
/// </summary>
public static double CalcMaintenanceMargin(double initialMargin, double additionalMargin, double direction)
{
return (initialMargin + additionalMargin) * direction;
}
/// <summary>
/// 阶段三 §3.1 单笔交易引擎计算(纯函数,收盘价由调用方解析后传入——债券取中债估值净价、指数/ETF取收盘价)。
/// closePrice&lt;=0 视为未取到收盘价:追加保证金按 0、维持保证金=初始保证金(不抛错,由调用方记日志)。
/// isInitialCalc=true(试算初始):只出初始项,追加保证金为收盘后口径不参与。
/// 返回 null 表示缺有效标的腿(期初价),调用方跳过该交易不产出 trade_span。
/// </summary>
public static double? CalcTradeMaintenanceMargin(double? tradeInitialMargin, SpanConfig spanCfg,
List<swap_position> legs, bool isInitialCalc, double closePrice)
{
//标的腿(多空):期初价格、数量、客户方向
var underlyingLeg = legs?.FirstOrDefault(x => x.PositionType == (int)PositionTypeFlag.Long || x.PositionType == (int)PositionTypeFlag.Short);
if (underlyingLeg == null || underlyingLeg.PosiGrossPrice <= 0)
{
return null;
}
var isCustomerLong = IsCustomerLong(underlyingLeg.PosiDirection, underlyingLeg.PositionType);
//初始保证金与方向:初始预付金腿(InterestMode=5)收付净额(多腿按净收取定方向);无腿时回落交易录入值(客户应付常态)
var initMarginLegs = legs.Where(x => x.InterestMode == (int)InterestModeEnum.).ToList();
double initialMargin;
double direction;
if (initMarginLegs.Any())
{
var netReceive = initMarginLegs.Sum(x => x.InterestDirection == (int)SwapDirectionEnum. ? x.InterestPrincipalFix : -x.InterestPrincipalFix);
initialMargin = Math.Abs((double)netReceive);
direction = netReceive >= 0 ? 1 : -1;
}
else
{
initialMargin = tradeInitialMargin ?? 0;
direction = 1;
}
if (isInitialCalc)
{
return CalcMaintenanceMargin(initialMargin, 0, direction);
}
var additional = 0.0;
//期初价比基:债券/指数/ETF统一为期初净价(PosiNetNoFeePrice,确认书"参考标的期初净价"同源),缺省回落期初全价
var initNetPrice = (double)(underlyingLeg.PosiNetNoFeePrice ?? 0m);
if (initNetPrice <= 0)
{
initNetPrice = (double)underlyingLeg.PosiGrossPrice;
}
if (closePrice > 0 && initNetPrice > 0)
{
var tiers = isCustomerLong ? spanCfg.LongSpans : spanCfg.ShortSpans;
var tier = MatchTier(tiers, isCustomerLong, closePrice / initNetPrice);
//追保金额基数:期初全价×券面总额(债券)/ 期初价格×名义份额(指数/ETF),同为 期初价×数量
additional = CalcAdditionalMargin(tier, (double)underlyingLeg.PosiGrossPrice, (double)underlyingLeg.PosiQuantity);
}
return CalcMaintenanceMargin(initialMargin, additional, direction);
}
}
}
@@ -43,7 +43,7 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// 剩余可用授信 = 有效授信 − 已使用授信。
/// 已使用授信 = 授信出入表 Σ(amount)(入金负/出金正)——入金使可用授信上升、出金使其收缩(业务口径)
/// 已使用授信 = 授信出入表 Σ(amount)(占用正/释放负,BUG-01 修正口径)——占用使可用授信收缩、释放使其回升
/// </summary>
public double GetAvailableCredit(int clientId, DateTime valueDate)
{
@@ -54,16 +54,16 @@ namespace YLErp.Modules.SwapModule
/// 簿记确认时对预付金腿定稿资金标签并产生资金记录(§2.3 四种情形,逐腿)。
/// fund_tag 单列:录入时存用户选择(Credit/Cash/NULL),本方法读取选择后在同列定稿——
/// 特批全现金;选授信按剩余额度分配(跨界腿拆单为 授信+现金 两条),未选/现金直接现金。
/// 授信腿只写授信出入表占用(入金方向记负数,绑定腿 position_id,冗余 trade_id),不产生资金流水;
/// 授信腿只写授信出入表占用(占用记正数,绑定腿 position_id,冗余 trade_id),不产生资金流水;
/// 现金腿走 SaveSwapTradeClientCash 幂等 upsert 产生 应付预付金 记录。
/// marginLegs 需为已过滤(IsDeductPrincipal 等)的预付金腿(InterestMode=5/6)。
/// </summary>
public void ApplyMarginFundTags(trade td, List<swap_position> marginLegs, ClientCashInCashOutService cashService, bool ignoreMoneyCheck)
{
var creditService = new ClientCreditInoutService(this);
//重确认/重补场景自愈:清掉本交易旧占用记录后按腿上当前选择与最新额度重写
//(阶段四追加保证金占用落地后需区分保留追加部分)
creditService.RemoveByTrade(td.id);
//重确认/重补场景自愈:清掉本交易旧占用记录后按腿上当前选择与最新额度重写
//追加保证金占用(阶段四 EOD 写入)不随重写清除——其资金记录不在本方法删除范围,由 EOD 幂等维护
creditService.RemoveByTrade(td.id, keepAdditionalMargin: true);
var valueDate = td.TradeDate ?? DateTime.Now;
var creditAvailable = GetAvailableCredit(td.ClientId, valueDate);
@@ -111,8 +111,8 @@ namespace YLErp.Modules.SwapModule
if (plan != null && plan.CreditAmount > 0)
{
//整腿授信 或 拆单后的授信部分:不产生资金流水,只写占用(拆单绑新拆出的授信腿)。
//入金方向记负数(业务口径:出入表金额与资金流水同号入金负/出金正;入金使可用授信上升
creditService.Occupy(td.ClientId, plan.CreditLeg?.id ?? leg.id, td.id, -plan.CreditAmount, happenDate,
//占用记正数(BUG-01 修正:已使用授信=Σ(amount) 占用上升;2026-08-20"与资金流水同号入金负"口径已废弃
creditService.Occupy(td.ClientId, plan.CreditLeg?.id ?? leg.id, td.id, plan.CreditAmount, happenDate,
plan.NeedSplit ? "簿记拆单授信部分" : "簿记授信占用");
}
//资金记录沿用既有符号口径(客户付钱为负 = -应付额):授信部分不产生流水,现金部分按差额产生
@@ -136,13 +136,15 @@ namespace YLErp.Modules.SwapModule
var leg = plan.Leg;
//应付额 = fix × (dir==1 ? 1 : -1),反推 fix 用同一比例(±1 自反)
var payableRatio = leg.InterestDirection == 1 ? 1 : -1;
var originalFix = leg.InterestPrincipalFix;
leg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CashAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
leg.FundTag = ConsFundTag.Cash;
var creditLeg = leg.Clone();
creditLeg.id = 0;
creditLeg.PositionId = 0;
creditLeg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CreditAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
//授信腿倒挤 = 原 fix − 现金 fix(BUG-20:两腿分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒)
creditLeg.InterestPrincipalFix = originalFix - leg.InterestPrincipalFix;
creditLeg.FundTag = ConsFundTag.Credit;
creditLeg.OptId = UserId;
creditLeg.OptName = UserName;
@@ -1514,8 +1514,9 @@ namespace YLErp.Modules.SwapModule
var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId);
var eodSwaps = DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId);
var clientcashinouts = DbContext.ClientCashInCashOut.Where(x => x.TradeId == tradeId && x.Action == ClientCashInCashOut._应付预付金);
// R4:修改清除资金记录时同步清理授信出入记录,重确认时按最新标签重写
new ClientCreditInoutService(this).RemoveByTrade(tradeId);
// R4:修改清除 应付预付金 资金记录时同步清理对应授信出入记录,重确认时按最新标签重写
// 追加保证金资金记录(阶段四 EOD 产生)不在本方法删除范围,其授信占用一并保留
new ClientCreditInoutService(this).RemoveByTrade(tradeId, keepAdditionalMargin: true);
swapFlowEvents.ForEach(x =>
{
x.DataState = (int)SwapFlowDateStateEnum.;
@@ -227,10 +227,12 @@ namespace YLErp.Modules.TradeModule
/// <summary>
/// 新版互换 期权费
/// matchDate=true 时 upsert 键追加 HappenDate(按结算日幂等):用于追加保证金等逐日增量记录,
/// 每个结算日一条独立记录,避免单条累计值覆盖 + HappenDate 前移导致跨日差分窗口全额重复计入(BUG-03)。
/// </summary>
/// <param name="swapTrade"></param>
/// <exception cref="ArgumentNullException"></exception>
public void SaveSwapTradeClientCash(trade swapTrade,double money, DateTime HappenDate,long positionId,string action= ClientCashInCashOut._期权费)
public void SaveSwapTradeClientCash(trade swapTrade,double money, DateTime HappenDate,long positionId,string action= ClientCashInCashOut._期权费, bool matchDate = false)
{
if (swapTrade is null)
{
@@ -242,7 +244,8 @@ namespace YLErp.Modules.TradeModule
}
var valueDate = valuedateBLL.ValueDate;
var client = DataCacheProvider.GetClientDataSource().GetData(swapTrade.ClientId);
var cic = DbContext.ClientCashInCashOut.FirstOrDefault(t => t.TradeId == swapTrade.id && t.ValidState != ConsGlobal.InValid && t.Action == action &&t.Deal==positionId);
var cic = DbContext.ClientCashInCashOut.FirstOrDefault(t => t.TradeId == swapTrade.id && t.ValidState != ConsGlobal.InValid && t.Action == action &&t.Deal==positionId
&& (!matchDate || t.HappenDate == HappenDate));
bool addClientCash = false;
if (cic == null)
{
+8 -5
View File
@@ -15,8 +15,10 @@
{Name:"互换开平仓事件流水",Rights:["交易管理-互换开平仓事件流水"],Url:"swapTrade2/EventList"},
{Name:"交易审批",Rights:["交易管理-交易审批"],Url:"trade/TradeApproval"},
{Name:"凭证审批",Rights:["交易管理-凭证审批"],Url:"v3/product/approve"},
{Name:"标的交易",Rights:["交易管理-标的交易"],Url:"exchangetrade/tradelist"},
{Name:"变更记录",Rights:["交易管理-变更记录"],Url:"trade/TradeChange"},
{Name:"标的交易",Rights:["交易管理-标的交易"],Url:"exchangetrade/tradelist"},
{Name:"变更记录",Rights:["交易管理-变更记录"],Url:"trade/TradeChange"},
//R1 三层级(交易绑定→客户默认→全局默认,2026-08-24):交易维度逐笔查看/调整模板绑定的入口
{Name:"交易预付金配置",Rights:["交易管理-交易预付金配置"],Url:"trade/tradeMarginTemplateList"},
]
},
{Name:"风险管理",Rights:["风险控制"],Icon:"menu-icon iconseven"
@@ -66,9 +68,10 @@
{Name:"黑名单审批",Rights:["客户管理-黑名单审批"],Url:"clientblack/clientblackApproval"},
{Name:"授信管理",Rights:["客户管理-授信管理"],Url:"credit/creditList"},
{Name:"资信评级",Rights:["客户管理-资信评级"],Url:"client_rating/List"},
{Name:"机构账号设置",Rights:["客户管理-机构账号设置"],Url:"v3/client/account"}
//保证金模板在交易页直接选择,客户预付金配置入口不展示(页面代码保留)
//{Name:"客户预付金配置",Rights:["客户管理-客户预付金配置"],Url:"client_margin_template/client_margin_templateList"},
{Name:"机构账号设置",Rights:["客户管理-机构账号设置"],Url:"v3/client/account"},
//R1 三层级(2026-08-24):客户默认模板绑定入口恢复——二级(client_margin_template)生效的必需配置页;
//原下线理由"保证金模板在交易页直接选择"只覆盖交易级,客户/全局兜底落地后不再成立
{Name:"客户预付金配置",Rights:["客户管理-客户预付金配置"],Url:"client_margin_template/client_margin_templateList"},
]
},
{Name:"互换簿记预设",Rights:["互换簿记预设"],Icon:"menu-icon iconfour"
+1 -1
View File
@@ -80,7 +80,7 @@ namespace YLErp.Web.Controllers
{
c.VarietyName = GetWhiteListVarietyNames(c);
}
// R4:已使用授信(授信出入表 SUM(占用)SUM(释放)+SUM(调整),按客户汇总)
// R4:已使用授信(授信出入表 Σ(amount),占用记正/释放记负,按客户汇总)
var usedCredits = Modules.SwapModule.ClientCreditInoutService.GetUsedCreditByClients(
sList.rows.Where(x => x.ClientId > 0).Select(x => x.ClientId ?? 0).Distinct().ToList(), yldb);
foreach (var c in sList.rows)
@@ -317,6 +317,19 @@ namespace YLErp.Web.Controllers
}
}
//BUG-13:期限档值须为合法四档(页面为固定区块正常到不了这里,接口直传非法档值会成取数侧永远匹配不到的死配置)
if (marginTemplate.RuleType == (int)MarginRuleTypeEnum.)
{
foreach (var detail in marginTemplate.Details)
{
var bondTerm = detail.SpanConfig?.BondTerm;
if (!string.IsNullOrEmpty(bondTerm) && !ConsMarginTerm.IsValidTerm(bondTerm))
{
throw new Exception("非法的利率债期限档:" + bondTerm + "(允许值:" + string.Join(" / ", ConsMarginTerm.AllowedTerms) + "");
}
}
}
//区间追保结构:客户原型全量手工录入的预警线/平仓线与多空各4层追保区间校验。
//预警线/平仓线/两层区间全部为空视为未配置(兼容存量 x/y 过渡),任一值存在即按规则校验:
//数值非负;同层上下边界都有时上边界>下边界;单层部分填写报错提示完整填写
@@ -359,13 +372,29 @@ namespace YLErp.Web.Controllers
{
if (marginTemplate.RuleType == (int)MarginRuleTypeEnum.)
{
//区间追保结构:按 (利率债期限档, ETF细分) 元组分组校验(EtfKind 为预留扩展键,本期无 UI 写入,等价于仅期限档分组),
//同一分组内标的类型不允许重复,不同分组允许相同标的类型
//区间追保结构:按 (利率债期限档, ETF细分) 元组分组校验(EtfKind 为预留扩展键,本期无 UI 写入,等价于仅期限档分组),
//同一分组内标的类型不允许重复,不同分组允许相同标的类型
//通配行(空/全部,UnderlyingType=None/All)两两之间位与恒为 0 检不出,须单独拦截(BUG-09)——
//否则取数侧通配兜底 matched.First() 命中不确定
foreach (var termGroup in marginTemplate.Details.GroupBy(x => (x.SpanConfig?.BondTerm ?? "", x.SpanConfig?.EtfKind ?? "")))
{
var termUnderlyingType = UnderlyingTypeEnum.None;
var wildcardSeen = false;
foreach (var detail in termGroup)
{
var isWildcard = detail.UnderlyingType == UnderlyingTypeEnum.None || detail.UnderlyingType == UnderlyingTypeEnum.All;
if (isWildcard && wildcardSeen)
{
if (marginTemplate.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.None)
{
throw new Exception("同一利率债期限档下存在重复的参数组");
}
throw new Exception("同一分组下存在重复的兜底参数组(标的类型为空或全部)");
}
if (isWildcard)
{
wildcardSeen = true;
}
if ((detail.UnderlyingType & termUnderlyingType) > 0)
{
if (marginTemplate.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.None)
@@ -444,6 +473,15 @@ namespace YLErp.Web.Controllers
yldb.SaveChanges();
//BUG-14:明细生效日期回写——前端新增行只带占位日期(2020-01-01 前后),此前不回写使新行(含模板拷贝)
//落库 0001-01-01,被取数侧 Max(ValueDate) 分组静默丢弃;占位日期对齐本次提交行的最新生效日期,无有效行时用模板生效日期
var placeholderDate = new DateTime(2020, 1, 1);
var effectiveValueDate = marginTemplate.Details
.Where(d => d.ValueDate > placeholderDate)
.Select(d => d.ValueDate)
.DefaultIfEmpty(marginTemplate.ValueDate > placeholderDate ? marginTemplate.ValueDate : placeholderDate)
.Max();
marginTemplate.Details.ForEach(x =>
{
var detail = yldb.margin_template_detail.Find(x.id);
@@ -453,6 +491,7 @@ namespace YLErp.Web.Controllers
detail = new margin_template_detail();
yldb.margin_template_detail.Add(detail);
}
detail.ValueDate = x.ValueDate > placeholderDate ? x.ValueDate : effectiveValueDate;
detail.MarginTemplateId = marginTemplate.id;
detail.GroupRatio = x.GroupRatio;
detail.MarginRatio1 = x.MarginRatio1;
+22 -7
View File
@@ -7972,7 +7972,9 @@ namespace YLErp.Web.Controllers
public ActionResult tradeMarginTemplateEdit(int tradeId)
{
var trade = yldb.trade.Find(tradeId);
var marginTemplates = yldb.margin_template_v2.Where(x => x.TradeTypes.Contains(trade.TradeType) && !x.IsDefault).ToList();
//R1 三层级(2026-08-24 配合修改):候选只列有效模板——已作废模板一旦绑定,
//解析一级"找到即停"会让该交易直接视为无预付金(仅留日志),必须从源头挡住
var marginTemplates = yldb.margin_template_v2.Where(x => x.IsValid && x.TradeTypes.Contains(trade.TradeType) && !x.IsDefault).ToList();
marginTemplates.ForEach(x => x.Details = yldb.margin_template_detail.Where(y => y.MarginTemplateId == x.id).ToList());
var latestTradeMarginTemplate = yldb.trade_margin_template.Where(x => x.TradeId == tradeId).OrderByDescending(x => x.ValueDate).FirstOrDefault();
@@ -8005,6 +8007,9 @@ namespace YLErp.Web.Controllers
}
//BUG-05 修正:接口补权限注解(与同组 tradeMarginTemplateList/Edit 一致),任何登录用户不可直改交易模板绑定
[MyAuthorize("交易管理-交易预付金配置")]
[HttpPost]
public JsonResult saveTradeMarginTemplate(trade_margin_template tradeMarginTemplate)
{
try
@@ -8030,14 +8035,20 @@ namespace YLErp.Web.Controllers
#region
var trade = yldb.trade.Find(tradeMarginTemplate.TradeId);
trade.InitialMargin = MarginDefault.GetInitialMarginWithPreHandle(trade);
yldb.SaveChanges();
if (trade.IsGroup == 2)
//R1 三层级(2026-08-24 配合修改):收益互换跳过系统重算——互换初始预付金为手工录入/预付金腿承载
//(方案B 引擎回落链消费 trade.InitialMargin),且试算链路(GetInitialMarginWithPreHandle 克隆 id=0
//对互换取不到模板绑定恒得 0,无条件重置会把手工录入值清零;与 TradeSaveService 保存守卫语义对齐
if (trade.TradeType != "收益互换")
{
var parentTrade = yldb.trade.Find(trade.ParentTradeId);
parentTrade.InitialMargin = yldb.trade.Where(x => x.ValidState != "InValid" && x.ParentTradeId == trade.ParentTradeId).Sum(y => y.InitialMargin);
trade.InitialMargin = MarginDefault.GetInitialMarginWithPreHandle(trade);
yldb.SaveChanges();
if (trade.IsGroup == 2)
{
var parentTrade = yldb.trade.Find(trade.ParentTradeId);
parentTrade.InitialMargin = yldb.trade.Where(x => x.ValidState != "InValid" && x.ParentTradeId == trade.ParentTradeId).Sum(y => y.InitialMargin);
yldb.SaveChanges();
}
}
#endregion
@@ -8049,6 +8060,8 @@ namespace YLErp.Web.Controllers
return JsonError(ex.Message);
}
}
//BUG-05 修正(同组接口一并收紧):批量重设初始预付金同样要求预付金配置权限
[MyAuthorize("交易管理-交易预付金配置")]
[HttpPost]
public JsonResult batchSetInitialMargin(TradeMarginTemplateReq req)
{
@@ -8087,6 +8100,8 @@ namespace YLErp.Web.Controllers
/// <summary>
/// 获取交易预付金规则列表
/// </summary>
//BUG-05 修正:接口补权限注解(与同组 tradeMarginTemplateList/Edit 一致)
[MyAuthorize("交易管理-交易预付金配置")]
[HttpPost]
public JsonResult tradeMarginTemplateQuery(TradeMarginTemplateReq req)
{
@@ -86,7 +86,7 @@
<td>期初结存</td>
<td id="LastDayRemainFund"></td>
<td>初始保证金金额</td>
<td id="MySideMargin"></td>
<td id="SwapInitMargin"></td>
<td>已实现盈亏</td>
<td id="WinLoss"></td>
</tr>
@@ -103,16 +103,16 @@
<td id="NetFundAll"></td>
<td>盯市金额</td>
<td id="SwapMarketAmount"></td>
<td></td>
<td></td>
<td>授信额度</td>
<td id="TotalCredit"></td>
</tr>
<tr>
<td>追保账户</td>
<td id="VmFundSum"></td>
<td>可用资金</td>
<td id="AvailableFund"></td>
<td></td>
<td></td>
<td>已使用授信</td>
<td id="UsedCredit"></td>
</tr>
<tr>
<td>其他收支</td>
@@ -322,7 +322,7 @@
else if (cellValue == "出金" || cellValue == "其他支出") {
return "支出";
}
else if (rowObject.Action == "系统操作-期权费" || rowObject.Action == "系统操作-平仓费" || rowObject.Action == "系统操作-行权费" || rowObject.Action == "系统操作-票息" || rowObject.Action == "系统操作-互换" || rowObject.Action == "系统操作-应付预付金" || rowObject.Action == "系统操作-预付金返息") {
else if (rowObject.Action == "系统操作-期权费" || rowObject.Action == "系统操作-平仓费" || rowObject.Action == "系统操作-行权费" || rowObject.Action == "系统操作-票息" || rowObject.Action == "系统操作-互换" || rowObject.Action == "系统操作-应付预付金" || rowObject.Action == "系统操作-预付金返息" || rowObject.Action == "系统操作-追加保证金") {
if (rowObject.Money < 0) {
return "支出";
}
@@ -384,6 +384,10 @@
else if (rowObject.Action == "系统操作-预付金返息") {
action = "预付金返息";
}
else if (rowObject.Action == "系统操作-追加保证金") {
//阶段四 §4.1 合约维度 EOD 结算产生的追保入金
action = "追加保证金";
}
if (action) {
return `${action}${parseFloat(rowObject.Money) > 0 ? "收入" : "支出"}`;
}
@@ -253,7 +253,7 @@
{{spanText(sec.detail).priceCurShort1}} ≤ <vue-number-input v-model="tier.Upper" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}}
</template>
<template v-else>
<vue-number-input v-model="tier.Lower" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}} &lt; {{spanText(sec.detail).priceCur}} ≤ <vue-number-input v-model="tier.Upper" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}}
<vue-number-input v-model="tier.Lower" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}} &lt; {{spanText(sec.detail).priceCurShort}} ≤ <vue-number-input v-model="tier.Upper" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}}
</template>
</td>
<td>
@@ -254,7 +254,7 @@
{{spanText(sec.detail).priceCurShort1}} ≤ <vue-number-input v-model="tier.Upper" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}}
</template>
<template v-else>
<vue-number-input v-model="tier.Lower" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}} &lt; {{spanText(sec.detail).priceCur}} ≤ <vue-number-input v-model="tier.Upper" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}}
<vue-number-input v-model="tier.Lower" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}} &lt; {{spanText(sec.detail).priceCurShort}} ≤ <vue-number-input v-model="tier.Upper" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}}
</template>
</td>
<td>
@@ -230,7 +230,7 @@
{{spanText(sec.detail).priceCurShort1}} ≤ <vue-number-input v-model="tier.Upper" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}}
</template>
<template v-else>
<vue-number-input v-model="tier.Lower" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}} &lt; {{spanText(sec.detail).priceCur}} ≤ <vue-number-input v-model="tier.Upper" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}}
<vue-number-input v-model="tier.Lower" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}} &lt; {{spanText(sec.detail).priceCurShort}} ≤ <vue-number-input v-model="tier.Upper" v-bind:format="inputFormatPercent"></vue-number-input> × {{spanText(sec.detail).priceInit}}
</template>
</td>
<td>
@@ -290,11 +290,13 @@ function SearchClientBalance() {
main.post("/trade_span/GetClientLatestBalance", { clientId: $("#ClientId").val(), ValueDateFrom: param.ValueDateStart, ValueDateTo: param.ValueDateEnd, IsClientBalanceGap: param.IsClientBalanceGap, IsGetOuterMarginGap: param.IsGetOuterMarginGap, ParentFlag: param.ParentFlag }).done(function (data) {
$("#LastDayRemainFund").text(numFormart(data.LastDayRemainFund));
$("#MySideMargin").text(numFormart(data.MySideMargin));
//R2 阶段三 §3.2 口径(BUG-07 修正,与邮件/Excel 报告 SettlementReportService 同源):
//初始保证金=SwapInitMargin(应付预付金净额)、维持保证金=MySideMarginclient_span 维持保证金反号聚合)
$("#SwapInitMargin").text(numFormart(data.SwapInitMargin));
$("#WinLoss").text(numFormart(data.WinLoss));
$("#CashInCashOutChange").text(numFormart(data.NetFundAll));
$("#PositionPnl").text(numFormart(isPvRounded ? data.RoundedPositionPnl : data.PositionPnl));
$("#MaintenanceMargin").text(numFormart(data.MaintenanceMargin));
$("#MaintenanceMargin").text(numFormart(-data.MySideMargin));
$("#NetFundAll").text(numFormart(data.NetFund));
$("#SwapMarketAmount").text(numFormart(data.SwapMarketAmount));
$("#VmFundSum").text(numFormart(data.VmFundSum));
@@ -303,6 +305,8 @@ function SearchClientBalance() {
$("#MarginByPayableMargin").text(numFormart(data.MarginByPayableMarginTotal));
$("#ToDayRemainFund").text(numFormart(data.AmountFund));
$("#DesirableFund").text(numFormart(data.DesirableFundTotal));
$("#TotalCredit").text(numFormart(data.TotalCredit));
$("#UsedCredit").text(numFormart(data.UsedCredit));
});
var data = { clientId: $("#ClientId").val(), ValueDateFrom: $("#ValueDateFrom").val(), ValueDateTo: $("#ValueDate").val() };
@@ -462,7 +462,7 @@ const vue = new Vue({
},
//规则15录入区块文案:按 detail 资产类型切换计价口径。
//债券类(利率债16/信用债32/其它债券64)及其他类型(默认债券口径):期初净价/当前净价,金额=×期初全价×券面总额;
//基金及基金专户(32768)/债券指数(536870912):参考标的期初净价/参考标的当前价(空头第1层用"参考标的当前收盘价"),
//基金及基金专户(32768)/债券指数(536870912):参考标的期初净价/参考标的当前收盘价(空头各层统一收盘价,BUG-11 修正),
//金额=×参考标的期初价格×参考标的名义份额。
spanText(detail) {
var ut = (detail && detail.UnderlyingType) || 0;
@@ -471,6 +471,7 @@ const vue = new Vue({
priceInit: '参考标的期初净价',
priceCur: '参考标的当前净价',
priceCurShort1: '参考标的当前收盘价',
priceCurShort: '参考标的当前收盘价',
amountBase: '参考标的期初价格 × 参考标的名义份额'
};
}
@@ -478,6 +479,7 @@ const vue = new Vue({
priceInit: '期初净价',
priceCur: '当前净价',
priceCurShort1: '当前净价',
priceCurShort: '当前净价',
amountBase: '期初全价 × 券面总额'
};
},
@@ -226,7 +226,7 @@ const vue = new Vue({
needDetailRules.push(MarginRuleTypeEnum.买入自动赎回规则1);
needDetailRules.push(MarginRuleTypeEnum.卖出自动赎回规则1);
needDetailRules.push(MarginRuleTypeEnum.区间追保结构);
needDetailRules.push(MarginRuleTypeEnum.单独计算规则1);
needDetailRules.push(MarginRuleTypeEnum.单独计算规则);
if (needDetailRules.indexOf(that.marginTemplate.RuleType.toString()) >= 0) {
new Promise((reslove) => {
//规则15:新增的是"资产类型区块"(含区间录入结构与独立区块键);其他规则维持原样
@@ -473,7 +473,7 @@ const vue = new Vue({
},
//规则15录入区块文案:按 detail 资产类型切换计价口径。
//债券类(利率债16/信用债32/其它债券64)及其他类型(默认债券口径):期初净价/当前净价,金额=×期初全价×券面总额;
//基金及基金专户(32768)/债券指数(536870912):参考标的期初净价/参考标的当前价(空头第1层用"参考标的当前收盘价"),
//基金及基金专户(32768)/债券指数(536870912):参考标的期初净价/参考标的当前收盘价(空头各层统一收盘价,BUG-11 修正),
//金额=×参考标的期初价格×参考标的名义份额。
spanText(detail) {
var ut = (detail && detail.UnderlyingType) || 0;
@@ -482,6 +482,7 @@ const vue = new Vue({
priceInit: '参考标的期初净价',
priceCur: '参考标的当前净价',
priceCurShort1: '参考标的当前收盘价',
priceCurShort: '参考标的当前收盘价',
amountBase: '参考标的期初价格 × 参考标的名义份额'
};
}
@@ -489,6 +490,7 @@ const vue = new Vue({
priceInit: '期初净价',
priceCur: '当前净价',
priceCurShort1: '当前净价',
priceCurShort: '当前净价',
amountBase: '期初全价 × 券面总额'
};
},
@@ -1,4 +1,4 @@
var g_grid = {};
var g_grid = {};
var query_data = {};
$(function () {
@@ -337,13 +337,17 @@ function confirmAllSelect() {
main.message("请选择需确认的交易");
return;
}
var confirmFunc = function (additionalProcessing) {
var confirmFunc = function (additionalProcessing, id, useRecall) {
var ggrid = jQuery('#listGrid');
var rowIds = ggrid.jqGrid('getGridParam', 'selarrrow');
var ors = [];
for (var i = 0; i < rowIds.length; i++) {
var or = ggrid.getRowData(rowIds[i]).id;
ors.push(or);
if (id) {
ors.push(id);
} else {
for (var i = 0; i < rowIds.length; i++) {
var or = ggrid.getRowData(rowIds[i]).id;
ors.push(or);
}
}
var pData = { tradeids: ors.join(",") };
// R4:不再硬编码 IsSkipCheck=true 静默跳过资金校验——普通确认走校验,
@@ -357,6 +361,72 @@ function confirmAllSelect() {
main.message(data.msg);
return;
}
//R4(BUG-04 修正):资金不足时后端返回 success=true + proccessType="AdditionalProcessing"(交易实际未确认),
//必须弹额度试算特批窗口并按弹窗结果重提(复用确认列表页 swapTradeConfirmList.js 逻辑),不能静默刷新
if (data.obj && data.obj.proccessType === "AdditionalProcessing") {
if (data.obj.TrialDataId) {
var layerSetting = {
type: 2,
title: "提示",
shadeClose: false,
shade: 0.4,
area: ['800px', '500px'],
content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId,
yes: function (index) {
var obj = window["layui-layer-iframe" + index].page;
if (obj.IsTF) {
if (obj.Data.Remark && obj.Data.Remark.length > 0) {
main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) {
if (!res || !res.success) {
main.message(res.msg);
}
});
confirmFunc("LackOfMoney", obj.Data.TradeId, true);
} else {
main.message("必须填写说明内容,才可以录入交易");
return;
}
} else {
if (obj.Data.Remark && obj.Data.Remark.length > 0) {
main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) {
if (!res || !res.success) {
main.message(res.msg);
}
});
}
confirmFunc("LackOfMoney", obj.Data.TradeId, true);
}
(parent || window).layer.closeAll();
},
cancel: function () {
if (window.parent && window.parent.reloadtrade) {
window.parent.reloadtrade();
}
(parent || window).layer.closeAll();
}
};
if (data.obj.type === "LackOfMoney") {
layerSetting.btn = ["交易特批", '取消'];
}
// 只展示"资金状况"的情况
if (data.obj.typecode === "FundStatus") {
layerSetting.btn = ["确认", '取消'];
}
layer.open(layerSetting);
}
else {
if (data.obj.message) {
main.message(data.obj.message);
}
}
return;
}
if (useRecall) {
//特批单笔已通过,重跑本批剩余交易
confirmFunc();
return;
}
main.message(data.msg);
SearchClick(true);
});
};
@@ -1,4 +1,4 @@
var g_grid = {};
var g_grid = {};
var query_data = {};
$(function () {
@@ -1048,13 +1048,17 @@ function confirmAllSelect() {
main.message("请选择需确认的交易");
return;
}
var confirmFunc = function (additionalProcessing) {
var confirmFunc = function (additionalProcessing, id, useRecall) {
var ggrid = jQuery('#listGrid');
var rowIds = ggrid.jqGrid('getGridParam', 'selarrrow');
var ors = [];
for (var i = 0; i < rowIds.length; i++) {
var or = ggrid.getRowData(rowIds[i]).id;
ors.push(or);
if (id) {
ors.push(id);
} else {
for (var i = 0; i < rowIds.length; i++) {
var or = ggrid.getRowData(rowIds[i]).id;
ors.push(or);
}
}
var pData = { tradeids: ors.join(",") };
// R4:不再硬编码 IsSkipCheck=true 静默跳过资金校验——普通确认走校验,
@@ -1069,6 +1073,72 @@ function confirmAllSelect() {
main.message(data.msg);
return;
}
//R4(BUG-04 修正):资金不足时后端返回 success=true + proccessType="AdditionalProcessing"(交易实际未确认),
//必须弹额度试算特批窗口并按弹窗结果重提(复用确认列表页 swapTradeConfirmList.js 逻辑),不能静默刷新
if (data.obj && data.obj.proccessType === "AdditionalProcessing") {
if (data.obj.TrialDataId) {
var layerSetting = {
type: 2,
title: "提示",
shadeClose: false,
shade: 0.4,
area: ['800px', '500px'],
content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId,
yes: function (index) {
var obj = window["layui-layer-iframe" + index].page;
if (obj.IsTF) {
if (obj.Data.Remark && obj.Data.Remark.length > 0) {
main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) {
if (!res || !res.success) {
main.message(res.msg);
}
});
confirmFunc("LackOfMoney", obj.Data.TradeId, true);
} else {
main.message("必须填写说明内容,才可以录入交易");
return;
}
} else {
if (obj.Data.Remark && obj.Data.Remark.length > 0) {
main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) {
if (!res || !res.success) {
main.message(res.msg);
}
});
}
confirmFunc("LackOfMoney", obj.Data.TradeId, true);
}
(parent || window).layer.closeAll();
},
cancel: function () {
if (window.parent && window.parent.reloadtrade) {
window.parent.reloadtrade();
}
(parent || window).layer.closeAll();
}
};
if (data.obj.type === "LackOfMoney") {
layerSetting.btn = ["交易特批", '取消'];
}
// 只展示"资金状况"的情况
if (data.obj.typecode === "FundStatus") {
layerSetting.btn = ["确认", '取消'];
}
layer.open(layerSetting);
}
else {
if (data.obj.message) {
main.message(data.obj.message);
}
}
return;
}
if (useRecall) {
//特批单笔已通过,重跑本批剩余交易
confirmFunc();
return;
}
main.message(data.msg);
SearchClick(true);
});
};