Files
zszq-trs/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs
T

356 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using YLErp.BLL;
using YLErp.DBModels;
using YLErp.Enums;
using YLErp.Modules.MarginModule;
using YLErp.Modules.TradeModule;
using YLErp.Helpers;
namespace YLErp.Modules.SwapModule.Margin
{
/// <summary>
/// R3 阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易日终结算产生"追加保证金"。
/// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 已缴保证金净额
/// (应付预付金现金净收额 + 初始授信占用净额——
/// 2026-08-27 修正:授信垫付的初始预付金不产生应付预付金流水,此前未计入已缴导致每个结算日按维持全额重复开追加);
/// 需求上升只增不减、回落不返还。
/// 腿化改造(2026-08-27):增量>0 时生成一条与手工追加预付金同形态的交易腿(InterestMode=追加预付金、
/// OptName="EOD追保" 打标),并复用手工确认链路的 SwapFundTagService.ApplyMarginFundTags 完成授信/现金簿记
/// (资金来源走腿 fund_tag→交易级 margin_fund_source 回退链;授信部分写授信出入表占用绑腿、不产生流水;
/// 现金部分出 Action=系统操作-追加保证金 流水、Deal=现金腿 id——Action 与手工腿簿记的"应付预付金"区隔,
/// 保住 EQD-6952 资金通知书聚合口径);平仓返还(ReleaseMarginByTag)与交易回退清理由既有链路自动获得。
/// 幂等:重跑先清(本结算日起 EOD 旧追保腿及其簿记)后建;eod_swap_position 当日行显式补写(方案A——
/// 腿生成晚于当日 SwapPositionCompose,快照须补齐,见 SwapEodPositionService.SaveEodAdditionalMarginPosition)。
/// 由 EOD 在客户资金计算之前调用:当日新记录计入当日出入金窗口并翻"已结算"。
/// 客户维度(MarginWatchRule=1/NULL)不产生资金记录(§0 占用口径),不在本服务范围。
/// </summary>
public class SwapAdditionalMarginService : YLBaseService
{
private static readonly IYcLogger logger = LogFactory.GetLogger("EOD追保");
/// <summary>
/// EOD 追保腿打标(OptName):与手工追加预付金腿(OptName=操作员实名)区分,
/// 幂等清理、RemoveByTrade 保护与时间轴回退清理均以此识别。拆单现金腿落库时被打服务身份,簿记后回打本标识。
/// </summary>
public const string EodOptName = "EOD追保";
public SwapAdditionalMarginService(OptUserInfo userInfo) : base(userInfo)
{
}
public SwapAdditionalMarginService(YLBaseService baseService) : base(baseService)
{
}
/// <summary>
/// EOD 追保腿识别(InterestMode=追加预付金 + OptName 打标);
/// happenDateFrom 限定重跑/回退窗口起点,防误删更早历史日已归属腿。
/// </summary>
public static bool IsEodMarginLeg(swap_position leg, DateTime? happenDateFrom = null)
{
return leg != null
&& leg.InterestMode == (int)InterestModeEnum.追加预付金
&& leg.OptName == EodOptName
&& (happenDateFrom == null || leg.HappenDate >= happenDateFrom);
}
/// <summary>
/// 构造 EOD 追保腿(纯函数,便于单测;落库与 PosiNumber 回填由调用方完成):
/// 与手工追加预付金腿同形态(PositionType=0 资金腿、PosiDirection=0、IsInitial、InterestMode=追加预付金、
/// 收取方向、fix=增量两位舍入),FundTag=null 回退交易级资金来源,OptName 打标 EOD 追保。
/// </summary>
public static swap_position BuildEodMarginLeg(trade td, double increment, DateTime settleDate, int optId)
{
return new swap_position
{
SwapTradeId = td.id,
PositionType = 0,
PosiDirection = 0,
IsInitial = true,
InterestMode = (int)InterestModeEnum.追加预付金,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestPrincipalFix = Math.Round(Convert.ToDecimal(increment), 2, MidpointRounding.AwayFromZero),
HappenDate = settleDate,
PosiStartDate = td.StartDate ?? settleDate,
PosiMatuirityDate = td.ExerciseDate,
Currency = td.SettlementCurrency ?? ConsGlobal.Currency.CNY,
FundTag = null,
OptId = optId,
OptName = EodOptName,
OptTime = DateTime.Now
};
}
/// <summary>
/// 结算日逐客户逐交易产生追加保证金(clientFilter 为部分结算的客户过滤,与 EOD 请求一致)。
/// 顶层兜异常日志(Error 级)后原样抛出,避免异常栈被 EOD 框架层吞掉无从定位。
/// </summary>
public void SettleAdditionalMargin(DateTime settleDate, List<int> clientFilter = null)
{
try
{
SettleAdditionalMarginCore(settleDate, clientFilter);
}
catch (Exception ex)
{
var filterDesc = clientFilter != null && clientFilter.Any() ? string.Join(",", clientFilter) : "全部";
logger.Error($"EOD追保:结算日{settleDate:yyyy-MM-dd}(客户范围:{filterDesc})追保腿生成异常", ex);
throw;
}
}
private void SettleAdditionalMarginCore(DateTime settleDate, List<int> clientFilter)
{
//合约维度盯市客户
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)
{
logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 无合约维度盯市(MarginWatchRule=0)客户,结束");
return;
}
logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 合约维度盯市客户{watchClientIds.Count}个({string.Join(",", watchClientIds)}");
//存续中的互换交易(状态口径与 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)
{
logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 盯市客户名下无存续收益互换交易,结束");
return;
}
//开始日门槛:未到交易开始日(未起息)的不参与追保——维持保证金引擎从成交日就产出 trade_span,
//初始预付金流水却到开始日才入账,不过滤会在 成交日~开始日 之间把未到期的初始预付金
//误判成缺口、按维持全额追加(2026-08-28 交易2571实证:07-31成交/08-03起息,重刷07-31误追500000
var notStartedTrades = trades.Where(t => (t.StartDate ?? t.TradeDate) > settleDate).ToList();
if (notStartedTrades.Count > 0)
{
logger.Info($"EOD追保:{notStartedTrades.Count}笔交易未到开始日(id=[{string.Join(",", notStartedTrades.Select(t => t.id))}]),不参与追保结算");
trades = trades.Except(notStartedTrades).ToList();
if (trades.Count == 0)
{
logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 交易均未到开始日,结束");
return;
}
}
//规则15(区间追保结构)交易:R1 三层级解析(BUG-02 修正,与引擎/确认书同口径)——
//交易绑定→客户默认→全局默认 找到即停,只配客户/全局默认模板的交易同样纳入追保结算
var candidateCount = trades.Count;
var templatesByTrade = MarginTemplateV2RateHelper.ResolveTieredTemplates(trades, settleDate, DbContext);
trades = trades.Where(t => templatesByTrade.TryGetValue(t.id, out var tpl)
&& tpl.RuleType == (int)MarginRuleTypeEnum.区间追保结构).ToList();
if (candidateCount > trades.Count)
{
logger.Info($"EOD追保:{candidateCount - trades.Count}笔交易模板非区间追保结构(或三级未命中),不参与追保结算");
}
var tradeIds = trades.Select(t => t.id).ToList();
if (tradeIds.Count == 0)
{
logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 规则15交易为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;口径与 EOD canonical 一致:非作废+已确认/已结算。
//腿化改造后现金流水 Deal=现金腿 id(原 Deal==0 条件去除):该 Action 只有 EOD 会写,手工链路写应付预付金,语义天然隔离)
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.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);
//初始预付金的授信占用净额(非"追加保证金"前缀:簿记初始占用 + 平仓释放取负,Σ(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 eodPositionService = new SwapEodPositionService(this);
var flowEventService = new SwapFlowEventService(this);
var settledTrades = 0;
var settledLegs = 0;
var totalIncrement = 0d;
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)
{
logger.Info($"EOD追保:交易{td.id}(客户{td.ClientId})跳过——当日无trade_span维持保证金或维持≤0maintenance={maintenance:0.00}");
continue;
}
//目标追加 = 维持保证金 − 已缴初始保证金净额(现金应付预付金净收额 + 授信初始占用净额,
//授信垫付与现金垫付同等对待,杜绝授信初始预付金被重复追加)
var payable = payableNetByTrade.TryGetValue(td.id, out var p) ? p : 0;
var initCredit = initCreditByTrade.TryGetValue(td.id, out var ic) ? ic : 0;
var payableNet = payable + initCredit;
var target = SwapAdditionalMarginCalc.CalcTarget(maintenance, payableNet);
if (target <= 0)
{
logger.Info($"EOD追保:交易{td.id}(客户{td.ClientId})跳过——目标追加≤0:维持={maintenance:0.00},已缴={payableNet:0.00}(应付净额{payable:0.00}+初始授信占用{initCredit:0.00})已覆盖");
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)
{
//已补足;追保回落(目标下降)不返还——负缺口在可用资金公式(Σ维持−累计)体现
logger.Info($"EOD追保:交易{td.id}(客户{td.ClientId})跳过——增量≤0:目标={target:0.00},已补足(现金{fundedCash:0.00}+授信{fundedCredit:0.00}),追保回落不返还");
continue;
}
logger.Info($"EOD追保:交易{td.id}(客户{td.ClientId})需追加:维持={maintenance:0.00},已缴={payableNet:0.00}(应付净额{payable:0.00}+初始授信占用{initCredit:0.00}),目标={target:0.00},已补足(现金{fundedCash:0.00}+授信{fundedCredit:0.00}),本次增量={increment:0.00}");
//幂等清理:先删本结算日起 EOD 旧追保腿及其簿记(腿/流水/占用/快照同生共死),再按最新增量重建;
//手工追加预付金腿(OptName≠EOD追保)不受影响
CleanupEodMarginLegs(td, settleDate);
//建腿:与手工追加预付金同形态,FundTag=null 回退交易级资金来源
var legIdsBefore = DbContext.swap_position.Where(x => x.SwapTradeId == td.id).Select(x => x.id).ToList();
var leg = BuildEodMarginLeg(td, increment, settleDate, UserId);
DbContext.swap_position.Add(leg);
DbContext.SaveChanges();
leg.PosiNumber = $"{td.TradeNumber}-{leg.id}";
//簿记:复用手工确认链路的标签分配(额度内授信占账、跨界拆单、现金出流水),
//Action 独立为 追加保证金(EQD-6952 口径)、占用 remark 带"追加保证金"前缀(累计/清理链路硬性识别点);
//resetExistingBookings=false——本交易初始预付金占用不在此重写(EOD 只簿记本次新腿,旧追保簿记上方已清)
fundTagService.ApplyMarginFundTags(td, new List<swap_position> { leg }, cashService, ignoreMoneyCheck: false,
cashAction: ClientCashInCashOut.系统操作_追加保证金,
occupyRemark: ClientCreditInoutService.AdditionalMarginRemark + "占用",
splitOccupyRemark: ClientCreditInoutService.AdditionalMarginRemark + "拆单授信部分",
bookingDate: settleDate,
resetExistingBookings: false);
//本次新腿 = 追保腿 + 可能的拆单现金腿(SplitLeg 落库时打了服务身份 OptName,回打 EOD 标识保证幂等清理覆盖完整)
var newLegs = DbContext.swap_position.Where(x => x.SwapTradeId == td.id && x.IsInitial
&& x.InterestMode == (int)InterestModeEnum.追加预付金 && !legIdsBefore.Contains(x.id))
.ToList();
newLegs.ForEach(x => x.OptName = EodOptName);
DbContext.SaveChanges();
logger.Info($"EOD追保:交易{td.id} 追保腿落库完成——新腿{newLegs.Count}条(id=[{string.Join(",", newLegs.Select(x => x.id))}],含拆单现金腿)");
settledTrades++;
settledLegs += newLegs.Count;
totalIncrement += increment;
//实时持仓克隆 + 开仓事件(参照 TradeConfirmService 簿记后动作,但只针对本次新腿——
//整交易 InitialPosition 会把浮动腿实时持仓重置回开仓态、AddPositionEvent 会为全部腿重复建开仓事件,EOD 场景不可用);
//克隆继承定稿标签,供平仓返还分流与后续快照链使用
foreach (var newLeg in newLegs)
{
var clone = newLeg.Clone();
clone.id = 0;
clone.PositionId = newLeg.id;
clone.IsInitial = false;
DbContext.swap_position.Add(clone);
}
DbContext.SaveChanges();
flowEventService.InitEvent(newLegs, td, EodOptName);
DbContext.SaveChanges();
//方案A:腿生成晚于当日 SwapPositionCompose(当日 trade_span 结算后才可算增量),显式补写当日 eod_swap_position 行,
//使当日报表明细(PostionMarginGain 等)不漏计;下一结算日快照由 Compose 先清后建正常接管
foreach (var newLeg in newLegs)
{
eodPositionService.SaveEodAdditionalMarginPosition(td, newLeg, settleDate);
}
}
}
logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 完成——规则15交易{tradeIds.Count}笔,{settledTrades}笔产生追保(腿{settledLegs}条,合计增量{totalIncrement:0.00}");
}
/// <summary>
/// 幂等清理:删除本交易 本结算日起 EOD 生成的旧追保腿(含其实时克隆)及其簿记——
/// 现金流水(Action=追加保证金,Deal=腿id,按现行 DeleteTradeCashInCashOut 习惯硬删)、
/// 授信占用(position_id=腿id)、开仓事件、方案A 补写的当日及以后 eod_swap_position 行。
/// </summary>
private void CleanupEodMarginLegs(trade td, DateTime settleDate)
{
var oldLegs = DbContext.swap_position.Where(x => x.SwapTradeId == td.id
&& x.InterestMode == (int)InterestModeEnum.追加预付金
&& x.OptName == EodOptName
&& x.HappenDate >= settleDate)
.ToList();
if (oldLegs.Count == 0)
{
return;
}
var oldLegIds = oldLegs.Select(x => x.id).ToList();
//簿记(流水 Deal/占用 position_id/快照 PositionId/事件 PositionId)均绑期初腿(拆单现金腿也是 IsInitial 的独立腿)
var oldInitialIds = oldLegs.Where(x => x.IsInitial).Select(x => x.id).ToList();
var oldCashRecords = DbContext.ClientCashInCashOut.Where(x => x.TradeId == td.id
&& x.Action == ClientCashInCashOut.系统操作_追加保证金
&& oldInitialIds.Contains(x.Deal))
.ToList();
DbContext.ClientCashInCashOut.RemoveRange(oldCashRecords);
var oldCreditRecords = DbContext.client_credit_inout.Where(x => x.trade_id == td.id
&& x.position_id != null && oldInitialIds.Contains(x.position_id.Value))
.ToList();
DbContext.client_credit_inout.RemoveRange(oldCreditRecords);
var oldEvents = DbContext.swap_flow_event.Where(x => x.SwapTradeId == td.id
&& x.EventType == (int)SwapFlowEventTypeEnum.开仓 && oldInitialIds.Contains(x.PositionId))
.ToList();
DbContext.swap_flow_event.RemoveRange(oldEvents);
var oldEodRows = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id
&& x.ValueDate >= settleDate && oldInitialIds.Contains(x.PositionId))
.ToList();
logger.Info($"EOD追保:交易{td.id} 幂等清理(重跑)——删EOD追保腿{oldLegs.Count}条(id=[{string.Join(",", oldLegs.Select(x => x.id))}])及其簿记:现金流水{oldCashRecords.Count}条、授信占用{oldCreditRecords.Count}条、开仓事件{oldEvents.Count}条、eod快照{oldEodRows.Count}行");
DbContext.eod_swap_position.RemoveRange(oldEodRows);
DbContext.swap_position.RemoveRange(oldLegs);
DbContext.SaveChanges();
}
}
}