- FundTagCalc 全链路(LegAmount/LegFundPlan/UnwindTagSplit/TagRelease/MarginLegSettlement)金额类型改 decimal,消除 double 二进制误差在拆单倒挤、守恒校验中的分位尾差 - 边界处显式转换:writeCash 委托与 ClientCreditInoutService.Occupy/Release(仍 double)、GetAvailableCredit 返回值 (decimal) 强转 - SwapDealService.RecordMarginCashFlow/ReleaseMarginByFundTag 签名同步 decimal,测试桩 TestableSwapDealService 对齐 - 单测断言去掉 1e-6 容差(decimal 精确比较)
2704 lines
159 KiB
C#
2704 lines
159 KiB
C#
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)在循环最外层路由,后续融资腿分支树不感知保证金概念 ──
|
||
// 有意跳过 GetFloatRate:CalcMarginInterest 纯固定利率(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 → B:B = 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 < 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 硬编码 0(commit e3c473ba),因测试交易
|
||
/// PosiDividendSum 恰好=0(3/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 同源,无差分)
|
||
/// 盘中 = accrualBasis(preEod.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 = marginAmount, CashRebate = marginRebate };
|
||
if (split.CashMargin != 0)
|
||
writeCash(td, (double)split.CashMargin, ClientCashInCashOut.系统操作_应付预付金, valueDate);
|
||
if (split.CashRebate != 0)
|
||
writeCash(td, (double)(-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 = marginAmount, CashRebate = 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);
|
||
// 例如 0004:5/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)取 FR007(fetchAfterDate=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 之后的重置日才取 FR007(fetchAfterDate=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.AccrueEod(DDD 命名 + 末位生产精度 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>
|
||
/// <param name="confirmation">新风控二次确认信息;首次提交传空。</param>
|
||
/// <returns>本次平仓的新风控试算结果。</returns>
|
||
/// <exception cref="ServiceException"></exception>
|
||
public YLErp.Modules.RiskEngine.RiskTrialResult SwapUnwind(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation)
|
||
{
|
||
if (unwindData == null)
|
||
{
|
||
throw new ServiceException("平仓信息不能为空");
|
||
}
|
||
|
||
if (FindTrade(unwindData.SwapTradeId) == null)
|
||
{
|
||
throw new ServiceException("未找到交易信息");
|
||
}
|
||
var riskTrialResult = CheckCloseRisk(unwindData, confirmation);
|
||
if (!riskTrialResult.Passed)
|
||
{
|
||
return riskTrialResult;
|
||
}
|
||
SwapUnwind(unwindData);
|
||
return riskTrialResult;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 单标的平仓
|
||
/// </summary>
|
||
/// <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>
|
||
/// <param name="confirmation">新风控二次确认信息;首次提交传空。</param>
|
||
/// <returns>平仓事件返回新风控试算结果,互换事件返回空。</returns>
|
||
/// <exception cref="ServiceException"></exception>
|
||
public YLErp.Modules.RiskEngine.RiskTrialResult ApplySwapTrade(UnwindData unwindData, int eventType, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation)
|
||
{
|
||
YLErp.Modules.RiskEngine.RiskTrialResult riskTrialResult = null;
|
||
if (eventType == (int)SwapEventTypeEnum.平仓)
|
||
{
|
||
riskTrialResult = CheckCloseRisk(unwindData, confirmation);
|
||
if (!riskTrialResult.Passed)
|
||
{
|
||
return riskTrialResult;
|
||
}
|
||
}
|
||
ApplySwapTrade(unwindData, eventType);
|
||
return riskTrialResult;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 互换/平仓提交审核
|
||
/// </summary>
|
||
/// <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
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行互换交易平仓审核时点的新风控试算。
|
||
/// </summary>
|
||
/// <param name="unwindData">包含本次平仓日期和交易ID的平仓请求。</param>
|
||
/// <param name="confirmation">新风控二次确认信息;首次提交传空。</param>
|
||
/// <returns>包含阻断、审批、提示及试算记录ID的新风控结果。</returns>
|
||
/// <exception cref="ServiceException">平仓请求为空。</exception>
|
||
private YLErp.Modules.RiskEngine.RiskTrialResult CheckCloseRisk(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation)
|
||
{
|
||
if (unwindData == null)
|
||
{
|
||
throw new ServiceException("平仓信息不能为空");
|
||
}
|
||
|
||
const string triggerPoint = "CLOSE_REVIEW";
|
||
const int closeTrialSource = 2;
|
||
var riskContext = new YLErp.Modules.RiskEngine.RiskContext
|
||
{
|
||
TradeId = unwindData.SwapTradeId,
|
||
TriggerPoint = triggerPoint,
|
||
UnwindDate = unwindData.UnwindDate,
|
||
PayDate = unwindData.PayDate
|
||
};
|
||
return new YLErp.Modules.RiskEngine.RiskTrialService(this).CheckRisk(riskContext, closeTrialSource, confirmation);
|
||
}
|
||
|
||
private void ValidateIncomeValueDate(UnwindData unwindData, trade td)
|
||
{
|
||
var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date;
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
|