#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
@@ -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.;