Files
zszq-trs/YLErpDAL/Modules/SwapModule/SwapDealService.cs
T

2195 lines
131 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 MoreLinq.Extensions;
using Newtonsoft.Json;
using YLErp.BLL;
using YLErp.BLL.Eod;
using YLErp.DBModels.Enums;
using YLErp.Derivatives.Interest;
using YLErp.Helpers;
using YLErp.Modules.DataProviderModule;
using YLErp.Modules.EodModule;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.FundingLegs;
using YLErp.Modules.SwapModule.Margin;
using YLErp.Modules.SwapModule.Penalty;
using YLErp.Modules.SwapModule.ReturnLegs;
using YLErp.Modules.TradeModule;
using YLErp.Modules.TradeModule.DealModule;
using YLErp.QdpModule;
namespace YLErp.Modules.SwapModule
{
public class SwapDealService : SwapTradeBaseService
{
private static readonly IYcLogger Logger = LogFactory.GetLogger(nameof(SwapDealService));
protected virtual bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
return EodPriceQueryService.TryGetPrice(valueDate, underlyingCode, out rate);
}
private IIndexFixer _indexFixer;
/// <summary>FR007 取价器,委托 TryGetFloatRate(保留 virtual 接缝供测试 stub)。</summary>
protected virtual IIndexFixer IndexFixer
=> _indexFixer ??= new SwapDealIndexFixer((d, c) =>
TryGetFloatRate(d, c, out double r) ? (true, r) : (false, 0d));
#region 可测试化接缝(Seams)——override 这些虚方法可在测试中替换 DB/外部调用,生产代码行为不变
// FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复)
/// <summary>添加资金记录(生产: AddClientCashInCashOut;测试: 计数并记录金额)</summary>
protected virtual int AddClientCash(trade td, double amount, string action, DateTime valueDate)
{
return AddClientCashInCashOut(td, amount, action, valueDate);
}
/// <summary>保存互换/平仓事件(生产: 落库+建事件;测试: 收集 unwindData 入内存列表)。
/// 原 private 改 protected virtual,使测试 stub 可整体 override,规避内部 new SwapEventService 连库。</summary>
protected virtual long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false)
{
UnwindNormalizer.NormalizeNotionalValues(unwindData);
return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve);
}
// 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 InterestMath.FundingLegPrecision,消除重复定义。
private const int InterestCalculationPrecision = InterestMath.FundingLegPrecision;
// 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。
private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason)
{
if (!UnwindNormalizer.NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason))
{
return;
}
if (unwindData.FlowEvents.Any(x => !string.IsNullOrEmpty(x.UnderlyingCode)))
{
CalcCloseAmount(unwindData);
if (eventType == (int)SwapEventTypeEnum.互换)
{
unwindData.SwapMarginAmount = 0;
}
return;
}
unwindData.SwapCloseAmount = Math.Round(unwindData.SwapCloseAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
unwindData.SwapRealizedPnL = Math.Round(unwindData.SwapRealizedPnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
/// <summary>保存所有变更(生产: DbContext.SaveChanges;测试: 空操作)</summary>
protected virtual void SaveAllChanges()
{
DbContext.SaveChanges();
}
/// <summary>在事务中执行(生产: BeginTransaction/Commit/Rollback;测试: 直接执行不包事务)</summary>
protected virtual void ExecuteInTransaction(Action action)
{
var trans = DbContext.Database.BeginTransaction();
try
{
action();
trans.Commit();
}
catch
{
trans.Rollback();
throw;
}
finally
{
trans.Dispose();
}
}
/// <summary>保存互换交易资金记录(生产: new ClientCashInCashOutService;测试: 空操作)。
/// 仅 SwapUnwind 全平仓且 NeedOpenFee=false 时调用。</summary>
protected virtual void CallSaveSwapTradeClientCash(trade td, DateTime valueDate)
{
td.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
if (td.trade_extend != null && !td.trade_extend.ExtendObj.NeedOpenFee)
{
new ClientCashInCashOutService(this).SaveSwapTradeClientCash(td, td.TradePrice ?? 0, valueDate, 0);
}
}
/// <summary>触发互换实时持仓计算(生产: Task.Run 异步 RealtimePnlCalc;测试: 空操作)。
/// 仅 SwapUnwind 成功后调用。</summary>
protected virtual void TriggerRealtimeSwapPosition()
{
Task.Run(() =>
{
try
{
RealtimePnlCalc.RealtimeSwapPosition(new OptUserInfo(0, "互换实时持仓服务", OptUserFrom.Service));
}
catch (Exception ex)
{
LogFactory.GetLogger<SwapDealService>().Error("互换实时持仓服务计算失败", ex);
}
});
}
/// <summary>查找待审核的互换/平仓事件(生产: DbContext.swap_event 查询;测试: 返回内存对象)</summary>
protected virtual swap_event FindSwapEvent(int tradeId, int eventType)
{
return DbContext.swap_event
.Where(x => x.SwapTradeId == tradeId && !x.Invalid && x.EventType == eventType)
.OrderByDescending(o => o.id).FirstOrDefault();
}
/// <summary>查找事件关联的流水事件(生产: DbContext.swap_flow_event 查询;测试: 返回内存列表)</summary>
protected virtual List<swap_flow_event> FindFlowEventsByEventId(long eventId)
{
return DbContext.swap_flow_event.Where(x => x.EventId == eventId).ToList();
}
/// <summary>平仓/互换审核的前置校验与状态设置(生产: new TradeUnwindService;测试: 空操作或计数)</summary>
protected virtual void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck)
{
new TradeUnwindService(this).CloseReCheck_SetTrade(swapTradeId, isSwap, needCheck);
}
protected virtual DateTime GetMaxIncomeValueDate(trade td)
{
return td.ExerciseDate.Value.AddDays(-1);
}
#endregion
public SwapDealService(OptUserInfo optUser) : base(optUser)
{
}
public SwapDealService(YLBaseService baseService) : base(baseService)
{
}
#region 前端盈亏只读校验(不阻断交易)
/// <summary>
/// 用 FrontendCalcReference 公式重算盈亏,与前端传来的 unwindData 比对,
/// 差异 > 0.01 记 Error 日志。整体 try/catch 吞异常——校验自身错误绝不阻断交易。
///
/// 目的:前端保持快速反馈(用户改输入立即算),后端不替代前端,仅做合理性兜底,
/// 为将来公式统一积累"前后端差异"数据。
/// 核心比对逻辑已抽到 SwapFrontendPnlValidator.BuildFrontendValidationDiffs 纯函数,便于单测覆盖。
/// </summary>
/// <param name="unwindData">前端算好传入的结算数据</param>
/// <param name="isIncome">true=结息页(income公式)false=平仓页(unwind公式)</param>
private void ValidateFrontendPnL(UnwindData unwindData, bool isIncome)
{
try
{
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome);
if (diffs == null) return;
// 取浮动腿用于日志上下文(与原实现一致)
var floatLeg = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode));
foreach (var d in diffs)
{
Logger.Error($"[互换盈亏校验分歧] tradeId={unwindData.SwapTradeId} field={d.Field} frontend={d.FrontendValue} backend={d.BackendValue} diff={d.Delta} " +
$"floatLeg=[gross={floatLeg?.PosiGrossPrice} avg={floatLeg?.TradingAmountAvg} qty={floatLeg?.Quantity} payDir={floatLeg?.PayDirection} posType={floatLeg?.PositionType}]");
}
}
catch (Exception ex)
{
// 校验自身错误绝不阻断交易
Logger.Error($"[互换盈亏校验异常] tradeId={unwindData.SwapTradeId} isIncome={isIncome}", ex);
}
}
#endregion
/// <summary>
/// 平仓初始化
/// </summary>
/// <param name="tradeId"></param>
/// <returns></returns>
/// <exception cref="ServiceException"></exception>
public UnwindData InitUnwind(int tradeId)
{
var td = DbContext.trade.Find(tradeId);
var positions = DbContext.swap_position.ActiveByTrade(tradeId);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode);
bool commodity = ConsGlobal.InstrumentType.CalcTypeIsFutures(um.UnderlyingInstrumentType);
List<int> eventTyps = new List<int>() { (int)SwapEventTypeEnum.自动互换, (int)SwapEventTypeEnum.互换 };
var dealDate = valuedateBLL.ValueDate <= td.ExerciseDate.Value ? valuedateBLL.ValueDate : td.ExerciseDate.Value;
//CheckLastEod(dealDate, td.TradeDate.Value, tradeId); //去掉平仓收盘限制
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault();
var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps);
var hasProcess = HasTradeProcess();
swap_flow_event floatEvent = new swap_flow_event();
UnwindData unwindData = new UnwindData();
if (((valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess)) && td.TradeStatus == ConsTrade.平仓待复核)
{
var swapEvent = GetSwapEvent(tradeId, (int)SwapEventTypeEnum.平仓);
if (swapEvent == null)
{
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
unwindData = swapEvent.unwindData;
}
else
{
unwindData.TradeStartDate = td.StartDate;
unwindData.CloseType = commodity ? 1 : 2;
unwindData.StartDate = td.TradeDate.Value;
if (preDealDate.HasValue)
{
unwindData.StartDate = preDealDate.Value;
}
unwindData.ValueDate = dealDate;
unwindData.UnwindDate = dealDate;
floatEvent.EventDate = dealDate;
floatEvent.UnwindDate = unwindData.UnwindDate;
floatEvent.PayDate = QdpCalendarHelper.GetNonHoliday(unwindData.UnwindDate.Value.AddDays(td.trade_extend.ExtendObj.SettlementRules));
unwindData.PayDate = floatEvent.PayDate;
floatEvent.SwapTradeId = tradeId;
floatEvent.SwapTradeNo = td.TradeNumber;
unwindData.SwapTradeId = tradeId;
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
// EQD-6977 罚息:平仓页「是否罚息」默认带出簿记值;以平仓时选择为准(可改),此处仅默认值
unwindData.IsPenaltyInterest = tradeExtend != null && tradeExtend.ExtendObj.IsPenaltyInterest;
unwindData.CloseMethod = (int)CloseMethodEnum.全部平仓;
// 占期初(original)语义(A):默认"平掉剩余全部持仓" = 剩余名义本金/期初名义本金。
// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);部分平仓后自动变为剩余比例(如已平10%则默认90%)。
// 与互换/提前终止 InitIncome(L447) 保持一致。
unwindData.ClosePercent = CalcDefaultInitClosePercent(unwindData.NotionalValue, unwindData.PosiNotionalValue);
unwindData.CloseNotionalValue = unwindData.PosiNotionalValue;
unwindData.CloseQty = unwindData.PositionQty;
if (position != null)
{
floatEvent.PositionId = position.PositionId;
floatEvent.EventType = (int)SwapEventTypeEnum.平仓;
floatEvent.EventReason = "交易";
// 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
// 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
// 同一 EOD 值取一次喂两栏:
// DividendIn = "浮动端平仓盈亏·分红"(本次动作要落袋的,落库后被前端按需展示)
// DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径,
// 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归)
decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
Logger.Info($"[分红-平仓预览] 方案C DividendIn=DividendPending=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
floatEvent.DividendIn = preEodDividendSum;
floatEvent.DividendPending = preEodDividendSum;
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
floatEvent.BeforeCloseFee = oriPosition.PosiTradingFeePending;
floatEvent.TradingFee = TradingFeeCalc.CalcInitTradingFee(oriPosition, unwindData);
floatEvent.PosiTradingFeeUnit = oriPosition?.PosiTradingFeeUnit ?? 0;
floatEvent.PosiFeeType = oriPosition?.PosiFeeType ?? 0;
floatEvent.MarkClosePnl = 0;
floatEvent.PayDirection = position.PosiDirection;
floatEvent.PosiGrossPrice = position.PosiGrossPrice;
floatEvent.PosiNetPrice = position.PosiNetPrice;
floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice;
floatEvent.PositionType = position.PositionType;
floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice;
floatEvent.Quantity = position.PosiQuantity;
floatEvent.PositionQty = 0;
floatEvent.ContractSize = position.ContractSize;
floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize;
var ratio = -DirectionRatio.ReceivePay(position.PosiDirection);
floatEvent.TradingFeePending = TradingFeeCalc.CalcInitTradingFeePending(oriPosition, position, unwindData);
floatEvent.DataState = (int)SwapFlowDateStateEnum.完成;
floatEvent.InterestMode = position.InterestMode;
floatEvent.ClientId = td.ClientId;
floatEvent.SetOpt(UserInfo);
}
unwindData.FlowEvents.Add(floatEvent);
}
return unwindData;
}
/// <summary>
/// 校验上日是否收盘
/// </summary>
/// <param name="tradeId"></param>
public void CheckEodTrade(int tradeId)
{
var td = DbContext.trade.Find(tradeId);
var dealDate = valuedateBLL.ValueDate <= td.ExerciseDate.Value ? valuedateBLL.ValueDate : td.ExerciseDate.Value;
//CheckLastEod(dealDate, td.StartDate.Value, tradeId);
}
/// <summary>
/// 校验收益结算操作(不检查收盘限制)
/// </summary>
/// <param name="tradeId"></param>
public void CheckEodTradeForIncome(int tradeId)
{
var td = DbContext.trade.Find(tradeId);
// 收益结算不检查收盘限制,只检查交易状态
if (td.TradeType != "收益互换")
{
throw new ServiceException("该交易不是收益互换类型");
}
if (td.ValidState == "InValid")
{
throw new ServiceException("该交易已无效");
}
if (td.TradeStatus != ConsTrade.确认成交 && td.TradeStatus != ConsTrade.提前终止拒绝)
{
throw new ServiceException($"该交易状态为【{td.TradeStatus}】,无法进行收益结算");
}
}
/// <summary>
/// 多空组合 平仓初始化
/// </summary>
/// <param name="tradeId"></param>
/// <returns></returns>
/// <exception cref="ServiceException"></exception>
/// <summary>
/// 平仓初始化
/// </summary>
/// <param name="tradeId"></param>
/// <returns></returns>
/// <exception cref="ServiceException"></exception>
public UnwindData InitIncome(int tradeId)
{
var checkEventTypes = new List<int>() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 };
var td = DbContext.trade.Find(tradeId);
var positions = DbContext.swap_position.ActiveByTrade(tradeId);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode);
List<int> eventTypes = new List<int>() { (int)SwapFlowEventTypeEnum.互换, (int)SwapFlowEventTypeEnum.自动互换 };
var maxIncomeValueDate = GetMaxIncomeValueDate(td);
var dealDate = valuedateBLL.ValueDate.Date > maxIncomeValueDate.Date ? maxIncomeValueDate : valuedateBLL.ValueDate;
// 收益结算不检查收盘限制
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
//var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期
var preDealDate = GetPreDealDate(tradeId, dealDate, eventTypes);
var hasProcess = HasTradeProcess();
swap_flow_event floatEvent = new swap_flow_event();
UnwindData unwindData = new UnwindData();
if (((valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess)) && td.TradeStatus == ConsTrade.互换待复核)
{
var swapEvent = GetSwapEvent(tradeId, (int)SwapEventTypeEnum.互换);
if (swapEvent == null)
{
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
unwindData = swapEvent.unwindData;
}
else
{
unwindData.StartDate = td.TradeDate.Value;
if (preDealDate.HasValue)
{
unwindData.StartDate = preDealDate.Value;
}
unwindData.TradeStartDate = td.StartDate;
unwindData.ValueDate = dealDate;
unwindData.UnwindDate = dealDate;
floatEvent.UnwindDate = unwindData.UnwindDate;
floatEvent.EventDate = dealDate;
unwindData.PayDate = valuedateBLL.ValueDate;
floatEvent.PayDate = unwindData.PayDate;
floatEvent.SwapTradeId = tradeId;
floatEvent.SwapTradeNo = td.TradeNumber;
floatEvent.EventType = (int)SwapFlowEventTypeEnum.互换;
floatEvent.EventReason = "交易";
unwindData.SwapTradeId = tradeId;
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
unwindData.ClosePercent = unwindData.PosiNotionalValue / unwindData.NotionalValue;
unwindData.CloseNotionalValue = unwindData.PosiNotionalValue;
if (position != null)
{
floatEvent.PositionId = position.PositionId;
// 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
// 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
Logger.Info($"[分红-收益结算] DividendIn=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
floatEvent.DividendIn = preEodDividendSum;
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
floatEvent.MarkClosePnl = 0;
floatEvent.PayDirection = position.PosiDirection;
floatEvent.PosiGrossPrice = position.PosiGrossPrice;
floatEvent.PosiNetPrice = position.PosiNetPrice;
// 注意:TradingAmountNetAvg 字段名为"成交净价(期末语义)",但收益结算/平仓初始化时装入的是期初净价(PosiNetNoFeePrice),前端展示期初净价时取此字段
floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice;
floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice;
floatEvent.PositionType = position.PositionType;
floatEvent.Quantity = position.PosiQuantity;
floatEvent.PositionQty = 0;
floatEvent.ContractSize = position.ContractSize;
floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize;
floatEvent.ClientId = td.ClientId;
floatEvent.DataState = (int)SwapFlowDateStateEnum.完成;
}
unwindData.FlowEvents.Add(floatEvent);
}
unwindData.MaxIncomeValueDate = maxIncomeValueDate;
return unwindData;
}
/// <summary>
/// 获取平仓利息端信息
/// </summary>
/// <param name="valueDate">平仓日期</param>
/// <param name="tradeId">交易id</param>
/// <param name="closePercent">平仓比例</param>
/// <returns></returns>
/// <exception cref="ServiceException"></exception>
public List<swap_flow_event> GetUnwindInterests(DateTime valueDate, DateTime unwindDate, int tradeId, decimal closePercent, int eventType, bool isPenaltyInterest = false)
{
List<swap_flow_event> interests = new List<swap_flow_event>();
if (closePercent > 1)
{
closePercent = 1;//防篡改
}
else if (closePercent < 0)
{
closePercent = 0;
}
var td = DbContext.trade.Find(tradeId);
if (td == null)
{
throw new ServiceException("未找到交易信息");
}
var allpositions = DbContext.swap_position.ActiveByTrade(tradeId).ToList();
var origPositions = allpositions.Where(x => x.IsInitial).ToList();
var realPostitions = allpositions.Where(x => !x.IsInitial).ToList();
// 根因修复(多次部分平仓预付金返还错误):见 ResolveInterestLegPositions 注释。
// 迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配)
// 仅对预付金腿以实时腿的剩余本金克隆覆盖,故此处不改任何日终匹配行为。
var positions = ResolveInterestLegPositions(origPositions, realPostitions);
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
List<int> eventTypes = new List<int>() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 };
var lastEod = DbContext.eod_swap.Where(x => x.ValueDate < unwindDate && x.SwapTradeId == tradeId).OrderByDescending(o => o.ValueDate).FirstOrDefault();
var _preSetteDate = lastEod == null ? unwindDate.AddDays(-1) : lastEod.ValueDate;
List<eod_swap_position> lastEodPositions = new SwapEodPositionService(this).GetPreEodPositions(tradeId, _preSetteDate);//上一交易数据
var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); // 当前平仓前的实时剩余本金
var posiNotionalValue = stockEqvNotional * closePercent;// 本次平仓名义本金
var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); // 上一日终的浮动端本金
var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId
&& x.UnwindDate == unwindDate
&& eventTypes.Contains(x.EventType)
&& x.DataState == (int)SwapFlowDateStateEnum.完成).ToList();
bool tdClose = closeList.Count > 0;
// 显式入口:平仓前剩余本金 + 实际平掉额 + B语义比例,盘中重放(语义见 InterestCalcRequest.IntradayUnwind
interests = GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions,
stockEqvNotional, posiNotionalValue,
closePercent, eventType, tdClose, orginPv, add: true, newCalcLast: false, closeList,
isPenaltyInterest));
return interests;
}
/// <summary>
/// 解析利息腿(PosiDirection==0)持仓,供 GetUnwindInterests 使用。抽为纯函数以便无库单测。
/// <para>根因(多次部分平仓预付金返还错误):预付金腿(初始/追加)的"当前剩余本金"存于实时持仓
/// realPositions.InterestPrincipalFix,每次平仓由 UpdateInitalPosition 递减;而原始腿
/// origPositions(IsInitial=1)的 InterestPrincipalFix 恒为初始值。GetInterests 算
/// closePrincipal = Fix × closePercent 时读 position.InterestPrincipalFix,若沿用原始腿,
/// 会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。</para>
/// <para>修复:迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配,
/// 全库实测 eod 均按 orig.id 归档;若换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿
/// Clone 覆盖其本金值为实时腿的剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。
/// 首次平仓时 orig==real 行为不变;仅在发生过部分平仓后 real≠orig 时用实时腿本金纠正。</para>
/// </summary>
/// <param name="origPositions">原始腿(IsInitial=1)全集</param>
/// <param name="realPositions">实时腿(IsInitial=0)全集,其 PositionId 指向对应 orig 的 id</param>
/// <returns>利息腿(PosiDirection==0)列表:预付金腿本金已对齐实时剩余本金,其余保持原始腿</returns>
public static List<swap_position> ResolveInterestLegPositions(List<swap_position> origPositions, List<swap_position> realPositions)
{
realPositions ??= new List<swap_position>();
return origPositions.Where(x => x.PosiDirection == 0).Select(p =>
{
if (MarginModes.Contains(p.InterestMode))
{
var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id);
if (realLeg != null && realLeg.InterestPrincipalFix != p.InterestPrincipalFix)
{
var clone = p.Clone();
clone.InterestPrincipalFix = realLeg.InterestPrincipalFix;
return clone;
}
}
return p;
}).ToList();
}
/// <summary>
/// 计算预付金腿当前真实持仓 (当前持仓+未来持仓)
/// </summary>
/// <param name="origPositions"></param>
/// <param name="realPositions"></param>
/// <param name="completedFlowEvents"></param>
/// <param name="settleDate"></param>
/// <returns></returns>
public static List<swap_position> ResolveInterestLegPositionsAsOf(
List<swap_position> origPositions, List<swap_position> realPositions,
IEnumerable<swap_flow_event> completedFlowEvents, DateTime settleDate)
{
realPositions ??= new List<swap_position>();
var futureFlows = (completedFlowEvents ?? Enumerable.Empty<swap_flow_event>())
.Where(x => x.EventType == (int)SwapEventTypeEnum.平仓 && x.EventDate > settleDate)
.ToList();
var originalNotional = origPositions.Where(x => x.PosiDirection > 0)
.Sum(x => x.PosiNotionalValue);
var futureCloseNotional = futureFlows.Where(x => x.PositionType > 0)
.Sum(x => x.TradingAmount);
var hasNotionalFlows = futureCloseNotional > 0 && originalNotional > 0;
var futureClosePrincipal = futureFlows
.Where(x => MarginModes.Contains(x.InterestMode))
.GroupBy(x => x.PositionId)
.ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal));
return origPositions.Where(x => x.PosiDirection == 0).Select(p =>
{
if (MarginModes.Contains(p.InterestMode))
{
var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id);
if (realLeg != null)
{
var futurePrincipal = hasNotionalFlows
? p.InterestPrincipalFix * futureCloseNotional / originalNotional
: futureClosePrincipal.TryGetValue(p.id, out var flowPrincipal) ? flowPrincipal : 0m;
var asOfPrincipal = realLeg.InterestPrincipalFix + futurePrincipal;
asOfPrincipal = Math.Min(p.InterestPrincipalFix, Math.Max(0m, asOfPrincipal));
if (asOfPrincipal != p.InterestPrincipalFix)
{
var clone = p.Clone();
clone.InterestPrincipalFix = asOfPrincipal;
return clone;
}
}
}
return p;
}).ToList();
}
public static decimal ResolveUnwindPreviousNotional(
eod_swap lastEod,
IEnumerable<eod_swap_position> lastEodPositions,
decimal currentNotional)
=> ClosePercentMath.ResolveUnwindPreviousNotional(lastEod, lastEodPositions, currentNotional);
/// <summary>
/// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。
/// 数据源为事件级 swap_flow_event.InterestAmount(互换/自动互换 完成态事件,互换当时即落库,不依赖日终归档)。
/// </summary>
/// <param name="tradeId">交易id</param>
/// <param name="positionId">利息腿id</param>
/// <param name="beforeDate">结算日(不含,仅汇总此日之前的历史已结利息;当日事件由 closeList 去重逻辑单独处理)</param>
/// <returns>历史已结利息累计金额(绝对值)</returns>
public virtual decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
{
List<int> swapEventTypes = new List<int>() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 };
var consumed = DbContext.swap_flow_event
.Where(x => x.SwapTradeId == tradeId && x.PositionId == positionId
&& swapEventTypes.Contains(x.EventType)
&& x.DataState == (int)SwapFlowDateStateEnum.完成
&& x.EventDate < beforeDate)
.Sum(s => (decimal?)s.InterestAmount) ?? 0m;
return consumed;
}
/// <summary>
/// 计算利息腿计息详细
/// </summary>
/// <param name="td">交易</param>
/// <param name="tradeExtend">交易扩展数据</param>
/// <param name="valueDate">操作日期</param>
/// <param name="eodPositions">上一日终持仓</param>
/// <param name="positions">期初利率端</param>
/// <param name="posiNotionalValue">持仓名义本金</param>
/// <param name="closePosiNotionalValue">平仓名义本金</param>
/// <param name="closePrecent"></param>
/// <param name="eventType"></param>
/// <param name="tdClose"></param>
/// <param name="add"></param>
/// <returns></returns>
/// <summary>
/// 【盘中平仓/互换结息】显式入口——GetInterests(settment:false) 盘中语义的具名封装(2026-08 显式化重构)。
/// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。
/// </summary>
public List<swap_flow_event> GetIntradayUnwindInterests(InterestCalcRequest req)
{
var interests = GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions,
req.PosiNotionalValue, req.ClosePosiNotionalValue,
req.ClosePercent, req.EventType, req.TdClose,
req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList);
// EQD-6977 罚息:GetInterests 返回后将罚息金额并入既有利息流的 InterestFee(其他费用含罚息)。
// 仅手动平仓(isPenaltyInterest)且事件类型为平仓时触发;互换结现路径不带罚息。
if (req.IsPenaltyInterest && req.EventType == (int)SwapEventTypeEnum.平仓)
MergePenaltyIntoFee(req, interests);
return interests;
}
/// <summary>
/// EQD-6977 罚息接缝(委托注入 + 轨迹落盘):在 GetInterests 返回后把罚息金额并入
/// 各融资腿正常平仓利息事件的 InterestFee(不产生独立罚息事件)。
/// 仅在此处耦合上帝类的利率解析(GetFixedRate / IndexFixer)与轨迹常驻落盘(SwapCalcTrace.Write),
/// 其余罚息计息数学全部下沉至 Penalty 模块,保持上帝类最小侵入。
/// </summary>
private void MergePenaltyIntoFee(InterestCalcRequest req, List<swap_flow_event> interests)
{
var fundingPositions = req.Positions.Where(p => !MarginModes.Contains(p.InterestMode)).ToList();
var annualDays = req.TradeExtend == null ? 365 : req.TradeExtend.ExtendObj.AnnualDays;
var calcLast = req.TradeExtend?.ExtendObj.CalcLast ?? true;
var trace = new AccrualTrace();
PenaltyInterestFeeMerger.Merge(
req.Td, fundingPositions, interests, req.UnwindDate, annualDays,
unwindDaySettled: calcLast || req.NewCalcLast,
maturityCalcLast: calcLast,
req.PosiNotionalValue, req.ClosePosiNotionalValue, req.ClosePercent,
getSpread: p => GetFixedRate(p, req.UnwindDate),
getPreEod: p => req.EodPositions.FirstOrDefault(x => x.PositionId == p.id),
tryGetFixing: (d, code) => IndexFixer.TryGetFixing(d, code, out decimal r) ? (decimal?)r : null,
trace: trace);
SwapCalcTrace.Write(trace); // 与既有 4 处 SwapCalcTrace.Write 同款常驻落盘
}
public List<swap_flow_event> GetInterests(
trade td,
trade_extend tradeExtend,
DateTime valueDate,
DateTime unwindDate,
List<eod_swap_position> eodPositions,
List<swap_position> positions,
decimal posiNotionalValue,
decimal closePosiNotionalValue,
decimal closePrecent,
int eventType,
bool tdClose,
decimal orginPv,
bool add = false,
bool settment = true,
bool newCalcLast= false,
List<swap_flow_event> closeList = null)
{
List<swap_flow_event> interests = new List<swap_flow_event>();
var annualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
bool calcFirst = tradeExtend?.ExtendObj.CalcFirst ?? true;
bool calcLast = tradeExtend?.ExtendObj.CalcLast ?? true;
// 计息到尾日(含平仓场景覆盖):交易本身算尾 或 本次平仓指定算尾(newCalcLast)
bool effectiveCalcLast = calcLast || newCalcLast;
foreach (var position in positions)
{
// 初始化持仓信息
var preEodPosition = eodPositions.FirstOrDefault(x => x.PositionId == position.id) ?? new eod_swap_position();
var positionClone = position.Clone();
DateTime? preDealDate = preEodPosition.id != 0 ? preEodPosition.ValueDate : null;
// 计算计息区间
int interestPeriod = position.interest_rest_days ?? 1;
// true=计息窗口为空(不计利息,利率与金额归零;典型触发=不算头首日/不算尾到期日回拨翻转,
// 判定只看日期窗口与事件类型无关;当日已结息日期相等时窗口非空,归零由下方 closeList 净额层处理)
bool interestWindowEmpty = InitInterestDate(unwindDate, preDealDate, td, tdClose, out DateTime startDate, out DateTime endDate);
// 获取利率(保证金/融资腿共用:SwapIntervalList 取当日适用固定利率 + 精度收口)
decimal rate = Math.Round(GetFixedRate(position, unwindDate), InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 做精度调整 原数据有精度误差
// ── 边界隔离:保证金腿(5/6)在循环最外层路由,后续融资腿分支树不感知保证金概念 ──
// 有意跳过 GetFloatRateCalcMarginInterest 纯固定利率(FundingLegRate.Fixed)且 FloatRate 恒 0
// 浮动取价/回写对保证金无意义;即使脏数据填了 FloatRateUnderlyingCode 且缺价,也不应阻断保证金结算。
if (MarginModes.Contains(position.InterestMode))
{
positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection);
// 保证金腿: 计息基数 = InterestPrincipalFix(保证金余额),无融资腿差分公式与 orginPv 维度 hack
interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate,
position.InterestPrincipalFix * closePrecent, position.InterestPrincipalFix,
closePrecent, annualDays, calcFirst, effectiveCalcLast, preEodPosition, eventType, add, settment, interestWindowEmpty));
continue;
}
// 计算名义本金(以下仅融资腿 1/2/9:走策略工厂)
var mode = (InterestModeEnum)position.InterestMode;
var r = FundingLegStrategyFactory.Get(mode)
.CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePrecent);
decimal closePrincipal = r.ClosePrincipal;
decimal posiPrincipal = r.PosiPrincipal;
decimal newClosePercent = r.ClosePercent;
// 根因位置:SwapEodPositionService.SaveAutoEodWithCloseInterestPosition 在平仓后收盘时传入
// “收盘后剩余本金 + closePercent=1”,与盘中“平仓前本金 + 实际关闭比例”不是同一语义。
// GetInterests 同时被盘中试算和 EOD 平仓后收盘调用:后者传入的
// posiNotionalValue 是收盘后的剩余本金,closePosiNotionalValue 才是本次实际平掉的本金。
// 例如平仓前 100、平掉 30、收盘后剩余 70 时,EOD 传入 posi=70、close=30、closePercent=1。
// 模式2(合约名义本金规模)的本次结息本金必须始终是实际平仓额,因此无条件覆盖,
// 否则会错误地用剩余 70 结算本次平掉的 30。模式9(标的期初全价)的部分平仓
// 仍保留既有的剩余/复利动态本金承接逻辑;仅最终全平时 posi=0,才覆盖以避免结息本金为 0。
if (mode == InterestModeEnum.合约名义本金规模
|| (mode == InterestModeEnum.标的期初全价
&& posiNotionalValue == 0m))
{
closePrincipal = closePosiNotionalValue;
}
decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, interestWindowEmpty, positionClone, effectiveCalcLast);
// 根据场景计算利息
if (settment)
{
// 收盘归档场景,使用 CalcEodInterest
interests.Add(CalcEodInterest(td, valueDate, positionClone, rate, floatRate, closePrincipal, posiPrincipal, annualDays, calcFirst, calcLast, preEodPosition, eventType, add));
}
else
{
// 盘中互换场景,使用 CalcUnwindInterest
// 取历史已结利息(事件级,互换当时落库),供复利重算扣除(仅复利需要;单利基于日终快照自带状态)
var consumedInterest = position.InterestType == (int)InterestTypeEnum.复利
? GetConsumedInterest(td.id, position.id, endDate)
: 0m;
interests.Add(CalcUnwindInterest(td, valueDate, endDate, positionClone, rate, floatRate, posiPrincipal,
closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, interestWindowEmpty, orginPv, calcFirst,
effectiveCalcLast, consumedInterest));
}
}
//当日有平仓或互换记录时,避免重复结算
if (closeList != null && closeList.Count > 0)
{
foreach (var item in interests)
{
var closeEvent = closeList.Where(x => x.PositionId == item.PositionId);
if (eventType == (int)SwapEventTypeEnum.互换 || eventType == (int)SwapEventTypeEnum.自动互换)
{
// 互换:该仓位当天有同类型的完成事件,直接归0
var swapCloseEvent = closeEvent.Where(x => x.EventType == eventType);
if (swapCloseEvent.Any())
{
item.InterestAmount = 0;
item.TdInterestAmount = 0;
item.InterestClosePnL = 0;
}
}
else if (!effectiveCalcLast)
{
// 平仓不算尾:扣除已结算的利息(算尾时利息已包含关闭日,无重叠)
var closePnl = closeEvent.Sum(s => s.InterestClosePnL);
var closeAmount = closeEvent.Sum(s => s.InterestAmount);
var closeTdAmount = closeEvent.Sum(s => s.TdInterestAmount);
item.InterestAmount = item.InterestAmount - closeAmount;
item.TdInterestAmount = item.TdInterestAmount - closeTdAmount;
item.InterestClosePnL = item.InterestClosePnL - closePnl;
}
}
}
return interests;
}
/// <summary>
/// 平仓比例口径转换(解决"显示占期初 / 计算占剩余"双语义问题)。
/// 前端与事件列表展示用"占期初(original)"语义(A);后端计息基数计算 / 费用递减 /
/// 全平判定均按"占剩余(remaining)"语义(B)消费。
/// A → BB = A × 期初名义本金(NotionalValue) / 剩余名义本金(PosiNotionalValue),并 cap 到 1。
/// B → A:A = B × 剩余名义本金 / 期初名义本金。
/// 分母为 0(无持仓等异常场景)时原样返回,避免除零。
/// </summary>
public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue)
=> ClosePercentMath.ToRemainingClosePercent(originalClosePercent, notionalValue, posiNotionalValue);
/// <summary>
/// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。
/// </summary>
public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue)
=> ClosePercentMath.ToOriginalClosePercent(remainingClosePercent, notionalValue, posiNotionalValue);
/// <summary>
/// 计算 InitUnwind 默认占期初(A)平仓比例 = PosiNotionalValue / NotionalValue。
/// </summary>
public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue)
=> ClosePercentMath.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue);
/// <summary>
/// 读取"上一收盘日"浮动腿的待实现分红(eod_swap_position.PosiDividendSum),
/// 用于平仓/互换预览页展示"浮动端平仓盈亏·分红(DividendIn)" 与 "待结算分红收益(DividendPending)"。
/// <para>方案C:替代前端 totalInterest × 期初持仓 的重算——后者会把登记日前已平仓、
/// 不享有该笔分红的部分重复计入(GLMS-20260105-0004 误显 -36,160)。
/// EOD 的 PosiDividendSum 已按"实际持仓递推 + 当日实现扣除"算出待实现分红,
/// 是单一可信源。</para>
/// <para>复用 GetUnwindInterests(cs:624-626) 的"上一 EOD 日期"推导:取 eod_swap 中
/// ValueDate &lt; dealDate 的最大日期,无则 dealDate.AddDays(-1);再经
/// SwapEodPositionService.GetPreEodPositions 取该日持仓,匹配 PositionId。</para>
/// <para>抽为 protected virtual:与 GetMaxIncomeValueDate 一致,便于测试替身覆写、
/// 也兼容无 EOD 的边界(返回 0,与历史 DividendIn=0 行为一致)。</para>
/// </summary>
/// <returns>上一收盘日该浮动腿的待实现分红;无 EOD 记录返回 0</returns>
/// <remarks>
/// 【口径论证·勿改】为什么 DividendPending 也用本方法的全量值(非分摊、非硬0):
/// <para>1. 字段语义直接对应:EOD PosiDividendSum 的 DisplayName="浮动端平仓盈亏·分红未实现"
/// (EodSwapPosition.cs:186),递推式 PosiDividendSum=前日+当日新计-当日实现
/// (SwapEodPositionService.cs:1825),即"扣过当日实现后、还挂在账上未来才结的存量"。
/// 前端列"待结算分红收益"(SwapflowList.js:561) 字面就是同一回事 → 直接取 PosiDividendSum。</para>
/// <para>2. 是"存量"非"流量"DividendPending 描述的是"账上还欠多少"(与本次平仓比例无关的总额),
/// 而 DividendIn 才是"本次动作落袋多少"。两者口径本就不同,各自正确。若把 DividendPending 改成
/// 按本次平仓比例分摊,会把"存量"误当"流量",与列名"待结算"矛盾。</para>
/// <para>3. 历史教训:方案C 初版曾把前端 DividendPending 硬编码 0commit e3c473ba),因测试交易
/// PosiDividendSum 恰好=03/2 已全额互换)而测试通过、掩盖问题。但对 PosiDividendSum≠0 的部分
/// 平仓交易,硬0 会落库(SwapFlowEventService.cs:588 冲账取负写入 swap_flow_event.DividendPending
/// 并在事件列表"待结算分红收益"列显示错误的 0 —— 这是确定的回归。故本方法返回值同时喂两栏,
/// 前端不得再覆盖。例外:互换页 DividendPending 保持 0(互换语义=全量结清,结清后待结算归0)。</para>
/// </remarks>
protected virtual decimal GetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
{
var preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate);
var sum = preEod == null ? 0m : preEod.PosiDividendSum;
Logger.Info($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}");
return sum;
}
/// <summary>
/// 取 dealDate 对应"上一收盘日"持仓的累计分红快照。
/// GLMS-20260105-0006:登记日当天手动平仓/互换时,当日 EOD 快照已含分红,应取到当日而非 T-1。
/// 故由 ValueDate 严格小于 dealDate 改为 小于等于:当日 EOD 存在则读当日,否则回退上一收盘日(原口径不变)。
/// </summary>
protected virtual eod_swap_position GetPreEodPositionByDate(int tradeId, long positionId, DateTime dealDate)
{
var lastEod = QueryPreEodSwaps(tradeId)
.Where(x => x.ValueDate <= dealDate)
.OrderByDescending(o => o.ValueDate).FirstOrDefault();
var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate;
Logger.Info($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}");
return QueryPreEodPosition(tradeId, positionId, preEodDate);
}
/// <summary>可测性 seam:返回某交易的全部 eod_swap 行(不做日期过滤)。测试可 override 注入内存数据。</summary>
protected virtual IQueryable<eod_swap> QueryPreEodSwaps(int tradeId)
=> DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId);
/// <summary>可测性 seam:取指定收盘日的持仓累计分红快照。测试可 override 注入内存数据。</summary>
protected virtual eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate)
=> new SwapEodPositionService(this)
.GetPreEodPositions(tradeId, valueDate)
.FirstOrDefault(x => x.PositionId == positionId);
/// <summary>
/// 获取固定利率
/// </summary>
private decimal GetFixedRate(swap_position position, DateTime startDate)
{
var swapIntervalToday = position.SwapIntervalList?.Where(x => x.Date <= startDate).OrderByDescending(o => o.Date).FirstOrDefault();
if (swapIntervalToday != null) return swapIntervalToday.Rate;
var nextInterval = position.SwapIntervalList?.Where(x => x.Date > startDate).OrderBy(o => o.Date).FirstOrDefault();
return nextInterval?.Rate ?? position.InterestRateDefault;
}
/// <summary>
/// 重置日判定:自锚点日起每 period 天一遇,锚点当日即首个重置日((日-锚点)%period==0)。
/// EQD-6968 取价依赖三判定之一。锚点口径注记:GetFloatRate 传交易起始日 td.StartDate
/// 分段重放/当日归周期判定传 position.PosiStartDate——非初始持仓(部分平仓剩余仓)两锚点可能不同,
/// 本方法只收口公式、不统一锚点(统一属行为变更,需业务定调)。
/// SwapEodPositionService 的"持仓延续腿重置日再定盘"亦用本判定(td.StartDate 锚点)。
/// </summary>
internal static bool IsResetDay(DateTime date, DateTime anchorDate, int period)
=> (date - anchorDate).Days % period == 0;
/// <summary>
/// 获取浮动利率
/// </summary>
private decimal GetFloatRate(swap_position position, eod_swap_position preEod, DateTime startDate, DateTime endDate, int period, bool interestWindowEmpty, swap_position positionClone, bool calcLast = true)
{
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return position.FloatRate;
int days = (endDate - startDate).Days;
bool isResetDay = IsResetDay(endDate, startDate, period);
// 重置日恰为到期日(endDate)时,取价日=endDate;否则=startDate(原逻辑)。
DateTime rateDate = IndexFixerBase.GetFixingDate(
isResetDay ? endDate : startDate, position.interest_rule);
// 历史上有"取错重置日利率"的线上 bug,取价决策必须常驻落盘(SwapCalcTrace.Critical 无条件 Info)。
SwapCalcTrace.Critical(
$"FIX GetFloatRate p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " +
$"重置日={isResetDay} rule={position.interest_rule} 取价日={rateDate:yyyy-MM-dd} calcLast={calcLast} " +
$"preEod={(preEod.id != 0 ? $"{preEod.ValueDate:yyyy-MM-dd}:{preEod.FloatRate:P6}" : "")}");
if (preEod.id != 0 && !isResetDay)
{
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}");
position.FloatRate = positionClone.FloatRate = preEod.FloatRate;
return preEod.FloatRate;
}
// EQD-6968 口径自洽化:不算尾(calcLast=false)时 endDate 当天不计息,其定盘一概不取
// (有价也不取)——事件/回写利率与金额同源(末段已消费利率),杜绝"上午/下午落库利率不同"。
// 剩余持仓的新周期利率由 SwapEodPositionService 的"重置日再定盘"显式获取,不靠此处顺带。
if (!interestWindowEmpty && !calcLast && isResetDay)
{
var keptNoFetch = preEod.id != 0 ? preEod.FloatRate : position.FloatRate;
SwapCalcTrace.Critical(
$"FIX GetFloatRate p{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})");
return keptNoFetch;
}
if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate))
{
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}");
position.FloatRate = positionClone.FloatRate = rate;
return position.FloatRate;
}
if (!interestWindowEmpty)
{
if (calcLast)
{
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截");
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格");
}
// 算头不算尾(calcLast=false)endDate 当天不计息,其 FR007 利率不参与计息,
// 缺价时直接沿用已有利率,不回退取其他日期利率,不告警。
var kept = preEod.id != 0 ? preEod.FloatRate : position.FloatRate;
SwapCalcTrace.Critical(
$"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})");
return kept;
}
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 计息窗口为空→利率不参与,返回0");
return 0m;
}
/// <summary>
/// 计算收盘利息(EOD
/// </summary>
private swap_flow_event CalcEodInterest(trade td, DateTime valueDate, swap_position position, decimal rate, decimal floatRate, decimal closePrincipal, decimal posiPrincipal, int annualDays, bool calcFirst, bool calcLast, eod_swap_position preEod, int eventType, bool add)
{
// 判断当日是否计息:首日不算头或到期日不算尾则不计息
bool calcToday = true;
if (calcFirst == false && valueDate == td.StartDate.Value) calcToday = false; // 首日不算头
if (calcLast == false && valueDate == td.ExerciseDate.Value) calcToday = false; // 到期日不算尾
if (valueDate < position.PosiStartDate)
{
calcToday = false;
}
// 初始化EOD持仓信息
if (preEod.id == 0)
{
preEod.FloatRate = floatRate;
preEod.TdInterestPrincipal = posiPrincipal;
preEod.PosiNotionalValue = posiPrincipal;
}
// 构建利息事件
var interest = new swap_flow_event
{
SwapTradeId = td.id,
SwapTradeNo = td.TradeNumber,
EventType = eventType,
EventReason = "交易",
EventDate = valueDate,
PositionId = position.id,
InterestDirection = position.InterestDirection,
InterestRate = rate,
InterestPrincipal = closePrincipal,
InterestSwapInterval = position.InterestSwapInterval,
InterestMode = position.InterestMode,
FloatRate = floatRate,
DataState = (int)SwapFlowDateStateEnum.完成,
ClientId = td.ClientId,
UnwindDate = valueDate
};
// 收盘场景使用 preEod.FloatRate(历史浮动利率),与 InitSwapDealInterest 收盘场景保持一致
decimal eodFloatRate = preEod.id != 0 ? preEod.FloatRate : floatRate;
decimal interestAmount = 0;
decimal tdInterestAmount = 0;
if (calcToday)
{
if (position.InterestType == (int)InterestTypeEnum.复利)
{
// 复利计算
CalcDailyCompoundInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount);
}
else
{
// 单利计算
CalcDailySimpleInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount);
}
}
// 四舍五入并赋值
interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
// 计算InterestClosePnL(方向:收取=1为正,支付=-1为负)
var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection);
interest.InterestClosePnL = interest.InterestAmount * interestRatio;
if (add) UpdateDbOption(interest);
return interest;
}
/// <summary>
/// 保证金腿(InterestMode 5/6)专属计息——替代 CalcEodInterest/CalcUnwindInterest 对保证金的处理。
///
/// 保证金是纯固定利率单利:浮动利率(FR007)/分段利率/复利对其均为死分支(前端无入口、
/// 确认书不含、FundingLegRate.Build 对空 FloatRateUnderlyingCode 恒返回 Fixed)。故本方法直接用
/// SimpleInterestAccrual 纯函数计息,本金取保证金余额:
/// EOD = 昨日终本金 preEod.TdInterestPrincipal(与旧 CalcDailySimpleInterestByEod 同源,无差分)
/// 盘中 = accrualBasispreEod.TdInterestPrincipal + posiPrincipal - orginPv
/// 盘中保留差分是必要的:posiPrincipal 是否经 ResolveInterestLegPositions 对齐到实时剩余是路径相关的
/// (生产对齐 / 诊断测试用原始腿),单一本金变量无法覆盖两种状态,差分经 orginPv 自适应。orginPv 在
/// 本方法内部按保证金维度计算(PreviousBalance),消除原 InitSwapDealInterest 的外部维度 hack
/// (融资腿 orginPv=浮动端名义本金)。保留累计语义(priorAccrued + 增量),满足下游字段契约。
/// </summary>
/// <remarks>
/// 前提(由前端保证金表单 + SwapTradeService 构造保证):
/// 1. <b>InterestType=单利</b>。本方法恒走 SimpleInterestAccrual 单利,不查 InterestType
/// 若库内 InterestMode=5/6 且 InterestType=复利(脏数据),会与旧 CalcEodInterest 复利分支不一致。
/// 2. <b>rate 由 GetFixedRate 提供</b>SwapDealService.cs:866)——从 SwapIntervalList 取 <c>Date ≤ unwindDate</c> 最近段的 Rate
/// 空表/单段时返回 InterestRateDefault。SwapIntervalList 是"互换观察日排期"(阶梯利率表 + 结息日历,非 FR007 浮动——
/// 浮动由 FloatRateUnderlyingCode + interest_rest_days 独立驱动);保证金前端亦开放"设置观察日"分段录入。
/// 盘中用该 rate 覆盖全程,与旧 CalcDailySimpleInterest 完全一致(BuildSegmentRates 的 spread 同样是 GetFixedRate 单一值全程,
/// 不按 SwapIntervalList 切段)——SwapIntervalList 阶梯利率在盘中半路变更的精细处理是既有未覆盖口径,非本次引入;
/// EOD 路径因每日重取 GetFixedRate(valueDate) 故能正确反映阶梯。
/// 契约与副作用:
/// 3. <b>position.InterestDirection 须已由调用方翻转</b>GetInterests:742 FlipDirection);本方法不翻转。
/// 4. <b>preEod 在 id==0 时被就地修改</b>(设 TdInterestPrincipal/PosiNotionalValue/FloatRate),与旧 CalcEodInterest 一致。
/// 定位:SwapCalcTrace 落盘 AccrueEod/AccrualPeriod 的 notional/days/rate/accrued;盘中 accrualBasis 可从 trace 的 notional 反推。
/// </remarks>
/// <param name="settment">true=收盘归档(EOD)false=盘中平仓/互换。</param>
/// <param name="interestWindowEmpty">计息窗口为空(仅盘中生效,true 时利息归零,同 InitSwapDealInterest;典型场景=互换当日已结息)。</param>
public swap_flow_event CalcMarginInterest(
trade td, DateTime valueDate, DateTime endDate, swap_position position, decimal rate,
decimal closePrincipal, decimal posiPrincipal, decimal closePercent,
int annualDays, bool calcFirst, bool calcLast,
eod_swap_position preEod, int eventType, bool add, bool settment, bool interestWindowEmpty)
{
// 当日是否计息(算头算尾)——同 CalcEodInterest
bool calcToday = true;
if (!calcFirst && valueDate == td.StartDate.Value) calcToday = false;
if (!calcLast && valueDate == td.ExerciseDate.Value) calcToday = false;
if (valueDate < position.PosiStartDate) calcToday = false;
// 首日初始化 preEod——同 CalcEodInterest
if (preEod.id == 0)
{
preEod.FloatRate = 0m;
preEod.TdInterestPrincipal = posiPrincipal;
preEod.PosiNotionalValue = posiPrincipal;
}
// 字段映射(保证金 FloatRate 恒 0;方向 position.InterestDirection 已由 GetInterests 翻转)
var interest = new swap_flow_event
{
SwapTradeId = td.id,
SwapTradeNo = td.TradeNumber,
EventType = eventType,
EventReason = "交易",
EventDate = valueDate,
PositionId = position.id,
InterestDirection = position.InterestDirection,
InterestRate = rate,
InterestPrincipal = closePrincipal,
InterestSwapInterval = position.InterestSwapInterval,
InterestMode = position.InterestMode,
FloatRate = 0m,
DataState = (int)SwapFlowDateStateEnum.完成,
ClientId = td.ClientId,
UnwindDate = settment ? valueDate : endDate
};
// 计息窗口为空:利息归零(同 InitSwapDealInterest;典型场景=互换事件)
if (interestWindowEmpty && !settment)
{
interest.InterestAmount = 0m;
interest.TdInterestAmount = 0m;
interest.InterestClosePnL = 0m;
if (add) UpdateDbOption(interest);
return interest;
}
decimal interestAmount = 0m;
decimal tdInterestAmount = 0m;
var legRate = FundingLegRate.Fixed(rate); // 保证金纯固定(无浮动)
if (calcToday)
{
if (settment)
{
// EOD:单日增量,累计 = 昨日累计 + 今日增量;notional = 昨日终本金(无差分)
var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound: false);
var r = SimpleInterestAccrual.AccrueEod(
priorAccrued: preEod.InterestProfitSum,
priorNotional: preEod.TdInterestPrincipal,
unwindFraction: 1m,
rate: legRate, policy: policy, eodDate: valueDate);
interestAmount = r.Accrued;
tdInterestAmount = r.AccruedToday;
}
else
{
// 盘中:accrualBasis 自适应"实时剩余本金"——posiPrincipal 已对齐(ResolveInterestLegPositions)
// 时 = posiPrincipal;未对齐的原始腿经 orginPv(=PreviousBalance 昨日终) 修正回昨日终剩余。
// 单一本金变量无法覆盖两种 position 状态,故保留差分(与 EOD 直接用 preEod.TdInterestPrincipal 不同)。
// orginPv 在此内部按保证金维度计算,消除原 InitSwapDealInterest 的外部维度 hack。
var orginPv = MarginCalc.PreviousBalance(preEod, posiPrincipal);
var accrualBasis = preEod.TdInterestPrincipal + posiPrincipal - orginPv;
var segmentRates = new List<(DateTime, decimal)> { (position.PosiStartDate, rate) };
var r = SimpleInterestAccrual.AccruePeriod(
priorAccrued: preEod.InterestProfitSum * closePercent,
notional: accrualBasis,
unwindFraction: closePercent,
segmentRates: segmentRates,
startDate: position.PosiStartDate,
endDate: endDate,
priorValueDate: preEod.ValueDate,
boundary: AccrualBoundary.Of(calcFirst, calcLast),
annualDays: annualDays,
isAnnualized: position.IsAnnualized);
interestAmount = r.Accrued;
tdInterestAmount = r.AccruedToday;
interest.InterestPrincipal = accrualBasis * closePercent; // 同 CalcDailySimpleInterest:1304
}
}
interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection);
interest.InterestClosePnL = interest.InterestAmount * interestRatio;
if (add) UpdateDbOption(interest);
return interest;
}
/// <summary>
/// 计算盘中利息(平仓/互换)
/// </summary>
private swap_flow_event CalcUnwindInterest(trade td, DateTime valueDate, DateTime endDate, swap_position position, decimal rate, decimal floatRate, decimal posiPrincipal, decimal closePrincipal, decimal closePercent, int annualDays, eod_swap_position preEod, int eventType, bool add, bool interestWindowEmpty, decimal orginPv, bool calcFirst, bool calcLast, decimal consumedInterest = 0m)
{
if (preEod.id == 0)
{
preEod.FloatRate = floatRate;
preEod.TdInterestPrincipal = posiPrincipal;
preEod.PosiNotionalValue = posiPrincipal;
// priorValueDate 恒取 开始日-1:首个重置日(=开始日)的定盘覆盖 [开始日,下一重置日) 全部计息日
//(不算头时 7/7 起的计息日仍属首段),必须落在取价窗内。原实现仅算头回拨一天,
// 不算头时 fetchAfter=开始日 会跳过首重置日取价、首段误用种子利率
//(历史上靠 GetFloatRate 尾日取价回填种子掩盖;EQD-6968 自洽化后暴露并根治)。
// "不算头少计一天"由计息边界 IncludeStart=false 承担,与此处无关。
preEod.ValueDate = td.StartDate.Value.AddDays(-1);
}
return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, interestWindowEmpty, posiPrincipal,
closePrincipal, closePercent, annualDays, eventType, preEod,
orginPv, calcFirst, calcLast, consumedInterest);
}
/// <summary>
/// 写入保证金的资金记录:应付预付金(SwapMarginAmount)和预付金返息(SwapMarginRebatePnl)。
/// 依赖实例方法 AddClientCash/AddClientCashInCashOut,暂留此处。
/// </summary>
private void RecordMarginCashFlow(trade td, UnwindData unwindData)
=> RecordMarginCashFlow(td, unwindData.ValueDate,
unwindData.SwapMarginAmount, unwindData.SwapMarginRebatePnl,
AddClientCashInCashOut);
/// <summary>
/// 写入保证金资金记录的通用重载,接受资金写入委托。
/// AddClientCash(virtual,测试可stub) 和 AddClientCashInCashOut(非virtual,直接写库)
/// 都可通过此重载统一。
/// </summary>
private void RecordMarginCashFlow(trade td, DateTime valueDate,
decimal marginAmount, decimal marginRebate,
Func<trade, double, string, DateTime, int> writeCash)
{
if (marginAmount != 0)
writeCash(td, Convert.ToDouble(marginAmount), ClientCashInCashOut.系统操作_应付预付金, valueDate);
if (marginRebate != 0)
writeCash(td, Convert.ToDouble(-marginRebate), ClientCashInCashOut.系统操作_预付金返息, valueDate);
}
/// <summary>
/// 初始化利息腿信息
/// </summary>
/// <param name="tradeId">交易编码</param>
/// <param name="valueDate">计息开始日期</param>
/// <param name="endDate">计息结束日期</param>
/// <param name="rate">计息年化利率</param>
/// <param name="position">利息腿</param>
/// <param name="add">是否新增</param>
/// <param name="interestWindowEmpty">计息窗口为空(InitInterestDate 判定:true=本次不计利息,利率与金额归零;典型场景=互换当日已结息)</param>
/// <param name="preEodPosition">上一日终归档</param>
/// <param name="posiNotionalValue">当日适用名义本金</param>
/// <param name="closePosiNotionalValue">当日平仓名义本金</param>
/// <param name="annualDays">年化天数</param>
/// <returns></returns>
private swap_flow_event InitSwapDealInterest(trade td,
DateTime valueDate,
DateTime endDate,
decimal rate,
swap_position position,
bool add,
bool interestWindowEmpty,
decimal posiNotionalValue,
decimal closePosiNotionalValue,
decimal closePrecent,
int annualDays,
int eventType,
eod_swap_position preEodPosition,
decimal orginPv,
bool calcFirst,
bool calcLast,
decimal consumedInterest = 0m
)
{
decimal interestProfitSum = preEodPosition.InterestProfitSum;
swap_flow_event interest = new swap_flow_event();
interest.SwapTradeId = td.id;
interest.SwapTradeNo = td.TradeNumber;
interest.EventType = eventType;
interest.EventReason = "交易";
interest.EventDate = valueDate;
interest.PositionId = position.id;
interest.InterestDirection = position.InterestDirection;
interest.InterestRate = rate;
interest.InterestPrincipal = closePosiNotionalValue;
interest.InterestSwapInterval = position.InterestSwapInterval;
interest.InterestMode = position.InterestMode;
interest.FloatRate = position.FloatRate;
interest.DataState = (int)SwapFlowDateStateEnum.完成;
interest.ClientId = td.ClientId;
interest.UnwindDate = endDate;
// 保证金腿已走 CalcMarginInterest(不经过本方法),orginPv 维度重映射不再需要;
// orginPv 此处仅对融资腿生效(差分公式 accrualBasis = TdInterestPrincipal + posiPrincipal - orginPv)。
if (interestWindowEmpty)
{
interest.InterestAmount = 0; // 利息金额
interest.TdInterestAmount = 0; // 当日新增利息
interest.InterestAmount = 0;
interest.InterestClosePnL = 0; // 利息端平仓盈亏
}
else
{
decimal InterestAmount = 0;
decimal TdInterestAmount = 0;
var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection);
var floateRate = preEodPosition.FloatRate;
if (position.InterestType == (int)InterestTypeEnum.复利)
{
var daysFromPreEod = preEodPosition.id != 0
? (endDate - preEodPosition.ValueDate).Days
: 0;
// 不算尾 + 当日即新周期首日 + 未到重置日 ==> 说明这一天应归入下一个计息周期 当天无需单独计息
if (!calcLast && daysFromPreEod == 1
&& !IsResetDay(endDate, position.PosiStartDate, position.interest_rest_days ?? 1))
{
interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent; // 计息基数
interest.FloatRate = preEodPosition.FloatRate;
InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; // 利息金额 = 待实现 * 平仓比例
TdInterestAmount = preEodPosition.InterestIncomeSum; // 当日新增利息
interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.InterestClosePnL = interest.InterestAmount * interestRatio; // 利息端平仓盈亏 = 利息金额 * 方向
return interest;
}
// remainingPercent 只用于把上一日待实现分配给本次计算对应的本金。
// 按照利息腿的实际 计息基数 重新计算一个历史待实现利息的 平仓比例。不替代全局的平仓比例
// 部分平仓计算关闭 30% 时取 30%;最终全平剩余仓位时取 100%。
var remainingPercent = preEodPosition.TdInterestPrincipal > 0m
? closePosiNotionalValue / preEodPosition.TdInterestPrincipal
: 1m;
remainingPercent = Math.Max(0m, Math.Min(1m, remainingPercent));
// resetCarryInterest 是重置日并入复利本金的历史待实现,不是当天新增利息。
// 把上日尚未实现的的利息 按本次平掉的这部分计息基数分给本次平仓 并在重置日并入计息基数
// 它只在当前 endDate 恰好为重置日时使用,避免把同一笔历史利息重复资本化。
var resetCarryInterest = preEodPosition.InterestIncomeSum * remainingPercent;
CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays,
floateRate, closePrecent, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount,
consumedInterest, resetCarryInterest);
if (preEodPosition.id != 0 && closePrecent == 1m)
{
// 【全平专属分支触发标记】(快速定位):设计意图=真全平(尾差一次带走)与观察日恒1全额结息。
// 普通部分平仓经 EOD 恒1惯例也会进入本分支(重算中间值不进结算现金流);本行日志用于监控进入者分布。
Logger.Info($"[利息-全平专属分支] tradeId={td.id} posiId={position.id} valueDate={valueDate:yyyy-MM-dd} " +
$"closePrecent={closePrecent} preEod.InterestIncomeSum={preEodPosition.InterestIncomeSum}");
// 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。
// InterestAmount 是本次最终应结金额;TdInterestAmount 是不按关闭比例缩放的参考累计值。
// 二者在全平时都以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走。
var interestAtEnd = new swap_flow_event { InterestRate = rate };
decimal amountAtEnd = 0m;
decimal tdAmountAtEnd = 0m;
// InitInterestDate 在最终日不算尾时会先把 endDate 回拨一天;
// 历史差分的 amountAtEnd 需补回该日,但计算器仍使用交易 calcLast,
// 并将重放日期限制在合约到期日,避免提前全平或超期重复计息。
// 如果算尾 重放日 = 正常到期日
// 不算尾 且未超过到期日 重放日 = endDate+1 (补齐不算尾那天漏计的利息)
// 加1天超过到期日 截断到到期日
var replayEndDate = endDate;
if (!calcLast && endDate < valueDate)
{
replayEndDate = endDate.AddDays(1);
if (replayEndDate > td.ExerciseDate.Value)
{
replayEndDate = td.ExerciseDate.Value;
}
}
// 计算截至本次平仓日的累计利息 amountAtEnd
CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue,
interestAtEnd, annualDays, floateRate, closePrecent,
calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest, exclusionStart: endDate);
var interestAtPreviousEod = new swap_flow_event { InterestRate = rate };
decimal amountAtPreviousEod = 0m;
decimal tdAmountAtPreviousEod = 0m;
// 最终日重放仍遵守交易的 calcLast;上一日终是历史截点而非合约尾日,
// 因此此处按闭区间包含上一日终当天,避免算头不算尾时重复加入该日利息。
// 计算截至上一日终累积的利息 amountAtPreviousEod
CalcDailyCompoundInterest(preEodPosition.ValueDate, position, closePosiNotionalValue,
interestAtPreviousEod, annualDays, floateRate, closePrecent,
calcFirst, true, ref amountAtPreviousEod, ref tdAmountAtPreviousEod, consumedInterest);
// 例如 00045/18 待实现 -118631.261797,加 5/19 新增约 -4648.912760
// 得到最终应结 -123280.174557,按金额两位落为 Excel BN 的 -123280.17。
// 上一日终已保存的待实现利息 + 截至平仓日累计利息 - 截至上一日终累计利息
// 这样只带走“上一日终以后新增的利息”,同时保留历史部分平仓时因两位金额结算留下的尾差,最终全平一次性结清。
InterestAmount = preEodPosition.InterestIncomeSum + amountAtEnd - amountAtPreviousEod;
TdInterestAmount = preEodPosition.InterestIncomeSum + tdAmountAtEnd - tdAmountAtPreviousEod;
}
}
else
{
CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount);
}
interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.InterestClosePnL = interest.InterestAmount * interestRatio;
}
if (add)
{
UpdateDbOption(interest);
}
return interest;
}
/// <summary>
/// 按 interest_rule 取 FR007 定盘价。无浮动标的时返回 fallback;取不到抛异常。
/// EOD 单日取率 + BuildSegmentRates 多日取率共用此方法,FR007 定盘逻辑收口到一处。
/// </summary>
private decimal ResolveFloatRate(swap_position position, DateTime date, decimal fallback)
{
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return fallback;
var fixingDate = IndexFixerBase.GetFixingDate(date, position.interest_rule);
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing))
{
if (fixing != 0m)
{
SwapCalcTrace.Critical(
$"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}");
return fixing;
}
SwapCalcTrace.Critical(
$"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}");
return fallback;
}
SwapCalcTrace.Critical(
$"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常");
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格");
}
/// <summary>
/// 按重置周期切分利率段,每段记录 all-in 利率(spread+fixing)。返回 (分段列表, 末段浮动利率)。
/// fetchAfterDate: 仅该日期之后的重置日才取 FR007(单利传 ValueDate,复利传 null 全程取)。
/// exclusionStart: 排除区间起点——该日期起(含)的重置日视为"排除日"(不算尾的不计息边界日),
/// 一概不取价;缺省=endDate。仅不算尾(calcLast=false)生效;算尾所有重置日照常强制取价。
/// </summary>
private (List<(DateTime StartDate, decimal Rate)> Segments, decimal LastFloat) BuildSegmentRates(
DateTime startDate, DateTime endDate, int interestPeriod,
swap_position position, decimal spread, decimal initialFloat,
DateTime? fetchAfterDate, bool calcLast = true, DateTime? exclusionStart = null)
{
var rates = new List<(DateTime, decimal)>();
var calcDays = (endDate - startDate).Days;
decimal currentFloat = initialFloat;
SwapCalcTrace.Critical(
$"FIX Segments p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " +
$"fetchAfter={(fetchAfterDate?.ToString("yyyy-MM-dd") ?? "全程")} calcLast={calcLast} " +
$"排除起点={(exclusionStart?.ToString("yyyy-MM-dd") ?? (calcLast ? "" : endDate.ToString("yyyy-MM-dd")))} seed={initialFloat:P6} spread={spread:P6}");
for (int i = 0; i <= calcDays; i += interestPeriod)
{
var resetDate = startDate.AddDays(i);
bool needFetch = (fetchAfterDate == null || resetDate > fetchAfterDate.Value);
// 算头不算尾(calcLast=false)时,endDate 当天不计息,其重置日利率不参与计息——
// 排除日(EQD-6968 自洽化)一概不取价(有价也不取),currentFloat 保持末段已消费利率:
// 事件/快照回写的浮动利率与金额同源、与平仓时刻无关。剩余持仓的新周期利率由
// SwapEodPositionService 的"重置日再定盘"显式获取,不靠排除日顺带。算尾照常强制取价。
bool isExcludedEnd = !calcLast && resetDate >= (exclusionStart ?? endDate);
if (needFetch && !isExcludedEnd)
{
currentFloat = ResolveFloatRate(position, resetDate, currentFloat);
}
else if (needFetch && isExcludedEnd)
{
SwapCalcTrace.Critical(
$"FIX Segment p{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}");
}
rates.Add((resetDate, spread + currentFloat));
}
return (rates, currentFloat);
}
/// <summary>
/// 计算复利 盘中
/// </summary>
/// <param name="lastSwapDate">上一互换日</param>
/// <param name="endDate">结算日期</param>
/// <param name="floatUnderylingCode">浮动标的</param>
/// <param name="principal">计息基数</param>
/// <param name="interestRate">固定利率</param>
/// <param name="isAnnualized">是否年化</param>
/// <param name="annualDays">年化天数</param>
/// <returns></returns>
public void CalcDailyCompoundInterest(DateTime endDate, swap_position position, decimal principal, swap_flow_event flowEvent,
int annualDays, decimal floateRate, decimal closePercent, bool calcFirst, bool calcLast,
ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m, DateTime? exclusionStart = null)
{
var startDate = position.PosiStartDate;
int interestPeriod = position.interest_rest_days ?? 1;
// 分段取率:复利全程重放,每个重置日(含 startDate)取 FR007fetchAfterDate=null)。
// calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移;
// exclusionStart 仅重放补计不算尾漏计利息时非 null(=真实平仓日),锚定尾日跳过取价起点。
var (segmentRates, currentFloat) = BuildSegmentRates(
startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate,
fetchAfterDate: null, calcLast: calcLast, exclusionStart: exclusionStart);
// 纯函数复利计息:分段重置日并本金 + resetCarryInterest + 扣 consumedInterest
var interestTrace = new AccrualTrace();
var result = CompoundInterestAccrual.AccruePeriod(
notional: principal,
segmentRates: segmentRates,
startDate: startDate,
endDate: endDate,
boundary: AccrualBoundary.Of(calcFirst, calcLast),
annualDays: annualDays,
isAnnualized: position.IsAnnualized,
resetCarryInterest: resetCarryInterest,
realizedInterest: consumedInterest,
unwindFraction: closePercent,
finalBasis: out var finalBasis,
trace: interestTrace);
SwapCalcTrace.Write(interestTrace);
// flowEvent 副作用:FloatRate=末段浮动利率;InterestPrincipal=复利终期本金(最后一次并本金后的基数)。
flowEvent.FloatRate = currentFloat;
flowEvent.InterestPrincipal = finalBasis;
InterestAmount = result.Accrued;
TdInterestAmount = result.AccruedToday;
}
/// <summary>
/// 计算单利 盘中(按重置天数分段,每段使用对应浮动利率)
/// </summary>
public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, DateTime? exclusionStart = null)
{
var startDate = position.PosiStartDate;
int interestPeriod = position.interest_rest_days ?? 1;
// orginPv 是路径相关参考本金(资金腿=上一日终浮动端名义本金)。保证金腿已走 CalcMarginInterest,不经此方法。
// 单利差分:accrualBasis 全程恒定 = 昨日终滚动基数 + 当日名义本金 - 参考本金。
var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv;
// 分段取率:仅 ValueDate 之后的重置日才取 FR007fetchAfterDate=ValueDate)。
// calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移。
var (segmentRates, currentFloat) = BuildSegmentRates(
startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate,
fetchAfterDate: preEodPosition.ValueDate, calcLast: calcLast, exclusionStart: exclusionStart);
// 纯函数计息:Accrued=缩放累计(InterestAmount)AccruedToday=未缩放累计(TdInterestAmount)
var interestTrace = new AccrualTrace();
var result = SimpleInterestAccrual.AccruePeriod(
priorAccrued: preEodPosition.InterestProfitSum * closePercent,
notional: accrualBasis,
unwindFraction: closePercent,
segmentRates: segmentRates,
startDate: startDate,
endDate: endDate,
priorValueDate: preEodPosition.ValueDate,
boundary: AccrualBoundary.Of(calcFirst, calcLast),
annualDays: annualDays,
isAnnualized: position.IsAnnualized,
trace: interestTrace);
SwapCalcTrace.Write(interestTrace);
// flowEvent 副作用(下游 EOD 用 InterestPrincipal 播种次日 TdInterestPrincipal
flowEvent.InterestPrincipal = accrualBasis * closePercent;
flowEvent.FloatRate = currentFloat;
InterestAmount = result.Accrued;
TdInterestAmount = result.AccruedToday;
}
/// <summary>
/// 计算复利 收盘
/// </summary>
/// <param name="lastSwapDate">上一互换日</param>
/// <param name="endDate">结算日期</param>
/// <param name="tradeDate">开仓日</param>
/// <param name="floatUnderylingCode">浮动标的</param>
/// <param name="principal">计息基数</param>
/// <param name="interestRate">固定利率</param>
/// <param name="isAnnualized">是否年化</param>
/// <param name="annualDays">年化天数</param>
/// <returns></returns>
public void CalcDailyCompoundInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount)
{
int interestPeriod = position.interest_rest_days ?? 1;
var isResetDay = (endDate - tradeDate).Days % interestPeriod == 0;
// 重置日按 interest_rule 重新定盘浮动利率(GLMS-JIATT-20260805 根因——
// 重置日=平仓日必须用新利率,否则沿用旧周期利率并污染后续 EOD)。非重置日沿用 floateRate。
var effectiveFloat = isResetDay ? ResolveFloatRate(position, endDate, floateRate) : floateRate;
flowEvent.FloatRate = effectiveFloat;
// remainingFraction:重置日把上一日终待实现利息按本次平仓基数分摊(EOD 全量为 1)。
var remainingFraction = posiPrincipal > 0m
? Math.Max(0m, Math.Min(1m, principal / posiPrincipal))
: 1m;
// 纯数学下沉至 CompoundInterestAccrual.AccrueEodDDD 命名 + 末位生产精度 12 舍入)。
var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat);
var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: true);
// 完整计息 trace:前后日期/基数/利率/重置标志全过程,经 SwapCalcTrace 常驻落盘(关键路径日志)。
var interestTrace = new AccrualTrace();
var result = CompoundInterestAccrual.AccrueEod(
priorAccrued: preEodPosition.InterestProfitSum,
priorNotional: preEodPosition.TdInterestPrincipal,
notional: posiPrincipal,
unwindFraction: closePercent,
rate: legRate,
policy: accrualPolicy,
isResetDay: isResetDay,
remainingFraction: remainingFraction,
eodDate: endDate,
trace: interestTrace);
SwapCalcTrace.Write(interestTrace);
// flowEvent.InterestPrincipal:当日计息基数(已按平仓比例缩放)——下游 EOD 用它播种次日 TdInterestPrincipal。
// 复用 CompoundEodBasis 单一真相源(与 CompoundInterestAccrual.AccrueEod 内部同一公式,见其 EodBasis 调用)。
flowEvent.InterestPrincipal = CompoundInterestAccrual.EodBasis(
isResetDay, posiPrincipal, preEodPosition.InterestProfitSum, remainingFraction,
preEodPosition.TdInterestPrincipal) * closePercent;
InterestAmount = result.Accrued;
TdInterestAmount = result.AccruedToday;
}
/// <summary>
/// 计算单利 收盘(按重置天数分段,每段使用对应浮动利率)
/// </summary>
public void CalcDailySimpleInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount)
{
// 首次操作(preEod.id == 0):计息基数按存量本金初始化——保留旧行为(含对 preEod 的就地修正)。
if (preEodPosition.id == 0)
{
preEodPosition.TdInterestPrincipal = posiPrincipal;
}
// 取率:重置日按 interest_rule 重新定盘浮动利率(GLMS-JIATT-20260805 根因——
// 重置日=平仓日必须用新利率,否则沿用旧周期利率并污染后续 EOD)。
int interestPeriod = position.interest_rest_days ?? 1;
var isResetDay = (endDate - tradeDate).Days % interestPeriod == 0;
var effectiveFloat = isResetDay ? ResolveFloatRate(position, endDate, floateRate) : floateRate;
flowEvent.FloatRate = effectiveFloat;
// 纯数学下沉至 SimpleInterestAccrual(末位生产精度 12 舍入)。
var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat);
var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: false);
// 完整计息 trace:收集器由适配器创建,随后经 SwapCalcTrace 常驻落盘(关键路径日志,无条件)。
var interestTrace = new AccrualTrace();
var result = SimpleInterestAccrual.AccrueEod(
priorAccrued: preEodPosition.InterestProfitSum,
priorNotional: preEodPosition.TdInterestPrincipal,
unwindFraction: closePercent,
rate: legRate,
policy: accrualPolicy,
eodDate: endDate,
trace: interestTrace);
InterestAmount = result.Accrued;
TdInterestAmount = result.AccruedToday;
SwapCalcTrace.Write(interestTrace);
}
/// <summary>
/// 单标的平仓
/// </summary>
/// <param name="unwindData"></param>
/// <exception cref="ServiceException"></exception>
public void SwapUnwind(UnwindData unwindData)
{
var td = FindTrade(unwindData.SwapTradeId);
if (td == null)
{
throw new ServiceException("未找到交易信息");
}
UnwindNormalizer.NormalizeEventUnwindDate(unwindData);
UnwindNormalizer.NormalizeNotionalValues(unwindData);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
// 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。
// 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
if (UnwindNormalizer.NormalizeFullCloseRequest(unwindData))
{
UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData);
}
ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易
bool cofirm = false;
ExecuteInTransaction(() =>
{
int clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate);
RecordMarginCashFlow(td, unwindData.ValueDate, unwindData.SwapMarginAmount, 0m, AddClientCash);
DealFloatPosition(unwindData);
var flowList = new List<swap_flow_event>(unwindData.FlowEvents);
var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.平仓, clientCashId, "系统操作_平仓");
var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(unwindData.CloseQty);
var isFullClose = UnwindNormalizer.IsFullCloseAfterDeduction(unwindData, remainingStockEqvNotional, remainingTradeAmount);
if (isFullClose)
{
td.TradeStatus = "已平仓";
td.StockEqvNotional = 0;
td.TradeAmount = 0;
CallSaveSwapTradeClientCash(td, unwindData.ValueDate);
}
else
{
td.HasPartialUnWind = 1;
td.StockEqvNotional = remainingStockEqvNotional;
td.TradeAmount = remainingTradeAmount;
}
td.Notional = td.TradeAmount;
td.UnWindDate = unwindData.UnwindDate;
SaveAllChanges();
cofirm = true;
});
if (cofirm)
{
TriggerRealtimeSwapPosition();
}
}
/// <summary>
/// 自动全平仓
/// </summary>
/// <param name="td"></param>
/// <param name="unwindPrice"></param>
/// <param name="unwindPriceFee"></param>
public void AuotoSwapUnwind(int tradeid, decimal unwindPrice, decimal unwindPriceFee, decimal unwindNetFee, decimal unwindNet, DateTime valueDate, decimal unwindQty, decimal closeFee)
{
unwindPriceFee = decimal.Parse(unwindPriceFee.ToString("F10"));
var td = DbContext.trade.Find(tradeid);
var positions = DbContext.swap_position.ActiveByTrade(td.id);
List<int> eventTypes = new List<int>() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换 };
var dealDate = valueDate;
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType)
? ConsGlobal.PriceRound
: ConsGlobal.SwapDeliveryPriceRound;
unwindPrice = Math.Round(unwindPrice, storagePriceRound, MidpointRounding.AwayFromZero);
var preDealDate = GetPreDealDate(td.id, dealDate, eventTypes);
swap_flow_event floatEvent = new swap_flow_event();
UnwindData unwindData = new UnwindData();
unwindData.CloseType = 2;
unwindData.StartDate = td.TradeDate.Value;
if (preDealDate.HasValue)
{
unwindData.StartDate = preDealDate.Value;
}
unwindData.ValueDate = dealDate;
unwindData.UnwindDate = QdpCalendarHelper.GetNonHoliday(dealDate.AddDays(1));
floatEvent.EventDate = dealDate;
floatEvent.UnwindDate = unwindData.UnwindDate;
floatEvent.PayDate = QdpCalendarHelper.GetNonHoliday(unwindData.UnwindDate.Value.AddDays(td.trade_extend.ExtendObj.SettlementRules));
unwindData.PayDate = floatEvent.PayDate;
floatEvent.SwapTradeId = td.id;
floatEvent.SwapTradeNo = td.TradeNumber;
unwindData.SwapTradeId = td.id;
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = position != null ? position.PosiNotionalValue : Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = Convert.ToDecimal(td.TradeAmount);
var unwindPercent = unwindData.PositionQty == 0 ? 0 : unwindQty / unwindData.PositionQty;
unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
unwindData.CloseMethod = unwindQty == unwindData.PositionQty ? (int)CloseMethodEnum.全部平仓 : (int)CloseMethodEnum.部分平仓;
unwindData.ClosePercent = unwindData.PositionQty == 0 ? 0 : unwindPercent;
unwindData.CloseNotionalValue = position == null ? 0 : unwindQty * position.PosiGrossPrice * position.ContractSize;
unwindData.CloseNotionalValue = unwindPercent >= 1 ? unwindData.PosiNotionalValue : Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
unwindData.CloseQty = unwindQty;
if (position != null)
{
decimal floatRatio = position.PosiDirection == 1 ? 1m : -1m;
decimal longRatio = position.PositionType == 1 ? 1m : -1m;
floatEvent.PositionId = position.PositionId;
floatEvent.EventType = (int)SwapFlowEventTypeEnum.平仓;
floatEvent.EventReason = "交易";
floatEvent.DividendIn = 0;
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
floatEvent.BeforeCloseFee = position.PosiTradingFee + position.PosiTradingFeePending;
floatEvent.PayDirection = position.PosiDirection;
floatEvent.PosiGrossPrice = position.PosiGrossPrice;
floatEvent.PosiNetPrice = position.PosiNetPrice;
floatEvent.PositionType = position.PositionType;
floatEvent.Quantity = unwindData.CloseQty;
floatEvent.PositionQty = unwindData.PositionQty - unwindData.CloseQty;
floatEvent.ContractSize = position.ContractSize;
floatEvent.DataState = (int)SwapFlowDateStateEnum.完成;
floatEvent.InterestMode = position.InterestMode;
floatEvent.TradingAmountAvg = unwindPrice;
floatEvent.TradingAmountFeeAvg = unwindPriceFee;
floatEvent.TradingAmountNetFeeAvg = unwindNetFee;
floatEvent.TradingAmountNetAvg = unwindNet;
floatEvent.TradingFeePending = position.PosiTradingFeePending * unwindData.ClosePercent;
floatEvent.TradingFeePending = Math.Round(floatEvent.TradingFeePending, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
floatEvent.TradingFee = closeFee;
floatEvent.MarkClosePnl = (unwindPrice - position.PosiGrossPrice) * unwindQty * floatRatio * longRatio;
//MarkClosePnl 纯盯市不要计算交易费用和分红
floatEvent.MarkClosePnl = Math.Round(floatEvent.MarkClosePnl, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
floatEvent.TradingAmount = unwindPrice * floatEvent.Quantity * floatEvent.ContractSize;
floatEvent.TradingAmount = Math.Round(floatEvent.TradingAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
floatEvent.OptLog = "流水自动";
floatEvent.ClientId = td.ClientId;
floatEvent.SetOpt(UserInfo);
EnrichDividendIn(floatEvent, unwindQty, td);
}
unwindData.FlowEvents.Add(floatEvent);
var interestPositions = GetUnwindInterests(unwindData.ValueDate, unwindData.UnwindDate.Value, td.id, unwindPercent, (int)SwapEventTypeEnum.平仓);
interestPositions.ForEach(item =>
{
item.OptLog = "流水自动";
});
foreach (var item in interestPositions)
{
item.TdInterestAmount = Math.Round(item.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
item.InterestAmount = Math.Round(item.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
item.InterestClosePnL = Math.Round(item.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
unwindData.FlowEvents.AddRange(interestPositions);
CalcCloseAmount(unwindData);
DealUnwind(unwindData, td);
}
private void EnrichDividendIn(swap_flow_event flowEvent, decimal unwindQty, trade td)
{
if (flowEvent.UnwindDate == null)
{
throw new ArgumentNullException("平仓日期缺失");
}
var date = flowEvent.EventDate;
BondPaymentService servie = new BondPaymentService(UserInfo);
var payments = servie.GetBondPayments(flowEvent.UnderlyingCode, td.StartDate.Value, date);
int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType);
int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection);
// + 付息日>上日日终且小于等于平仓日期的分红数据
var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode);
decimal tax = um.ValueAddedTax ?? 0;
dividendIn = DividendCalc.AfterTaxRaw(dividendIn, tax);
flowEvent.DividendIn = Math.Round(dividendIn, 2, MidpointRounding.AwayFromZero);
}
public decimal GetUnderlyingTax(string code)
{
var data = DataCacheProvider.GetUnderlyingDataSource().GetData(code);
if (data == null)
{
return 0;
}
return data.ValueAddedTax ?? 0;
}
private void DealUnwind(UnwindData unwindData, trade td, string actionMsg = "系统操作_自动平仓")
{
int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate);
RecordMarginCashFlow(td, unwindData.ValueDate, unwindData.SwapMarginAmount, 0m, AddClientCashInCashOut);
var flowList = new List<swap_flow_event>(unwindData.FlowEvents);
var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.平仓, clientCashId, actionMsg);
if (unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓)
{
td.TradeStatus = "已平仓";
td.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
if (td.trade_extend != null && !td.trade_extend.ExtendObj.NeedOpenFee)
{
new ClientCashInCashOutService(this).SaveSwapTradeClientCash(td, td.TradePrice ?? 0, unwindData.ValueDate, 0);
}
}
else
{
td.HasPartialUnWind = 1;
}
td.UnWindDate = unwindData.UnwindDate;
td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
td.TradeAmount -= Convert.ToDouble(unwindData.CloseQty);
td.Notional = td.TradeAmount;
td.OptDate = DateTime.Now;
td.OptId = UserId;
td.OptName = UserName;
DbContext.SaveChanges();
}
/// <summary>
/// 计算平仓总额
/// </summary>
/// <param name="unwindData"></param>
private void CalcCloseAmount(UnwindData unwindData)
{
var floatPosition = unwindData.FlowEvents.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode));
var interestList = unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode));
decimal floatRatio = floatPosition.PayDirection == 1 ? 1m : -1m;
var pnl = floatPosition.FloatPnlSum;
unwindData.SwapCloseAmount = pnl;
unwindData.SwapRealizedPnL = pnl;
unwindData.SwapMarginRebatePnl = 0;
unwindData.SwapMarginAmount = 0;
if (interestList != null)
{
MarginCalc.AccumulateSettlement(interestList.ToList(), unwindData);
interestList.ForEach(x =>
{
unwindData.SwapRealizedPnL += x.InterestClosePnL;
unwindData.SwapCloseAmount += x.InterestClosePnL;
});
}
unwindData.SwapCloseAmount = Math.Round(unwindData.SwapCloseAmount, 2, MidpointRounding.AwayFromZero);
unwindData.SwapRealizedPnL = Math.Round(unwindData.SwapRealizedPnL, 2, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 互换
/// </summary>
/// <param name="swap_Deal"></param>
/// <exception cref="ServiceException"></exception>
public void SwapIncome(UnwindData unwindData)
{
var td = FindTrade(unwindData.SwapTradeId);
if (td == null)
{
throw new ServiceException("未找到交易信息");
}
UnwindNormalizer.NormalizeEventUnwindDate(unwindData);
ValidateIncomeValueDate(unwindData, td);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
ValidateFrontendPnL(unwindData, isIncome: true); // 只读校验告警,不阻断交易
ExecuteInTransaction(() =>
{
int clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut.系统操作_互换, unwindData.ValueDate);
RecordMarginCashFlow(td, unwindData.ValueDate, 0m, unwindData.SwapMarginRebatePnl, AddClientCash);
foreach (var item in unwindData.FlowEvents)
{
item.OptLog = "手工操作";
}
SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.互换, clientCashId, "系统操作_互换");
if (td.ExerciseDate <= unwindData.ValueDate)
{
td.Notional = 0;
td.StockEqvNotional = 0;
td.TradeStatus = "已到期";
}
td.UnWindDate = unwindData.UnwindDate;
SaveAllChanges();
});
}
/// <summary>
/// 互换/平仓审核通过
/// </summary>
/// <param name="td"></param>
/// <param name="eventType"></param>
/// <exception cref="Exception"></exception>
public void ApproveSwapTrade(trade td, int eventType)
{
var swapEvent = FindSwapEvent(td.id, eventType);
if (swapEvent == null)
{
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
UnwindNormalizer.NormalizeEventUnwindDate(swapEvent.unwindData);
UnwindNormalizer.NormalizeNotionalValues(swapEvent.unwindData);
// Stored events keep display ratio A; approval calculations consume remaining ratio B.
swapEvent.unwindData.ClosePercent = ToRemainingClosePercent(
swapEvent.unwindData.ClosePercent,
swapEvent.unwindData.NotionalValue,
swapEvent.unwindData.PosiNotionalValue);
var flowList = FindFlowEventsByEventId(swapEvent.id);
foreach (var item in flowList)
{
item.EventDate = swapEvent.unwindData.ValueDate;
item.UnwindDate = swapEvent.unwindData.UnwindDate;
}
swapEvent.unwindData.FlowEvents = flowList;
if (eventType == (int)SwapEventTypeEnum.平仓)
{
if (UnwindNormalizer.NormalizeFullCloseRequest(swapEvent.unwindData))
{
UnwindNormalizer.RecalculateNormalizedUnwindAmounts(swapEvent.unwindData);
}
}
if (eventType == (int)SwapEventTypeEnum.互换)
{
ValidateIncomeValueDate(swapEvent.unwindData, td);
}
if (eventType == (int)SwapEventTypeEnum.平仓)
{
foreach (var item in flowList.Where(x => x.PositionType > 0))
{
item.Quantity = swapEvent.unwindData.CloseQty;
item.PositionQty = swapEvent.unwindData.ClosePercent == 1
? 0
: swapEvent.unwindData.PositionQty - swapEvent.unwindData.CloseQty;
}
}
string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费;
int clientCashId = AddClientCash(td, Convert.ToDouble(-swapEvent.unwindData.SwapRealizedPnL), action, swapEvent.unwindData.ValueDate);
if (eventType == (int)SwapEventTypeEnum.平仓)
{
RecordMarginCashFlow(td, swapEvent.unwindData.ValueDate, swapEvent.unwindData.SwapMarginAmount, 0m, AddClientCash);
}
swapEvent.ClientCashId = clientCashId;
td.UnWindDate = swapEvent.unwindData.UnwindDate;
if (eventType != (int)SwapEventTypeEnum.互换)
{
var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(swapEvent.unwindData.CloseQty);
var isFullClose = UnwindNormalizer.IsFullCloseAfterDeduction(swapEvent.unwindData, remainingStockEqvNotional, remainingTradeAmount);
if (isFullClose)
{
td.TradeStatus = "已平仓";
td.StockEqvNotional = 0;
td.TradeAmount = 0;
CallSaveSwapTradeClientCash(td, swapEvent.unwindData.ValueDate);
}
else
{
td.TradeStatus = ConsTrade.确认成交;
td.HasPartialUnWind = 1;
td.StockEqvNotional = remainingStockEqvNotional;
td.TradeAmount = remainingTradeAmount;
}
}
else if (swapEvent.unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓)
{
td.TradeStatus = "已平仓";
CallSaveSwapTradeClientCash(td, swapEvent.unwindData.ValueDate);
}
else
{
td.TradeStatus = ConsTrade.确认成交;
td.HasPartialUnWind = 1;
}
td.Notional = td.TradeAmount;
UpdateInitalPosition(flowList, swapEvent.unwindData, eventType);
SaveAllChanges();
}
/// <summary>
/// 互换/平仓提交审核
/// </summary>
/// <param name="unwindData"></param>
/// <param name="eventType"></param>
/// <exception cref="ServiceException"></exception>
public void ApplySwapTrade(UnwindData unwindData, int eventType)
{
var td = FindTrade(unwindData.SwapTradeId);
if (td == null)
{
throw new ServiceException("未找到交易信息");
}
UnwindNormalizer.NormalizeEventUnwindDate(unwindData);
if (eventType == (int)SwapEventTypeEnum.互换)
{
ValidateIncomeValueDate(unwindData, td);
}
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
NormalizeManualSettlementAmounts(unwindData, eventType, eventType == (int)SwapEventTypeEnum.互换 ? "系统操作_互换" : "系统操作_平仓");
// 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。
// 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。
// 与 SwapUnwind(L1270) 保持一致——缺少此转换会导致 SaveSwapDealInternal 的 B→A 还原出错
// (例如第二次部分平仓 50%(A) → 错误还原为 0.325 而非 0.50)。
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
if (eventType == (int)SwapEventTypeEnum.平仓)
{
if (UnwindNormalizer.NormalizeFullCloseRequest(unwindData))
{
UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData);
}
}
string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费;
ExecuteInTransaction(() =>
{
CloseReCheckSetTrade(unwindData.SwapTradeId, eventType == (int)SwapEventTypeEnum.互换, true);
SaveSwapDeal(unwindData, eventType, 0, action, true);
SaveAllChanges();
// 需求①:若触发条件判定无需审批(CloseReCheck_SetTrade 已将 ProcessOrderId 设为审批通过),
// 在 swap_event 记录创建完成后再执行审批通过流程。
td = FindTrade(unwindData.SwapTradeId);
if (td.ProcessOrderId == ProcessTradeLog.审批通过)
{
new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel
{
tradeId = td.id,
status = "pass",
comments = "触发条件未满足,自动跳过审批",
notNeedOperationHistory = false
});
}
});
}
private void ValidateIncomeValueDate(UnwindData unwindData, trade td)
{
var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date;
if (unwindData.ValueDate.Date > maxIncomeValueDate)
{
throw new ServiceException($"手动互换结算日期不能晚于当前交易结束日期T-1{maxIncomeValueDate:yyyy-MM-dd}");
}
}
/// <summary>
/// 保存平仓/互换事件
/// </summary>
/// <param name="swap_Deal"></param>
/// <param name="eventType"></param>
private long SaveSwapDealInternal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false)
{
var td = DbContext.trade.Find(unwindData.SwapTradeId);
if (td == null)
{
throw new ServiceException("未找到交易信息");
}
var flowList = new List<swap_flow_event>(unwindData.FlowEvents);
UnwindNormalizer.NormalizeSettledInterestAmounts(flowList, eventType, eventResason);
unwindData.FlowEvents.Clear();
// 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。
// 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。
var storedClosePercent = ToOriginalClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
var incomingClosePercent = unwindData.ClosePercent;
unwindData.ClosePercent = storedClosePercent;
string data = JsonConvert.SerializeObject(unwindData);
unwindData.ClosePercent = incomingClosePercent;
var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, eventType, data, clientCashId, true, eventResason);//将平仓、互换总额存入事件
foreach (var item in flowList)
{
if (item.PositionType > 0 && eventType == (int)SwapEventTypeEnum.平仓)
{
item.Quantity = unwindData.CloseQty;
item.PositionQty = unwindData.ClosePercent == 1 ? 0 : unwindData.PositionQty - unwindData.CloseQty;
}
item.PayDate = unwindData.PayDate;
item.UnwindDate = unwindData.UnwindDate;
item.EventDate = unwindData.ValueDate;
FillSwapFlowEvent(item, unwindData, td, eventType, swapEvent.id);
DbContext.swap_flow_event.Add(item);
}
if (!approve)
{
UpdateInitalPosition(flowList, unwindData, eventType);
}
DbContext.SaveChanges();
var savedFlowEvents = DbContext.swap_flow_event.Where(x => x.EventId == swapEvent.id).ToList();
foreach (var item in savedFlowEvents)
{
FillSwapFlowEvent(item, unwindData, td, eventType, swapEvent.id);
}
DbContext.SaveChanges();
return swapEvent.id;
}
private void FillSwapFlowEvent(swap_flow_event item, UnwindData unwindData, trade td, int eventType, long eventId)
{
item.EventId = eventId;
item.SwapTradeId = unwindData.SwapTradeId;
item.SwapTradeNo = td.TradeNumber;
item.EventType = eventType;
item.EventReason = string.IsNullOrWhiteSpace(item.EventReason) ? "交易" : item.EventReason;
UpdateDbOption(item);
}
/// <summary>
/// 计算平仓数据
/// </summary>
/// <param name="unwindData"></param>
private void DealFloatPosition(UnwindData unwindData)
{
foreach (var item in unwindData.FlowEvents)
{
item.OptLog = "手工操作";
if (item.PositionType > 0 && item.EventType == (int)SwapEventTypeEnum.平仓)
{
decimal shortRatio = item.PositionType == (int)PositionTypeFlag.Long ? -1m : 1m;
item.TradingAmountFeeAvg = item.TradingAmountAvg + item.TradingFeePending / unwindData.CloseQty * shortRatio;
item.TradingAmountNetFeeAvg = item.TradingAmountNetAvg + item.TradingFeePending / unwindData.CloseQty * shortRatio;
item.TradingAmount = item.TradingAmountAvg * unwindData.CloseQty;
}
}
}
/// <summary>
/// 单标的互换更新实时持仓信息
/// </summary>
/// <param name="swap_Deal"></param>
private void UpdateInitalPosition(List<swap_flow_event> flowList, UnwindData unwindData, int eventType)
{
var positions = DbContext.swap_position.Where(x => !x.IsInitial && x.SwapTradeId == unwindData.SwapTradeId && !x.Invalid);
foreach (var position in positions)
{
if (!string.IsNullOrEmpty(position.UnderlyingCode))
{
// 收益结算(互换)不改变持仓数量和名义本金,只更新费用
if (eventType == (int)SwapEventTypeEnum.互换)
{
position.PosiTradingFee -= position.PosiTradingFee * unwindData.ClosePercent;
position.PosiTradingFeePending -= position.PosiTradingFeePending * unwindData.ClosePercent;
}
else
{
// 平仓时才扣减持仓
var remainingPositionQty = position.PosiQuantity - unwindData.CloseQty;
var remainingPositionNotional = Math.Round(
position.PosiNotionalValue - unwindData.CloseNotionalValue,
ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
position.PosiQuantity = unwindData.ClosePercent == 1
? 0
: remainingPositionQty;
position.PosiNotionalValue = unwindData.ClosePercent == 1
? 0
: remainingPositionNotional;
position.PosiTradingFee -= position.PosiTradingFee * unwindData.ClosePercent;
position.PosiTradingFeePending -= position.PosiTradingFeePending * unwindData.ClosePercent;
}
}
else
{
var interest = flowList.FirstOrDefault(x => x.PositionId == position.PositionId);
if (interest != null)
{
position.InterestAmount += interest.InterestAmount;
position.InterestFeePending += interest.InterestFee;
if (MarginModes.Contains(interest.InterestMode) && eventType == (int)SwapEventTypeEnum.平仓)
{
var remainingInterestPrincipal = Math.Round(
position.InterestPrincipalFix - interest.InterestPrincipal,
ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
position.InterestPrincipalFix = unwindData.ClosePercent == 1
? 0
: remainingInterestPrincipal;
}
}
}
}
}
}
}