Merge remote-tracking branch 'origin/glms/feature/1.4.2' into glms/feature/1.4.2

This commit is contained in:
张名锐
2026-08-27 16:14:07 +08:00
40 changed files with 2193 additions and 268 deletions
@@ -8,7 +8,9 @@ namespace YLErp.Modules.SwapModule.Margin
{
/// <summary>
/// R3 阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易日终结算产生"追加保证金"资金记录。
/// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 累计保证金(应付预付金+追加保证金 流水净额 + 追加授信占用);
/// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 已缴保证金净额
/// (应付预付金现金净收额 + 初始授信占用净额 + 追加保证金现金累计 + 追加授信占用累计——
/// 2026-08-27 修正:授信垫付的初始预付金不产生应付预付金流水,此前未计入已缴导致每个结算日按维持全额重复开追加);
/// 现金部分为逐结算日增量记录(BUG-03 修正:每结算日一条、Money=increment,键 TradeId+Action+Deal+HappenDate 幂等),
/// 需求上升只增不减;授信优先(阶段二规则):授信部分只写授信出入表(remark 前缀=追加保证金,position_id 空、冗余 trade_id)。
/// 由 EOD 在客户资金计算之前调用:当日新记录计入当日出入金窗口并翻"已结算",重跑时 目标/已补足 不变 → 新增为 0 不重复写。
@@ -107,6 +109,16 @@ namespace YLErp.Modules.SwapModule.Margin
.Select(g => new { TradeId = g.Key ?? 0, Funded = g.Sum(x => x.amount) })
.ToDictionary(x => x.TradeId, x => x.Funded);
//初始预付金的授信占用净额(非"追加保证金"前缀:簿记初始占用 + 平仓释放取负,Σ(amount) 即净已缴):
//授信腿不产生应付预付金流水,目标追加里只扣现金净收额会把授信垫付的初始预付金漏掉——
//每个结算日都按维持保证金全额重复开追加(BUG:多收授信占用/现金,2026-08-27 交易2538实证:初始授信200万未扣、首日全额追加280.8万)
var initCreditByTrade = DbContext.client_credit_inout.AsNoTracking()
.Where(x => x.trade_id != null && tradeIds.Contains(x.trade_id ?? 0)
&& (x.remark == null || !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);
@@ -121,8 +133,11 @@ namespace YLErp.Modules.SwapModule.Margin
{
continue;
}
var target = SwapAdditionalMarginCalc.CalcTarget(maintenance,
payableNetByTrade.TryGetValue(td.id, out var payableNet) ? payableNet : 0);
//目标追加 = 维持保证金 − 已缴初始保证金净额(现金应付预付金净收额 + 授信初始占用净额,
//授信垫付与现金垫付同等对待,杜绝授信初始预付金被重复追加)
var payableNet = (payableNetByTrade.TryGetValue(td.id, out var payable) ? payable : 0)
+ (initCreditByTrade.TryGetValue(td.id, out var initCredit) ? initCredit : 0);
var target = SwapAdditionalMarginCalc.CalcTarget(maintenance, payableNet);
if (target <= 0)
{
continue;
@@ -72,11 +72,27 @@ namespace YLErp.Modules.SwapModule.Margin
return tier;
}
}
//未落任何层:价格已穿出最深一层边界(低于多头最深层下界/高于空头最深层上界),按最深层计;
//最深层按边界值取(多头=最小下界空头=最大上界),不依赖配置数组顺序(BUG-25 引擎侧防御)
return isCustomerLong
//未落任何层分两种情形
//① 价格穿出最深一层边界(多头低于最深层下界/空头高于最深层上界)→ 按最深层计(追保金额不再上升);
// 最深层按边界值取(多头=最小下界、空头=最大上界),不依赖配置数组顺序(BUG-25 引擎侧防御)。
//② 层间空隙(如空头 (0.99,1.00]——价格在期初附近小幅波动、未触发追保的区间)→ 返回 null,追加保证金按 0。
// 此前兜底不分情形一律按最深层计,空隙价格被错误收取最深档追保
// 2026-08-27 交易2538实证:08-24净价100→ratio 0.99999 落空头(0.99,1.00]空档,被按0.04最深档收80.8万)。
var deepest = isCustomerLong
? valid.OrderBy(t => t.Lower ?? double.MinValue).First()
: valid.OrderByDescending(t => t.Upper ?? double.MaxValue).First();
if (isCustomerLong)
{
if (priceRatio < (deepest.Lower ?? double.MinValue))
{
return deepest;
}
}
else if (priceRatio > (deepest.Upper ?? double.MaxValue))
{
return deepest;
}
return null;
}
/// <summary>
@@ -2001,6 +2001,33 @@ namespace YLErp.Modules.SwapModule
SwapCalcTrace.Write(interestTrace);
}
/// <summary>
/// 单标的平仓
/// </summary>
/// <param name="unwindData"></param>
/// <param name="confirmation">新风控二次确认信息;首次提交传空。</param>
/// <returns>本次平仓的新风控试算结果。</returns>
/// <exception cref="ServiceException"></exception>
public YLErp.Modules.RiskEngine.RiskTrialResult SwapUnwind(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation)
{
if (unwindData == null)
{
throw new ServiceException("平仓信息不能为空");
}
if (FindTrade(unwindData.SwapTradeId) == null)
{
throw new ServiceException("未找到交易信息");
}
var riskTrialResult = CheckCloseRisk(unwindData, confirmation);
if (!riskTrialResult.Passed)
{
return riskTrialResult;
}
SwapUnwind(unwindData);
return riskTrialResult;
}
/// <summary>
/// 单标的平仓
/// </summary>
@@ -2421,6 +2448,29 @@ namespace YLErp.Modules.SwapModule
SaveAllChanges();
}
/// <summary>
/// 互换/平仓提交审核
/// </summary>
/// <param name="unwindData"></param>
/// <param name="eventType"></param>
/// <param name="confirmation">新风控二次确认信息;首次提交传空。</param>
/// <returns>平仓事件返回新风控试算结果,互换事件返回空。</returns>
/// <exception cref="ServiceException"></exception>
public YLErp.Modules.RiskEngine.RiskTrialResult ApplySwapTrade(UnwindData unwindData, int eventType, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation)
{
YLErp.Modules.RiskEngine.RiskTrialResult riskTrialResult = null;
if (eventType == (int)SwapEventTypeEnum.)
{
riskTrialResult = CheckCloseRisk(unwindData, confirmation);
if (!riskTrialResult.Passed)
{
return riskTrialResult;
}
}
ApplySwapTrade(unwindData, eventType);
return riskTrialResult;
}
/// <summary>
/// 互换/平仓提交审核
/// </summary>
@@ -2476,6 +2526,33 @@ namespace YLErp.Modules.SwapModule
}
});
}
/// <summary>
/// 执行互换交易平仓审核时点的新风控试算。
/// </summary>
/// <param name="unwindData">包含本次平仓日期和交易ID的平仓请求。</param>
/// <param name="confirmation">新风控二次确认信息;首次提交传空。</param>
/// <returns>包含阻断、审批、提示及试算记录ID的新风控结果。</returns>
/// <exception cref="ServiceException">平仓请求为空。</exception>
private YLErp.Modules.RiskEngine.RiskTrialResult CheckCloseRisk(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation)
{
if (unwindData == null)
{
throw new ServiceException("平仓信息不能为空");
}
const string triggerPoint = "CLOSE_REVIEW";
const int closeTrialSource = 2;
var riskContext = new YLErp.Modules.RiskEngine.RiskContext
{
TradeId = unwindData.SwapTradeId,
TriggerPoint = triggerPoint,
UnwindDate = unwindData.UnwindDate,
PayDate = unwindData.PayDate
};
return new YLErp.Modules.RiskEngine.RiskTrialService(this).CheckRisk(riskContext, closeTrialSource, confirmation);
}
private void ValidateIncomeValueDate(UnwindData unwindData, trade td)
{
var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date;