Files
zszq-trs/YLErpDAL/Modules/SwapModule/SwapDealService.cs
T
张名锐 d2b6dff598 fix(swap): 解决互换平仓时保证金返息重复计算问题
- 添加保证金返息去重机制,防止平仓时返息被重复计入资金记录
- 新增 GetMainCashRealizedPnL 方法专门处理返息剔除逻辑
- 更新 DealUnwind 和 ApproveSwapTrade 方法使用统一的返息处理口径
- 修改 ReleaseMarginByFundTag 方法增强标签分流功能
- 添加多个单元测试验证返息处理的正确性
- 将 SaveAllChanges 设为可测试化接缝便于测试验证
- 调整 FundTag 分流逻辑支持授信和现金腿分别处理
2026-08-27 16:13:59 +08:00

2627 lines
156 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);
}
/// <summary>查询交易当前有效的初始腿和实时腿。测试可返回内存快照,避免初始化测试触库。</summary>
protected virtual List<swap_position> FindActiveSwapPositions(int tradeId)
{
return DbContext.swap_position
.Where(x => x.SwapTradeId == tradeId && !x.Invalid)
.ToList();
}
/// <summary>
/// 找到平仓数据对应的实时浮动腿。正式路径以 PositionId 绑定,缺失时才按标的代码兜底;
/// 这样后台不会把前端传入的价格当成权威基线。测试可 override 为内存持仓。
/// </summary>
protected virtual swap_position FindRealtimeFloatPosition(UnwindData unwindData)
{
if (unwindData == null)
{
return null;
}
var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode));
var query = DbContext.swap_position
.Where(x => x.SwapTradeId == unwindData.SwapTradeId && !x.IsInitial && !x.Invalid
&& !string.IsNullOrEmpty(x.UnderlyingCode));
if (floatEvent?.PositionId > 0)
{
var byPositionId = query.FirstOrDefault(x => x.PositionId == floatEvent.PositionId);
if (byPositionId != null)
{
return byPositionId;
}
}
if (!string.IsNullOrEmpty(floatEvent?.UnderlyingCode))
{
var byCode = query.FirstOrDefault(x => x.UnderlyingCode == floatEvent.UnderlyingCode);
if (byCode != null)
{
return byCode;
}
}
return query.FirstOrDefault();
}
/// <summary>查询 valueDate 当日已经生效的最近有效 Stock/Fund EOD。</summary>
protected virtual eod_swap_position FindLatestFundEodPosition(
int tradeId,
long positionId,
DateTime valueDate)
{
return new SwapEodPositionService(this)
.GetLatestValidEodPosition(tradeId, positionId, valueDate);
}
/// <summary>
/// 查询 valueDate 当天真正生效的 Stock/Fund 公司行为。
/// ExDividendDate 只是登记日,盘中基线不能按登记日提前切换;只有
/// EffectiveDate == valueDate 时才把上一 EOD 的 Q/P 转成当日 BOD 的除权后 Q/P。
/// </summary>
protected virtual ex_dividend_info FindFundCorporateAction(
string underlyingCode,
DateTime valueDate)
{
return DbContext.ex_dividend_info.FirstOrDefault(x => x.ValidStatus
&& x.UnderlyingCode == underlyingCode
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value == valueDate.Date);
}
/// <summary>
/// 查询从最近 EOD 之后到平仓日已经生效的 Stock/Fund 公司行为。
/// 平仓可能跨越登记日、生效日和多个非交易日,因此不能只按 valueDate 命中一条记录。
/// 调用方已限定为 Stock/Fund 浮动腿;同日多条记录按生效日、主键稳定排序后逐条应用。
///
/// 查询范围说明:
/// - 严格 > eodDate(开区间):EOD 快照本身已经是除权后结果,不能再次套用
/// - 范围示例:eodDate=8/14(除权后 2000/50),valueDate=8/20,则查询 (8/14, 8/20] 内的记录
/// </summary>
protected virtual List<ex_dividend_info> FindFundCorporateActions(
string underlyingCode,
DateTime eodDate,
DateTime valueDate)
{
var fromDate = eodDate.Date;
var toDate = valueDate.Date;
var corporateActions = DbContext.ex_dividend_info
.Where(x => x.ValidStatus
&& x.UnderlyingCode == underlyingCode
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value.Date > fromDate
&& x.EffectiveDate.Value.Date <= toDate)
.OrderBy(x => x.EffectiveDate)
.ThenBy(x => x.id)
.ToList();
Logger.Info($"[公司行为查询] 标的={underlyingCode} EOD={eodDate:yyyy-MM-dd} 平仓日={valueDate:yyyy-MM-dd} 查询到{corporateActions.Count}条公司行为");
return corporateActions;
}
/// <summary>
/// Stock/Fund 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。
/// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。
/// </summary>
protected virtual decimal GetFundCorporateActionClosePrice(
ex_dividend_info dividendInfo,
decimal fallbackPrice)
{
if (!dividendInfo.ExDividendDate.HasValue)
{
return fallbackPrice;
}
var closePrice = new EodPriceProvider(dividendInfo.ExDividendDate.Value)
.GetPrice(dividendInfo.UnderlyingCode, SettlementTypeEnum.ClosePrice);
return Convert.ToDecimal(closePrice);
}
/// <summary>
/// 判断最新 EOD 之后是否已有同一浮动腿的完成流水。若有,说明当日实时持仓已发生部分平仓/互换,
/// 不能再把较早 EOD 的数量覆盖回来,否则会抹掉当日成交结果。
/// </summary>
protected virtual bool HasCompletedFlowAfterFundEod(
int tradeId,
long positionId,
DateTime eodDate,
DateTime valueDate)
{
var asOfDate = valueDate.Date;
return DbContext.swap_flow_event.Any(x => x.SwapTradeId == tradeId
&& x.PositionId == positionId
&& x.DataState == (int)SwapFlowDateStateEnum.完成
&& x.EventDate > eodDate
&& x.EventDate <= asOfDate);
}
/// <summary>
/// 恢复实时 Stock/Fund 浮动腿到截至指定日有效的 EOD 基线。
/// 这是唯一允许把 EOD 公司行为结果带入盘中平仓的入口:10 送 10 后 EOD 是 2000 份/50
/// 时,下一日直接使用 2000/50,不再把前端可能传入的 1000/100 或已除权价格重复套系数。
/// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Stock/Fund、无
/// EOD 和固定/利息腿均返回 false,沿用原逻辑。
/// </summary>
protected virtual bool TryRestorePositionFromEod(
swap_position position,
DateTime valueDate)
{
// 只对收取方向的 Stock/Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。
// 无历史 EOD 或最新 EOD 后已有完成流水时返回 false,由调用方保持实时持仓原值,
// 不伪造一份快照,也不把较早的 2000 份/50 覆盖掉当日已经部分平仓后的实时数量。
if (position == null
|| position.PosiDirection <= 0
|| !SwapEodPositionService.IsCorporateActionInstrument(position.UnderlyingInstrumentType))
{
Logger.Info($"[公司行为恢复] 跳过非适用场景 positionId={position?.PositionId} direction={position?.PosiDirection} instrumentType={position?.UnderlyingInstrumentType}");
return false;
}
var eodPosition = FindLatestFundEodPosition(
position.SwapTradeId,
position.PositionId,
valueDate);
if (eodPosition == null)
{
Logger.Info($"[公司行为恢复] 未找到有效EOD tradeId={position.SwapTradeId} positionId={position.PositionId} valueDate={valueDate:yyyy-MM-dd}");
return false;
}
// 验证 EOD 数据完整性
if (eodPosition.PosiQuantity <= 0 || eodPosition.PosiGrossPrice <= 0)
{
Logger.Info($"[公司行为恢复] EOD快照数据异常 tradeId={position.SwapTradeId} positionId={position.PositionId} " +
$"eodDate={eodPosition.ValueDate:yyyy-MM-dd} qty={eodPosition.PosiQuantity} price={eodPosition.PosiGrossPrice}");
return false;
}
if (HasCompletedFlowAfterFundEod(
position.SwapTradeId,
position.PositionId,
eodPosition.ValueDate,
valueDate))
{
Logger.Info($"[公司行为恢复] EOD后已有完成流水,保持实时持仓 tradeId={position.SwapTradeId} positionId={position.PositionId} eodDate={eodPosition.ValueDate:yyyy-MM-dd}");
return false;
}
Logger.Info($"[公司行为恢复] 从EOD恢复基线 tradeId={position.SwapTradeId} positionId={position.PositionId} " +
$"eodDate={eodPosition.ValueDate:yyyy-MM-dd} eodQty={eodPosition.PosiQuantity} eodPrice={eodPosition.PosiGrossPrice}");
if (!SwapEodPositionService.RestoreFundPositionFromEod(position, eodPosition))
{
return false;
}
// 最近 EOD 已经处于生效日或更晚时,说明该快照本身已经是除权后基线,
// 不能再次套系数。若平仓跨过多个生效日,则按生效日、id 顺序逐条补齐。
var corporateActions = FindFundCorporateActions(
position.UnderlyingCode,
eodPosition.ValueDate,
valueDate);
foreach (var corporateAction in corporateActions ?? new List<ex_dividend_info>())
{
Logger.Info($"[公司行为应用] 除权前 id={corporateAction.id} " +
$"登记日={corporateAction.ExDividendDate:yyyy-MM-dd} " +
$"生效日={corporateAction.EffectiveDate:yyyy-MM-dd} " +
$"标的={position.UnderlyingCode} Q={position.PosiQuantity} P={position.PosiGrossPrice}");
var closePrice = GetFundCorporateActionClosePrice(
corporateAction,
position.PosiGrossPrice);
if (closePrice <= 0)
{
throw new ServiceException(
$"Stock/Fund 标的【{position.UnderlyingCode}】" +
$"登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】" +
$"生效日【{corporateAction.EffectiveDate:yyyy-MM-dd}】" +
$"缺少有效收盘价(id={corporateAction.id}),无法执行除权");
}
// TODO: 现金模式不使用税率参与 Q/P 除权;价格调整模式启用后再根据需求 考虑接入该配置。
// var dividendTaxRate = GetFundDividendTaxRate();
SwapEodPositionService.ApplyCorporateActionToPosition(
position,
corporateAction,
closePrice,
0m);
Logger.Info($"[公司行为应用] 除权后 id={corporateAction.id} Q={position.PosiQuantity} P={position.PosiGrossPrice} notional={position.PosiNotionalValue}");
}
return true;
}
/// <summary>
/// 在直接提交前复核前端平仓数据。基线恢复成功时同步浮动流水价格、有效数量和名义本金,
/// 并拒绝 CloseQty 超过有效 EOD 数量;全平请求则把数量规范为当前有效全部持仓。
/// </summary>
protected virtual bool TryRestoreAndValidateUnwindData(
UnwindData unwindData,
DateTime valueDate)
{
var position = FindRealtimeFloatPosition(unwindData);
if (!TryRestorePositionFromEod(position, valueDate))
{
return false;
}
var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode));
var effectiveQty = position.PosiQuantity;
var requestedQty = unwindData.CloseQty;
var fullClose = unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓
|| unwindData.ClosePercent >= 1m;
// CloseQty 是部分平仓请求的数量口径;全平请求忽略前端缓存的旧数量,统一取 EOD 有效数量。
// 例如 10 送 10 后 EOD 为 2000 份/50,前端仍传 1000 份时,全平必须落成 2000 份,
// 否则会遗留 1000 份;现金派现后若 EOD 名义本金为 99000,平一半应按 49500 扣减。
// 若交易级余额仍沿用旧值 100000,再扣有效平仓额 49500,就会错误留下 50500。
if (requestedQty < 0m || (!fullClose && requestedQty > effectiveQty))
{
throw new ServiceException(
$"Stock/Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}");
}
var closeQty = fullClose ? effectiveQty : requestedQty;
var closeNotional = fullClose
? position.PosiNotionalValue
: Math.Round(
closeQty * position.PosiGrossPrice * position.ContractSize,
ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
unwindData.PositionQty = effectiveQty;
unwindData.PosiNotionalValue = position.PosiNotionalValue;
unwindData.CloseQty = closeQty;
unwindData.CloseNotionalValue = closeNotional;
if (!fullClose)
{
unwindData.ClosePercent = unwindData.NotionalValue > 0m
? closeNotional / unwindData.NotionalValue
: (effectiveQty == 0m ? 0m : closeQty / effectiveQty);
}
if (floatEvent != null)
{
floatEvent.PosiGrossPrice = position.PosiGrossPrice;
floatEvent.PosiNetPrice = position.PosiNetPrice;
floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice;
floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice;
floatEvent.Quantity = closeQty;
floatEvent.PositionQty = effectiveQty - closeQty;
floatEvent.ContractSize = position.ContractSize;
// EOD 恢复会改变入场基准和有效平仓数量;按当前平仓价重算前端派生盈亏。
// FloatPnlSum 是只读属性,由 MarkClosePnl、费用和分红自动派生,不能直接写入。
UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData);
}
return true;
}
#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">交易ID</param>
/// <param name="valueDate">平仓日期</param>
/// <returns>返回浮动腿的最新基线数据</returns>
public virtual (decimal PositionQty, decimal PosiNotionalValue, decimal PosiGrossPrice, decimal PosiNetPrice, bool IsRestored) RefreshFloatLegBaseline(int tradeId, DateTime valueDate)
{
var td = DbContext.trade.Find(tradeId);
if (td == null)
{
throw new ServiceException("未找到交易信息");
}
var positions = DbContext.swap_position.ActiveByTrade(tradeId);
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
if (position == null)
{
// 无浮动腿,返回trade表的原始值
return (
Convert.ToDecimal(td.TradeAmount),
Convert.ToDecimal(td.StockEqvNotional),
0m,
0m,
false
);
}
// 尝试恢复除权后的 EOD 基线
var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, valueDate);
return (
position.PosiQuantity,
position.PosiNotionalValue,
position.PosiGrossPrice,
position.PosiNetPrice,
restoredCorporateActionBaseline
);
}
/// <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();
// Stock/Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50
// 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。
var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, dealDate);
// 恢复失败表示非 Stock/Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值,
// 继续原有盘中流程,避免用不完整快照制造数量/价格。
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);
// 现金分红会调整 EOD 期初价但不改数量,因此持仓名义本金可能从 100000 变为 99000。
// 只有 Stock/Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。
unwindData.PosiNotionalValue = restoredCorporateActionBaseline
? position.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();
// 收益结算与手工平仓共用 Stock/Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。
var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, dealDate);
// 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD
// 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。
//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 = restoredCorporateActionBaseline
? position.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 融资腿{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 融资腿{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 融资腿{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 融资腿{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}");
position.FloatRate = positionClone.FloatRate = rate;
return position.FloatRate;
}
if (!interestWindowEmpty)
{
if (calcLast)
{
SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{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 融资腿{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})");
return kept;
}
SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{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)。
/// R4 §2.4 平仓/到期按被平仓腿 FundTag 原路返还:
/// Credit 腿的返还本金与返息不产生资金流水(本金写授信出入表"释放",按 position_id 匹配原占用),
/// Cash/无标签(存量)部分正常产生资金流水。
/// </summary>
private void RecordMarginCashFlow(trade td, DateTime valueDate,
List<swap_flow_event> interestEvents,
decimal marginAmount, decimal marginRebate,
Func<trade, double, string, DateTime, int> writeCash)
{
var split = interestEvents != null && interestEvents.Any(x => string.IsNullOrEmpty(x.UnderlyingCode))
? ReleaseMarginByFundTag(td, valueDate, interestEvents, marginAmount, marginRebate)
//无预付金腿结算事件(如金额手工归一化/无腿场景)——退化原逻辑,全额现金
: new UnwindTagSplit { CashMargin = Convert.ToDouble(marginAmount), CashRebate = Convert.ToDouble(marginRebate) };
if (split.CashMargin != 0)
writeCash(td, split.CashMargin, ClientCashInCashOut.系统操作_应付预付金, valueDate);
if (split.CashRebate != 0)
writeCash(td, -split.CashRebate, ClientCashInCashOut.系统操作_预付金返息, valueDate);
}
/// <summary>
/// 按标签分流并写授信释放记录(virtual,测试可 stub 为全现金,见 TestableSwapDealService)。
/// 分流结果以逐腿结算额(settlements)为准,入参 marginAmount/marginRebate 仅在
/// 结算事件缺失(异常数据)时兜底,保证不丢资金记录。
/// </summary>
protected virtual UnwindTagSplit ReleaseMarginByFundTag(trade td, DateTime valueDate, List<swap_flow_event> interestEvents,
decimal marginAmount, decimal marginRebate)
{
var settlements = new SwapFundTagService(this).GetSettlements(interestEvents);
var split = new SwapFundTagService(this).ReleaseMarginByTag(td, valueDate, settlements);
//结算事件缺失(异常数据)时保底按传入总额走现金,不丢资金记录
if (settlements.Count == 0)
{
return new UnwindTagSplit { CashMargin = Convert.ToDouble(marginAmount), CashRebate = Convert.ToDouble(marginRebate) };
}
return split;
}
/// <summary>
/// 主客户现金记录(平仓费)应使用的已实现盈亏:从 SwapRealizedPnL 中剔除保证金返息 SwapMarginRebatePnl。
/// <para>
/// 口径背景:CalcCloseAmount 汇总时预付金腿的 InterestClosePnL 已计入 SwapRealizedPnL
/// (即总盈亏"已包含"返息),而 RecordMarginCashFlow 又会为这部分返息单独分流记账——
/// 现金腿写"预付金返息"资金流水、授信腿不产生任何资金(R4 §2.4)。
/// 主记录若不剔除,返息会被计两次:一次混在平仓费总额里,一次在独立返息记录里。
/// </para>
/// <para>
/// 必须剔除"完整返息"而非仅现金部分:授信腿返息同样混在总盈亏里,只是它不落资金流水;
/// 若只减现金部分,授信返息会残留在主记录里被当成真实现金支付给客户。
/// SwapMarginRebatePnl=0(无预付金腿交易,或存量待复核事件反序列化的默认值)时本方法为无操作,
/// 历史事件审批重放不会改变金额,向后兼容。
/// </para>
/// </summary>
private static decimal GetMainCashRealizedPnL(UnwindData unwindData)
{
return unwindData.SwapRealizedPnL - unwindData.SwapMarginRebatePnl;
}
/// <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 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}");
return fixing;
}
SwapCalcTrace.Critical(
$"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}");
return fallback;
}
SwapCalcTrace.Critical(
$"FIX Resolve 融资腿{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 融资腿{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 融资腿{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);
// 提交时再次从有效 EOD/实时腿复核 Stock/Fund 基线,不能只相信前端缓存的数量和价格。
var restoredCorporateActionBaseline = TryRestoreAndValidateUnwindData(unwindData, unwindData.ValueDate);
// 这是直接提交路径的最后一道复核。若返回 false(非 Stock/Fund、无快照、或 EOD 后已有完成流水),
// 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。
if (restoredCorporateActionBaseline)
{
// 正式提交必须让交易级余额与同一 Stock/Fund EOD 基线一致,再执行原有扣减。
// 例:派现后有效名义本金为 99000,平掉一半 49500 后应剩 49500
// 若仍从 trade 旧值 100000 扣减,会错误留下 50500。
td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue);
td.TradeAmount = Convert.ToDouble(unwindData.PositionQty);
}
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(() =>
{
// 主客户现金(平仓费)须剔除保证金返息(完整口径见 GetMainCashRealizedPnL):
// SwapRealizedPnL 已含返息,RecordMarginCashFlow 又会单独为返息记账,不剔除会重复计一次;
// 且须剔除完整 SwapMarginRebatePnl 而非仅现金部分——Credit 返息同样混在总盈亏里,
// 只是不落资金,若只减现金部分会把授信返息当真金白银付出去。
int clientCashId = AddClientCash(td, Convert.ToDouble(-GetMainCashRealizedPnL(unwindData)), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate);
// 平仓了结交易:预付金本金(SwapMarginAmount)+返息(SwapMarginRebatePnl)都按腿 FundTag 原路返还。
RecordMarginCashFlow(td, unwindData.ValueDate, unwindData.FlowEvents, 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();
// 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Stock/Fund EOD。
// 因此它不具备手工 SwapUnwind 的 EOD 复核保护,生产上需确保自动流水已在正确的 EOD 基线之后生成。
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 um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode);
// 期间付息和公司行为现金分红可能同时命中;BondPaymentService 逐条按来源单位换算,
// 原生 bond_payment_info 记录按每 100 份,公司行为表补充记录按每 10 份。
var dividendIn = servie.CalcPayment(
payments,
unwindQty,
shortRatio,
directionRatio);
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;
}
/// <summary>
/// 自动平仓的资金结算与持仓扣减(自动平仓/到期自动处理共用)。
/// private 改 protected virtual 仅为可测试化:生产无子类覆写、行为不变,
/// 测试经 TestableSwapDealService.DealUnwindForTest 直接驱动本方法(见 UW_005C)。
/// </summary>
protected virtual void DealUnwind(UnwindData unwindData, trade td, string actionMsg = "系统操作_自动平仓")
{
// 与手工 SwapUnwind 同口径(完整背景见 GetMainCashRealizedPnL):
// 主客户现金剔除已单独记账的保证金返息,防止返息重复计一次。
// AddClientCash 为可测试化接缝:生产等价于原 AddClientCashInCashOut(见类头 seam 区注释)。
int clientCashId = AddClientCash(td, Convert.ToDouble(-GetMainCashRealizedPnL(unwindData)), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate);
// 平仓了结交易:预付金本金+返息按腿 FundTag 原路返还。
RecordMarginCashFlow(td, unwindData.ValueDate, unwindData.FlowEvents, unwindData.SwapMarginAmount, 0m, AddClientCash);
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;
SaveAllChanges(); // 可测试化接缝:生产=DbContext.SaveChanges(),测试空操作(原直写 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);
// 正常页面先由 InitIncome 读取最近有效 Stock/Fund EOD;本提交方法本身不再重读快照,
// 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权
// 不会在这里回写,属于审批链路的残余风险。
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, unwindData.FlowEvents, 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("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
// 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Stock/Fund EOD 重建数量和价格。
// 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线;
// 直接提交路径的 EOD 复核不覆盖此审批路径。
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.系统操作_平仓费;
decimal mainCashPnl = eventType == (int)SwapEventTypeEnum.平仓
? GetMainCashRealizedPnL(swapEvent.unwindData)
: swapEvent.unwindData.SwapRealizedPnL;
int clientCashId = AddClientCash(td, Convert.ToDouble(-mainCashPnl), action, swapEvent.unwindData.ValueDate);
if (eventType == (int)SwapEventTypeEnum.平仓)
{
RecordMarginCashFlow(td, swapEvent.unwindData.ValueDate, swapEvent.unwindData.FlowEvents,
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);
// 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Stock/Fund EOD 复核。
// 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。
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;
}
}
}
}
}
}
}