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

This commit is contained in:
锦麟 王
2026-08-24 09:40:03 +08:00
338 changed files with 9170 additions and 64604 deletions
@@ -67,6 +67,10 @@ public sealed class AccrualTrace
=> Add(AccrualTraceEvent.End, default,
$"END accrued={totalAccrued:F6} today={totalToday:F6}");
/// <summary>自由文本注解(如罚息接缝的诊断行),不绑定特定计息语义。</summary>
public void Note(string message)
=> Add(AccrualTraceEvent.Note, default, message);
private void Add(AccrualTraceEvent step, DateTime date, string line)
=> _entries.Add(new AccrualTraceEntry(step, date, line));
@@ -78,7 +82,7 @@ public sealed class AccrualTrace
/// <summary>追踪条目的语义类别(对应 QuantLib/Strata 的"事件"概念),便于程序化筛选(如"只看重置日")。</summary>
public enum AccrualTraceEvent
{
Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End
Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End, Note
}
/// <summary>单条追踪记录:类别 + 日期 + 已渲染文本。</summary>
@@ -61,6 +61,11 @@ public static class CompoundInterestAccrual
/// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。
/// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。
/// </summary>
/// <param name="carryInInterest">
/// 窗口前已计未结转利息(EQD-6977 罚息承接):在【首个重置日】并入计息基数——
/// 与"持有至到期"全期轨迹严格对齐(恒等式:全期复利 = 平仓日已结利息 + 罚息窗口利息)。
/// 默认 0 时与旧行为逐位一致。与 resetCarryInterest 互斥使用(后者是全平重放的末段存量替代)。
/// </param>
public static InterestResult AccruePeriod(
decimal notional,
IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates,
@@ -73,10 +78,14 @@ public static class CompoundInterestAccrual
decimal realizedInterest,
decimal unwindFraction,
out decimal finalBasis,
AccrualTrace? trace = null)
AccrualTrace? trace = null,
decimal carryInInterest = 0m)
{
decimal accrualBasis = notional;
decimal accrued = 0m;
// carryInInterest 是"窗口前已计未结转利息":先并入 accrued,随首个重置日的
// basis = notional + accrued 一并资本化,并在其后每个重置日持续留在基数里
//(与全窗口重放时 accrued 含全部历史利息的轨迹严格一致);最终报告时扣除。
decimal accrued = carryInInterest;
trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized);
@@ -116,6 +125,9 @@ public static class CompoundInterestAccrual
finalBasis = accrualBasis;
// 报告口径只含窗口内增量(carryIn 是窗口前已结利息,由正常平仓流单独结算)
accrued -= carryInInterest;
if (realizedInterest != 0m)
trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction);
accrued -= realizedInterest * unwindFraction;
@@ -36,19 +36,24 @@ public sealed class InterestCalcRequest
public bool NewCalcLast { get; }
public List<swap_flow_event> CloseList { get; }
/// <summary>是否计罚息(EQD-6977):利息端按持有至到期计息。由平仓页下拉经 UnwindData 透传;默认 false。</summary>
public bool IsPenaltyInterest { get; }
private InterestCalcRequest(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePercent,
int eventType, bool tdClose, decimal orginPv,
bool add, bool newCalcLast, List<swap_flow_event> closeList)
bool add, bool newCalcLast, List<swap_flow_event> closeList,
bool isPenaltyInterest = false)
{
Td = td; TradeExtend = tradeExtend; ValueDate = valueDate; UnwindDate = unwindDate;
EodPositions = eodPositions; Positions = positions;
PosiNotionalValue = posiNotionalValue; ClosePosiNotionalValue = closePosiNotionalValue;
ClosePercent = closePercent; EventType = eventType; TdClose = tdClose; OrginPv = orginPv;
Add = add; NewCalcLast = newCalcLast; CloseList = closeList;
IsPenaltyInterest = isPenaltyInterest;
}
/// <summary>
@@ -62,10 +67,11 @@ public sealed class InterestCalcRequest
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal preCloseNotional, decimal closedNotional, decimal closePercentRemaining,
int eventType, bool tdClose, decimal orginPv,
bool add, bool newCalcLast, List<swap_flow_event> closeList)
bool add, bool newCalcLast, List<swap_flow_event> closeList,
bool isPenaltyInterest = false)
=> new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions,
preCloseNotional, closedNotional, closePercentRemaining,
eventType, tdClose, orginPv, add, newCalcLast, closeList);
eventType, tdClose, orginPv, add, newCalcLast, closeList, isPenaltyInterest);
/// <summary>
/// 【EOD 当日有平仓后的收盘结息】场景(→ CalcEodPostCloseSettleInterestssettment:false 全额结息)。
@@ -0,0 +1,47 @@
using YLErp.Models;
namespace YLErp.Modules.SwapModule;
/// <summary>
/// 利息腿 EOD 归档场景分派规格(纯函数集;表驱动单测见 InterestEodScenarioDispatchTest)。
/// 优先级链(承重业务语义,不能乱序):
/// ① hasSwap(手工互换) → 按事件流水重新生成,压制观察日自动结息
/// ② observationInterval(观察日) 存在 → 自动结息;同日有平仓 → autoSwap=true
/// ③ hasClose(纯平仓, 非观察日) → autoSwap=false
/// ④ 普通日 → 复制上一日终并计提当日新增
/// 注意:hasSwap/hasClose 是交易级标志(整笔合约当天有无事件),
/// observationInterval 是腿级(本条利息腿当天是否观察日)——粒度不同,分派依赖此区分。
/// 生产分派点:SwapEodPositionService.DealInterests(分派结构接线前为影子规格,见其分派处注释)。
/// </summary>
public enum InterestEodScenario
{
ManualSwap, // ① 手工互换:压制观察日自动结息
AutoSettleWithClose, // ② 观察日 + 当日平仓 (autoSwap=true)
AutoSettle, // ② 观察日 + 当日无平仓
CloseOnly, // ③ 非观察日 + 当日平仓 (autoSwap=false)
RollForward, // ④ 普通日滚动
}
public static class InterestEodScenarioDispatch
{
/// <summary>三分量布尔 → 场景(8 组合表驱动见 InterestEodScenarioDispatchTest)。</summary>
public static InterestEodScenario ResolveInterestScenario(bool hasInterval, bool hasSwap, bool hasClose)
{
if (hasSwap)
return InterestEodScenario.ManualSwap;
if (hasInterval)
return hasClose ? InterestEodScenario.AutoSettleWithClose : InterestEodScenario.AutoSettle;
if (hasClose)
return InterestEodScenario.CloseOnly;
return InterestEodScenario.RollForward;
}
/// <summary>
/// 查找利息腿在指定结算日的观察日信息(SwapIntervalList 中 Date==settleDate 且 Settlement==1 的记录)。
/// 观察日即自动结息触发日;返回 null 表示当日非观察日。分派见 ResolveInterestScenario。
/// </summary>
public static IntervalModel FindObservationInterval(swap_position interest, DateTime settleDate)
{
return interest.SwapIntervalList.FirstOrDefault(x => x.Date == settleDate && x.Settlement == 1);
}
}
@@ -0,0 +1,185 @@
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.FundingLegs;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace YLErp.Modules.SwapModule.Penalty;
/// <summary>
/// EQD-6977 罚息接缝(纯函数,无 DB 依赖):把罚息金额**并入既有平仓利息流的 InterestFee(其他费用含罚息)**。
/// 不产生独立罚息事件——前端其他费用列/盈亏公式(含 InterestFee)与日终 TdCloseInterestFee 链路天然承接。
///
/// 设计:上帝类(SwapDealService.GetIntradayUnwindInterests)仅注入三个外部依赖委托——
/// getSpread(加点利差)/ getPreEod(上一日终快照行)/ tryGetFixing(定盘取价),本类零 DB 耦合、可 headless 单测。
///
/// 复利承接量(精确续接口径的关键)**必须取实际计息状态**,严禁冻结利率重放推导:
/// 已并复利本金 capitalized = max(0, preEod.TdInterestPrincipal×份额 closePrincipal) —— 实际滚动复利基数中已并入部分;
/// 段内已计利息 carryIn = 正常平仓流实结 InterestAmount 已并复利本金 —— 最近重置日后实际已计利息;
/// 无 preEod(首日平仓):已并复利本金=0、段内已计利息=实结金额。
/// 逐腿全程 trace 落盘(SwapCalcTrace),供计算过程分析与错误定位。
/// </summary>
public static class PenaltyInterestFeeMerger
{
/// <summary>
/// 对每条融资腿:解析冻结利率 → 以实际计息状态推导承接量 → 计算罚息 → 并入该腿正常平仓利息事件的 InterestFee。
/// 取不到冻结利率(浮动腿缺价且无 preEod)时跳过该腿(不阻断正常平仓),留 trace。
/// </summary>
public static void Merge(
trade td,
List<swap_position> fundingPositions,
List<swap_flow_event> interests,
DateTime unwindDate,
int annualDays,
bool unwindDaySettled,
bool maturityCalcLast,
decimal posiNotionalValue,
decimal closePosiNotionalValue,
decimal closePercent,
Func<swap_position, decimal> getSpread,
Func<swap_position, eod_swap_position?> getPreEod,
Func<DateTime, string, decimal?> tryGetFixing,
AccrualTrace? trace = null)
{
if (td.ExerciseDate == null)
{
trace?.Note("PENALTY|跳过 交易无到期日(ExerciseDate=null)");
return;
}
var maturityDate = td.ExerciseDate.Value;
foreach (var position in fundingPositions)
{
// 正常平仓利息流(GetInterests 刚产出)——段内已计利息的事实源与罚息并入目标
var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id);
if (normalEvent == null)
{
trace?.Note($"PENALTY|融资腿{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)");
continue;
}
// 复用 GetInterests 的本金口径(mode2 无条件覆盖 / mode9 全平兜底,见其根因位置注释)
var mode = (InterestModeEnum)position.InterestMode;
var r = FundingLegStrategyFactory.Get(mode)
.CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePercent);
decimal closePrincipal = r.ClosePrincipal;
if (mode == InterestModeEnum.
|| (mode == InterestModeEnum. && posiNotionalValue == 0m))
{
closePrincipal = closePosiNotionalValue;
}
var share = r.PosiPrincipal > 0m ? Math.Min(1m, closePrincipal / r.PosiPrincipal) : 1m;
var isCompound = position.InterestType == (int)InterestTypeEnum.;
// 冻结利率:前一晚收盘在役利率优先(preEod.FloatRate),无快照再按取价日=unwindDate-1 所在区间取定盘
FundingLegRate frozenRate;
string rateSource;
var preEod = getPreEod(position);
try
{
frozenRate = PenaltyLegRateResolver.ResolveFrozenRate(
position, getSpread(position), preEod?.FloatRate, unwindDate,
d => tryGetFixing(d, position.FloatRateUnderlyingCode));
// 来源标签须反映实际路径:固定腿不取价(利差即冻结利率);浮动腿才有快照/取价之分
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
rateSource = "固定腿利差(不取价)";
else if (preEod != null)
rateSource = $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}";
else
rateSource = "定盘取价(unwindDate-1区间)";
}
catch (Exception ex)
{
trace?.Note($"PENALTY|融资腿{position.id} 跳过 冻结利率解析失败:{ex.Message}");
continue;
}
// 复利承接:实际滚动基数中已并入部分(已并复利本金)+ 段内实际已计利息(段内已计利息)。单利无并本金语义恒 0。
var (capitalized, carryIn) = isCompound
? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace)
: (0m, 0m);
var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound);
// 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate
var penalty = SwapPenaltyInterestCalculator.CalcPenaltyAmount(
position, closePrincipal, unwindDate, maturityDate,
unwindDaySettled, maturityCalcLast,
capitalized, carryIn,
frozenRate, policy, position.PosiStartDate, trace);
penalty = InterestMath.Round(penalty, InterestMath.FundingLegPrecision);
var feeBefore = normalEvent.InterestFee;
normalEvent.InterestFee += penalty;
normalEvent.InterestClosePnL += penalty * DirectionRatio.ReceivePay(position.InterestDirection);
trace?.Note(
$"PENALTY|融资腿{position.id} 完成 mode={mode} {(isCompound ? "" : "")} " +
$"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " +
$"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " +
$"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " +
$"承接[已并复利本金]={capitalized:F4} [段内已计利息]={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " +
$"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}");
}
}
/// <summary>
/// 复利承接量:已并复利本金(实际滚动基数中已并入部分)+ 段内已计利息(最近重置日后实际已计,= 实结 − 已并复利本金)。
///
/// 已并复利本金的取值依赖平仓日是否为重置日、有无日终快照(数据契约):
/// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+已并复利本金),直接作差;
/// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest)
/// 会把它写为末次并本金后的基数(=被平份额本金+已并复利本金),同样是实际值而非推导值;
/// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取
/// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。
/// </summary>
private static (decimal Capitalized, decimal CarryIn) ResolveCompoundCarry(
swap_position position, swap_flow_event normalEvent, eod_swap_position? preEod,
decimal closePrincipal, decimal share, DateTime unwindDate, AccrualTrace? trace)
{
var periodDays = position.interest_rest_days ?? 1;
var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays);
var capitalized = 0m;
if (unwindOnResetDay)
{
capitalized = (preEod?.InterestIncomeSum ?? 0m) * share;
if (preEod != null)
trace?.Note(
$"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " +
$"昨日待实现利息InterestIncomeSum={preEod.InterestIncomeSum:F4} ×share={share:P4} → 已并复利本金={capitalized:F4}");
if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays)
trace?.Note($"PENALTY|融资腿{position.id} 注意 无preEod且平仓日=重置日:已并复利本金退化0(此前重置并入额缺失,请核对日终归档完整性)");
}
else if (preEod != null)
{
// 段中平仓+有快照(复利承接主路径):已并复利本金 = 快照滚动基数×份额 − 平仓本金。全程留推导——
// 结果异常时凭此行即可区分"快照基数错 / share错 / 平仓本金错"三因,不必反推。
var rawCarry = preEod.TdInterestPrincipal * share - closePrincipal;
capitalized = Math.Max(0m, rawCarry);
trace?.Note(
$"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " +
$"滚动基数TdInterestPrincipal={preEod.TdInterestPrincipal:F4} ×share={share:P4} −平仓本金{closePrincipal:F4} = {rawCarry:F4} → 已并复利本金={capitalized:F4}" +
(rawCarry < 0m ? "(原始差为负已钳0:快照滚动基数×份额小于平仓本金,疑部分平仓比例与快照归档口径不一致,请核对eod_swap_position.TdInterestPrincipal" : ""));
}
else
{
capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal);
trace?.Note(
$"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → 已并复利本金={capitalized:F4}");
// 兜底已并复利本金=0 但账龄已过重置周期:复利每周期并本,理应 >0——多为 interestWindowEmpty
// (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。
var ageDays = (unwindDate - position.PosiStartDate).Days;
if (capitalized == 0m && ageDays >= periodDays)
trace?.Note(
$"PENALTY|融资腿{position.id} 注意 无preEod兜底已并复利本金=0但账龄{ageDays}天≥重置周期{periodDays}天:" +
$"事件基数{normalEvent.InterestPrincipal:F2}=平仓本金{closePrincipal:F2}(疑似interestWindowEmpty种子未重放/日终归档缺失," +
$"请核对swap_flow_event.InterestPrincipal重放回写与eod_swap_position归档)");
}
// 已并复利本金不得超过实结金额(数据异常时钳制并留痕,避免负的段内已计利息进入计息)
if (capitalized > Math.Max(0m, normalEvent.InterestAmount))
{
trace?.Note($"PENALTY|融资腿{position.id} 注意 承接已并复利本金钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum");
capitalized = Math.Max(0m, normalEvent.InterestAmount);
}
return (capitalized, normalEvent.InterestAmount - capitalized);
}
}
@@ -0,0 +1,48 @@
using YLErp.Derivatives.Interest;
using YLErp.Modules.SwapModule.Accrual;
namespace YLErp.Modules.SwapModule.Penalty;
/// <summary>
/// EQD-6977 罚息冻结利率解析(纯函数)。
///
/// 规则(需求 2.2.2):剩余期限利率冻结为「最后一个重置区间」的 FR007 定盘值——
/// 终止日恰为重置日且下午已出新价时,仍取上一重置区间(边缘场景显式落地)。
///
/// 冻结来源优先级:
/// 1. preEod.FloatRate——上一日终快照即昨日「实际在役」利率(GetFloatRate 非重置日正是沿用它),
/// 天然覆盖重置日下午边缘;且避开 td.StartDate / PosiStartDate 双锚点推导(见 GetFloatDate 锚点注记);
/// 2. 无 preEod(首日平仓等):取价日 = GetFixingDate(unwindDate-1)-1 所在重置区间的定盘,
/// interest_rule 0=当前营业日/-1=前一营业日由 IndexFixerBase 统一处理)。
/// 固定腿利率本即冻结,直接 Fixed;剩余期限的加点利差由调用方按 SwapIntervalList 取 as-of 平仓日值传入。
/// </summary>
public static class PenaltyLegRateResolver
{
/// <summary>
/// 解析罚息窗口的冻结 all-in 利率。
/// </summary>
/// <param name="position">利息腿(融资腿,非保证金)</param>
/// <param name="spread">加点利差(调用方按 SwapIntervalList 取 as-of unwindDate 值,同 GetFixedRate 口径)</param>
/// <param name="preEodFloatRate">上一日终快照 FloatRate;无 preEod 传 null</param>
/// <param name="unwindDate">提前终止日</param>
/// <param name="tryGetFixing">定盘取价委托(测试可注入);入参=取价日,无价返回 null</param>
public static FundingLegRate ResolveFrozenRate(
swap_position position,
decimal spread,
decimal? preEodFloatRate,
DateTime unwindDate,
Func<DateTime, decimal?> tryGetFixing)
{
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
return FundingLegRate.Fixed(spread);
if (preEodFloatRate.HasValue)
return FundingLegRate.Floating(spread, preEodFloatRate.Value);
var fixingDate = IndexFixerBase.GetFixingDate(unwindDate.AddDays(-1), position.interest_rule);
var fixing = tryGetFixing(fixingDate);
if (!fixing.HasValue)
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格");
return FundingLegRate.Floating(spread, fixing.Value);
}
}
@@ -0,0 +1,137 @@
using YLErp.Modules.SwapModule.Accrual;
namespace YLErp.Modules.SwapModule.Penalty;
/// <summary>
/// EQD-6977 平仓罚息计算器(纯函数)——返回罚息金额。
///
/// 口径(2026-08-20 裁定,评审 11.5):**精确续接**。唯一近似 = 未来 FR007 不可得——
/// 剩余窗口一律用「前一晚收盘在役利率」(preEod.FloatRate,由 PenaltyLegRateResolver 解析);
/// 其余与正常到期计息**丝毫不能差**:每 7 天重置节奏照旧、并本金照旧、单/复利走同一套
/// Accrual 纯函数。金标准恒等式(验收基准):
///
/// 全期利息 = 平仓日已结利息(正常平仓流) + 罚息金额(本方法)
///
/// 边界规格(经金标准恒等式测试钉死):
/// - IncludeStart = !unwindDaySettled:正常结算已计平仓日(算尾)→ 罚息自次日起;不算尾 → 含平仓日;
/// - IncludeEnd = maturityCalcLast:到期日沿用交易自身算尾约定(非本次平仓的 newCalcLast)。
///
/// 复利承接(平仓日落在重置段中间时与全期轨迹逐日对齐的两个量,**必须来自实际计息状态**,
/// 由调用方 PenaltyInterestFeeMerger 从 preEod 快照与正常平仓流实结金额推导——严禁冻结利率重放推导,
/// FR007 有真实利率历史时重放值必偏):
/// - capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即加入计息基数;
/// - carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入并持续留在基数。
/// 二者之和 = 被平部分的平仓日实结利息(正常平仓流 InterestAmount)。
///
/// 产物为金额,由接缝并入既有利息事件的 InterestFee(其他费用含罚息);不产生独立罚息事件。
/// 仅融资腿;保证金腿(MarginModes)与浮动端 P&amp;L 不进入本模块。
/// </summary>
public static class SwapPenaltyInterestCalculator
{
/// <summary>
/// 计算罚息窗口 [unwindDate, maturityDate] 的罚息金额。
/// </summary>
/// <param name="position">被平的融资腿</param>
/// <param name="closePrincipal">被平部分计息本金(部分平仓仅算被平份额)</param>
/// <param name="unwindDate">提前终止日(窗口起点)</param>
/// <param name="maturityDate">合约原始到期日(窗口终点,= td.ExerciseDate</param>
/// <param name="unwindDaySettled">正常平仓利息是否已计平仓日(effectiveCalcLast = calcLast || newCalcLast</param>
/// <param name="maturityCalcLast">交易到期日算尾约定(tradeExtend.CalcLast</param>
/// <param name="capitalizedInterest">复利承接①:已并入最近重置日的累计利息(被平份额),窗口首日起即入基数;单利传 0</param>
/// <param name="carryInInterest">复利承接②:最近重置日后已计至平仓日的利息(被平份额),首个窗口重置日并入;单利传 0</param>
/// <param name="frozenRate">冻结利率(PenaltyLegRateResolver.ResolveFrozenRate 产物 = 前一晚收盘在役利率)</param>
/// <param name="policy">计息政策(单复利/重置周期/年化天数;Convention 由本方法覆盖)</param>
/// <param name="resetAnchor">重置日锚点 = position.PosiStartDate(与正常计息重放网格一致,勿用 td.StartDate</param>
/// <param name="trace">计息轨迹(可选,SwapCalcTrace 落盘)</param>
public static decimal CalcPenaltyAmount(
swap_position position,
decimal closePrincipal,
DateTime unwindDate,
DateTime maturityDate,
bool unwindDaySettled,
bool maturityCalcLast,
decimal capitalizedInterest,
decimal carryInInterest,
FundingLegRate frozenRate,
AccrualPolicy policy,
DateTime resetAnchor,
AccrualTrace? trace = null)
{
var boundary = AccrualBoundary.Of(includeStart: !unwindDaySettled, includeEnd: maturityCalcLast);
var allInRate = frozenRate.AllInRate;
return policy.IsCompound
? AccrueCompound(closePrincipal, capitalizedInterest, carryInInterest, unwindDate, maturityDate, boundary, policy, resetAnchor, allInRate, trace)
: AccrueSimple(closePrincipal, unwindDate, maturityDate, boundary, policy, allInRate, trace);
}
/// <summary>
/// 复利罚息计息:即使冻结利率为单值,也必须按重置日分段(并本金发生在分段边界),每段同一冻结利率。
/// notional = 本金 + 已并入最近重置日的利息(capitalizedInterest):全期轨迹中当前重置段的滚动基数,
/// 段内每一天都在其上计息——平仓日落在段中间时与全期逐日对齐的关键。carryInInterest 在首个窗口重置日并入。
/// </summary>
private static decimal AccrueCompound(
decimal closePrincipal, decimal capitalizedInterest, decimal carryInInterest,
DateTime unwindDate, DateTime maturityDate, AccrualBoundary boundary, AccrualPolicy policy,
DateTime resetAnchor, decimal allInRate, AccrualTrace? trace)
{
var segments = BuildFrozenSegments(unwindDate, maturityDate, policy.ResetPeriodDays, resetAnchor, allInRate);
var r = CompoundInterestAccrual.AccruePeriod(
notional: closePrincipal + capitalizedInterest,
segmentRates: segments,
startDate: unwindDate,
endDate: maturityDate,
boundary: boundary,
annualDays: policy.AnnualDays,
isAnnualized: policy.IsAnnualized,
resetCarryInterest: 0m,
realizedInterest: 0m,
unwindFraction: 1m,
finalBasis: out _,
trace: trace,
carryInInterest: carryInInterest);
return r.Accrued;
}
/// <summary>
/// 单利罚息计息:无并本金语义,冻结利率即单段全程(需求 2.2.1 公式:利率 × 名义本金 × 剩余天数 / 计息基准)。
/// </summary>
private static decimal AccrueSimple(
decimal closePrincipal, DateTime unwindDate, DateTime maturityDate,
AccrualBoundary boundary, AccrualPolicy policy, decimal allInRate, AccrualTrace? trace)
{
var r = SimpleInterestAccrual.AccruePeriod(
priorAccrued: 0m,
notional: closePrincipal,
unwindFraction: 1m,
segmentRates: new List<(DateTime StartDate, decimal Rate)> { (unwindDate, allInRate) },
startDate: unwindDate,
endDate: maturityDate,
priorValueDate: unwindDate.AddDays(-1),
boundary: boundary,
annualDays: policy.AnnualDays,
isAnnualized: policy.IsAnnualized,
trace: trace);
return r.Accrued;
}
/// <summary>
/// 复利冻结分段:段边界 = 窗口内重置日((d - anchor) % period == 0,对齐 IsResetDay 公式),
/// 每段填同一冻结利率。首段必为 (unwindDate, rate)[start,end] 含端点的重置日也生成段
/// (末段起点==到期日时由 AccruePeriod 的边界决定是否计息)。
/// </summary>
private static List<(DateTime StartDate, decimal Rate)> BuildFrozenSegments(
DateTime start, DateTime end, int periodDays, DateTime anchor, decimal rate)
{
if (periodDays <= 1)
return new List<(DateTime, decimal)> { (start, rate) };
var segments = new List<(DateTime StartDate, decimal Rate)> { (start, rate) };
for (var d = start.AddDays(1); d <= end; d = d.AddDays(1))
{
if (SwapDealService.IsResetDay(d, anchor, periodDays))
segments.Add((d, rate));
}
return segments;
}
}
@@ -9,7 +9,6 @@ using YLErp.Abstract;
using YLErp.BLL;
using YLErp.BLL.Eod;
using YLErp.Helpers;
using YLErp.Model.HengTaiModel;
using YLErp.Modules.ExchangeTradeModule;
namespace YLErp.Modules.SwapModule
+545 -52
View File
@@ -10,6 +10,7 @@ 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;
@@ -153,6 +154,301 @@ namespace YLErp.Modules.SwapModule
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)
@@ -201,6 +497,48 @@ namespace YLErp.Modules.SwapModule
#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>
@@ -220,6 +558,11 @@ namespace YLErp.Modules.SwapModule
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();
@@ -254,9 +597,15 @@ namespace YLErp.Modules.SwapModule
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional);
// 现金分红会调整 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%)。
@@ -364,6 +713,10 @@ namespace YLErp.Modules.SwapModule
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();
@@ -400,7 +753,9 @@ namespace YLErp.Modules.SwapModule
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional);
unwindData.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;
@@ -444,7 +799,7 @@ namespace YLErp.Modules.SwapModule
/// <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)
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)
@@ -475,17 +830,17 @@ namespace YLErp.Modules.SwapModule
var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); // 当前平仓前的实时剩余本金
var posiNotionalValue = stockEqvNotional * closePercent;// 本次平仓名义本金
var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); // 上一日终的浮动端本金
var grossPrice = realPostitions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice;
var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId
&& x.UnwindDate == unwindDate
&& eventTypes.Contains(x.EventType)
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));
closePercent, eventType, tdClose, orginPv, add: true, newCalcLast: false, closeList,
isPenaltyInterest));
return interests;
}
@@ -618,10 +973,41 @@ namespace YLErp.Modules.SwapModule
/// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。
/// </summary>
public List<swap_flow_event> GetIntradayUnwindInterests(InterestCalcRequest req)
=> GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions,
{
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,
@@ -643,8 +1029,10 @@ namespace YLErp.Modules.SwapModule
{
List<swap_flow_event> interests = new List<swap_flow_event>();
var annualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
bool calcFirst = tradeExtend?.ExtendObj.InterestCalcMode?.StartsWith("1") ?? true;
bool calcLast = tradeExtend?.ExtendObj.InterestCalcMode.EndsWith("1") ?? true;
bool calcFirst = tradeExtend?.ExtendObj.CalcFirst ?? true;
bool calcLast = tradeExtend?.ExtendObj.CalcLast ?? true;
// 计息到尾日(含平仓场景覆盖):交易本身算尾 或 本次平仓指定算尾(newCalcLast)
bool effectiveCalcLast = calcLast || newCalcLast;
foreach (var position in positions)
{
// 初始化持仓信息
@@ -654,8 +1042,9 @@ namespace YLErp.Modules.SwapModule
// 计算计息区间
int interestPeriod = position.interest_rest_days ?? 1;
// true 跳过 不计利息; false 正常利息
bool swap = InitInterestDate(unwindDate, preDealDate, td, tdClose, out DateTime startDate, out DateTime endDate);
// 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); // 做精度调整 原数据有精度误差
@@ -669,7 +1058,7 @@ namespace YLErp.Modules.SwapModule
// 保证金腿: 计息基数 = InterestPrincipalFix(保证金余额),无融资腿差分公式与 orginPv 维度 hack
interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate,
position.InterestPrincipalFix * closePrecent, position.InterestPrincipalFix,
closePrecent, annualDays, calcFirst, calcLast || newCalcLast, preEodPosition, eventType, add, settment, swap));
closePrecent, annualDays, calcFirst, effectiveCalcLast, preEodPosition, eventType, add, settment, interestWindowEmpty));
continue;
}
@@ -696,7 +1085,7 @@ namespace YLErp.Modules.SwapModule
closePrincipal = closePosiNotionalValue;
}
decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone);
decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, interestWindowEmpty, positionClone, effectiveCalcLast);
// 根据场景计算利息
if (settment)
@@ -712,8 +1101,8 @@ namespace YLErp.Modules.SwapModule
? GetConsumedInterest(td.id, position.id, endDate)
: 0m;
interests.Add(CalcUnwindInterest(td, valueDate, endDate, positionClone, rate, floatRate, posiPrincipal,
closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, swap, orginPv, calcFirst,
calcLast || newCalcLast, consumedInterest));
closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, interestWindowEmpty, orginPv, calcFirst,
effectiveCalcLast, consumedInterest));
}
}
//当日有平仓或互换记录时,避免重复结算
@@ -733,7 +1122,7 @@ namespace YLErp.Modules.SwapModule
item.InterestClosePnL = 0;
}
}
else if (!calcLast && !newCalcLast)
else if (!effectiveCalcLast)
{
// 平仓不算尾:扣除已结算的利息(算尾时利息已包含关闭日,无重叠)
var closePnl = closeEvent.Sum(s => s.InterestClosePnL);
@@ -844,29 +1233,73 @@ namespace YLErp.Modules.SwapModule
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 swap, swap_position positionClone)
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(
days % period == 0 ? endDate : startDate, position.interest_rule);
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 && days % period != 0)
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 (!swap) throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格");
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;
}
@@ -968,12 +1401,12 @@ namespace YLErp.Modules.SwapModule
/// 定位:SwapCalcTrace 落盘 AccrueEod/AccrualPeriod 的 notional/days/rate/accrued;盘中 accrualBasis 可从 trace 的 notional 反推。
/// </remarks>
/// <param name="settment">true=收盘归档(EOD)false=盘中平仓/互换。</param>
/// <param name="swap">互换事件(仅盘中生效,true 时利息归零,同 InitSwapDealInterest)。</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 swap)
eod_swap_position preEod, int eventType, bool add, bool settment, bool interestWindowEmpty)
{
// 当日是否计息(算头算尾)——同 CalcEodInterest
bool calcToday = true;
@@ -1009,8 +1442,8 @@ namespace YLErp.Modules.SwapModule
UnwindDate = settment ? valueDate : endDate
};
// 互换事件:利息归零(同 InitSwapDealInterest
if (swap && !settment)
// 计息窗口为空:利息归零(同 InitSwapDealInterest;典型场景=互换事件
if (interestWindowEmpty && !settment)
{
interest.InterestAmount = 0m;
interest.TdInterestAmount = 0m;
@@ -1075,21 +1508,22 @@ namespace YLErp.Modules.SwapModule
/// <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 swap, decimal orginPv, bool calcFirst, bool calcLast, decimal consumedInterest = 0m)
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;
preEod.ValueDate = td.StartDate.Value;
if (calcFirst)
{
preEod.ValueDate = preEod.ValueDate.AddDays(-1);
}
// 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, swap, posiPrincipal,
return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, interestWindowEmpty, posiPrincipal,
closePrincipal, closePercent, annualDays, eventType, preEod,
orginPv, calcFirst, calcLast, consumedInterest);
}
@@ -1139,7 +1573,7 @@ namespace YLErp.Modules.SwapModule
/// <param name="rate">计息年化利率</param>
/// <param name="position">利息腿</param>
/// <param name="add">是否新增</param>
/// <param name="swap">是否已互换</param>
/// <param name="interestWindowEmpty">计息窗口为空(InitInterestDate 判定:true=本次不计利息,利率与金额归零;典型场景=互换当日已结息)</param>
/// <param name="preEodPosition">上一日终归档</param>
/// <param name="posiNotionalValue">当日适用名义本金</param>
/// <param name="closePosiNotionalValue">当日平仓名义本金</param>
@@ -1151,7 +1585,7 @@ namespace YLErp.Modules.SwapModule
decimal rate,
swap_position position,
bool add,
bool swap,
bool interestWindowEmpty,
decimal posiNotionalValue,
decimal closePosiNotionalValue,
decimal closePrecent,
@@ -1185,7 +1619,7 @@ namespace YLErp.Modules.SwapModule
// 保证金腿已走 CalcMarginInterest(不经过本方法),orginPv 维度重映射不再需要;
// orginPv 此处仅对融资腿生效(差分公式 accrualBasis = TdInterestPrincipal + posiPrincipal - orginPv)。
if (swap)
if (interestWindowEmpty)
{
interest.InterestAmount = 0; // 利息金额
interest.TdInterestAmount = 0; // 当日新增利息
@@ -1200,12 +1634,12 @@ namespace YLErp.Modules.SwapModule
var floateRate = preEodPosition.FloatRate;
if (position.InterestType == (int)InterestTypeEnum.)
{
var daysFromStart = (endDate - position.PosiStartDate).Days;
var daysFromPreEod = preEodPosition.id != 0
? (endDate - preEodPosition.ValueDate).Days
: 0;
// 不算尾 + 当日即新周期首日 + 未到重置日 ==> 说明这一天应归入下一个计息周期 当天无需单独计息
if (!calcLast && daysFromPreEod == 1 && daysFromStart % (position.interest_rest_days ?? 1) != 0)
if (!calcLast && daysFromPreEod == 1
&& !IsResetDay(endDate, position.PosiStartDate, position.interest_rest_days ?? 1))
{
interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent; // 计息基数
interest.FloatRate = preEodPosition.FloatRate;
@@ -1233,9 +1667,7 @@ namespace YLErp.Modules.SwapModule
if (preEodPosition.id != 0 && closePrecent == 1m)
{
// 【全平专属分支触发标记】(快速定位):设计意图=真全平(尾差一次带走)与观察日恒1全额结息。
// ⚠️ 契约修复暂缓期间,普通部分平仓经 EOD 恒1惯例【仍会进入本分支(重算结果已被裁决
// 证为不落库/不动钱/不进资金,零生产后果);修复落地后部分平仓不再进入——本行日志届时
// 兼作落地验证哨兵(部分平仓出现在此=修复未生效/被回退)。
// 普通部分平仓经 EOD 恒1惯例会进入本分支(重算中间值不进结算现金流);本行日志用于监控进入者分布。
Logger.Info($"[利息-全平专属分支] tradeId={td.id} posiId={position.id} valueDate={valueDate:yyyy-MM-dd} " +
$"closePrecent={closePrecent} preEod.InterestIncomeSum={preEodPosition.InterestIncomeSum}");
// 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。
@@ -1262,7 +1694,7 @@ namespace YLErp.Modules.SwapModule
// 计算截至本次平仓日的累计利息 amountAtEnd
CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue,
interestAtEnd, annualDays, floateRate, closePrecent,
calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest);
calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest, exclusionStart: endDate);
var interestAtPreviousEod = new swap_flow_event { InterestRate = rate };
decimal amountAtPreviousEod = 0m;
decimal tdAmountAtPreviousEod = 0m;
@@ -1305,27 +1737,58 @@ namespace YLErp.Modules.SwapModule
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return fallback;
var fixingDate = IndexFixerBase.GetFixingDate(date, position.interest_rule);
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing))
return fixing != 0m ? fixing : fallback;
{
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)
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);
if (fetchAfterDate == null || resetDate > fetchAfterDate.Value)
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);
@@ -1344,15 +1807,17 @@ namespace YLErp.Modules.SwapModule
/// <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)
ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m, DateTime? exclusionStart = null)
{
var startDate = position.PosiStartDate;
int interestPeriod = position.interest_rest_days ?? 1;
// 分段取率:复利全程重放,每个重置日(含 startDate)取 FR007fetchAfterDate=null)。
// calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移;
// exclusionStart 仅重放补计不算尾漏计利息时非 null(=真实平仓日),锚定尾日跳过取价起点。
var (segmentRates, currentFloat) = BuildSegmentRates(
startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate,
fetchAfterDate: null);
fetchAfterDate: null, calcLast: calcLast, exclusionStart: exclusionStart);
// 纯函数复利计息:分段重置日并本金 + resetCarryInterest + 扣 consumedInterest
var interestTrace = new AccrualTrace();
@@ -1382,7 +1847,7 @@ namespace YLErp.Modules.SwapModule
/// <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)
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;
@@ -1392,9 +1857,10 @@ namespace YLErp.Modules.SwapModule
var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv;
// 分段取率:仅 ValueDate 之后的重置日才取 FR007fetchAfterDate=ValueDate)。
// calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移。
var (segmentRates, currentFloat) = BuildSegmentRates(
startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate,
fetchAfterDate: preEodPosition.ValueDate);
fetchAfterDate: preEodPosition.ValueDate, calcLast: calcLast, exclusionStart: exclusionStart);
// 纯函数计息:Accrued=缩放累计(InterestAmount)AccruedToday=未缩放累计(TdInterestAmount)
var interestTrace = new AccrualTrace();
@@ -1526,6 +1992,18 @@ namespace YLErp.Modules.SwapModule
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); //去掉平仓收盘限制
@@ -1587,6 +2065,8 @@ namespace YLErp.Modules.SwapModule
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;
@@ -1688,10 +2168,15 @@ namespace YLErp.Modules.SwapModule
int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType);
int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection);
// + 付息日>上日日终且小于等于平仓日期的分红数据
var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode);
decimal tax = um.ValueAddedTax ?? 0;
// 期间付息和公司行为现金分红可能同时命中;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);
@@ -1774,6 +2259,9 @@ namespace YLErp.Modules.SwapModule
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); //去掉平仓收盘限制
@@ -1810,6 +2298,9 @@ namespace YLErp.Modules.SwapModule
{
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
// 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Stock/Fund EOD 重建数量和价格。
// 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线;
// 直接提交路径的 EOD 复核不覆盖此审批路径。
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
UnwindNormalizer.NormalizeEventUnwindDate(swapEvent.unwindData);
UnwindNormalizer.NormalizeNotionalValues(swapEvent.unwindData);
@@ -1904,6 +2395,8 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
UnwindNormalizer.NormalizeEventUnwindDate(unwindData);
// 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Stock/Fund EOD 复核。
// 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。
if (eventType == (int)SwapEventTypeEnum.)
{
ValidateIncomeValueDate(unwindData, td);
@@ -175,7 +175,15 @@ namespace YLErp.Modules.SwapModule
var tradeContract = DbContext.trade_contract_r.Where(x => x.IsValid && x.TradeId == tradeId && x.Type == ContractTypeEnum.Trade).FirstOrDefault();
if (tradeContract == null)
{
return "";
// 定位要点:此处历史上静默 return "",前端当"已发送"但实际未发任何邮件。
// 打出 warn 并把失败原因透传给前端,便于区分"没生成过交易确认书"与"被重生成作废(IsValid=false)"。
var rows = DbContext.trade_contract_r.Where(x => x.TradeId == tradeId).Select(x => new { x.Type, x.IsValid, x.ContractCode }).ToList();
var detail = rows.Any()
? string.Join("", rows.Select(r => $"Type={r.Type},IsValid={r.IsValid},Code={r.ContractCode}"))
: "trade_contract_r 无任何行";
LogFactory.GetLogger("SwapEndConfirm").Error(
$"发送交易确认书邮件中止: tradeId={tradeId} 无有效交易确认书(需 IsValid=true 且 Type={ContractTypeEnum.Trade}); 库内实际行: {detail}");
return "发送失败:未找到有效交易确认书,请先生成后再发送";
}
tradeContract.send_email_result = "发送中";
DbContext.SaveChanges();
@@ -248,5 +256,47 @@ namespace YLErp.Modules.SwapModule
}
return "未配置邮件接口地址";
}
/// <summary>
/// EQD-5320 批量发送结算确认书邮件:服务端代理 bond-oms /swap/email/settle/batchSend。
/// 前端原直连 /trs_hub_api 反向代理(依赖 nginx 配置,未配的环境 404)——统一改走本代理,
/// 与债券计算器/SendEmail 同一条 BondOmsInterface_BaseUrl 出口,不再依赖前端网关。
/// 返回 空串=成功;非空=失败原因(透传 bond-oms message)。
/// </summary>
public string BatchSendSettleEmail(List<long> swapFlowEventIds)
{
var baseUrl = Environment.GetEnvironmentVariable("BondOmsInterface_BaseUrl");
if (string.IsNullOrEmpty(baseUrl))
{
return "未配置邮件接口地址(BondOmsInterface_BaseUrl)";
}
const string url = "/swap/email/settle/batchSend";
var logger = LogFactory.GetLogger("SwapEndConfirm");
var idsDesc = string.Join(",", swapFlowEventIds);
try
{
var result = new HttpHelper(baseUrl, null)
.PostRequestNoAuth<OmsSettleBatchSendReq, SendEmailResult>(url,
new OmsSettleBatchSendReq { swapFlowEventIds = swapFlowEventIds })
.Result;
logger.Info($"批量发送结算确认书: url={baseUrl}{url} ids=[{idsDesc}] → success={result?.success} message={result?.message}");
if (result == null)
{
return "邮件服务无响应";
}
return result.success ? "" : (result.message ?? "发送失败");
}
catch (Exception ex)
{
logger.Error($"批量发送结算确认书异常: url={baseUrl}{url} ids=[{idsDesc}]", ex);
return "请求邮件服务异常:" + ex.GetBaseException().Message;
}
}
/// <summary>bond-oms SettleEmailSendParam 契约(字段名须与 Java 端一致,Jackson 按 swapFlowEventIds 绑定)</summary>
private class OmsSettleBatchSendReq
{
public List<long> swapFlowEventIds { get; set; }
}
}
}
File diff suppressed because it is too large Load Diff
@@ -164,6 +164,12 @@ namespace YLErp.Modules.SwapModule
&& f.UnwindDate == valueDate && f.PayDirection > 0 && eventTypes.Contains(f.EventType) && f.DataState == (int)SwapFlowDateStateEnum.
select f;
var flowEventList = flowQuery.ToList();
// 定位要点:发送范围按【客户+日期】整组扩张——用户勾选 A 事件,同客户同日的 B/C 事件会被一并拉入,
// 后续校验报错常是被拉入的事件缺确认书(用户以为"刚生成了还报错")。此日志把勾选与扩张结果对齐打出来。
LogFactory.GetLogger("SwapEventEmail").Info(
$"资金提示邮件-发送范围: 用户勾选事件[{string.Join(",", EventEmailEmails.Select(s => s.event_id))}] " +
$"客户[{string.Join(",", clientIds.Distinct())}] 日期[{valueDate:yyyy-MM-dd}] " +
$"扩张后实际处理事件[{string.Join(",", flowEventList.Select(f => $"{f.id}:{f.SwapTradeNo}:" + (f.EventType == (int)SwapFlowEventTypeEnum.开仓 ? "" : f.EventType == (int)SwapFlowEventTypeEnum.平仓 ? "" : "T" + f.EventType)))}]");
var docs = new List<SwapTradeContractDto>();
var openFlowEventList = flowEventList.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.).ToList();
var closeFlowEventList = flowEventList.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.).ToList();
@@ -216,6 +222,22 @@ namespace YLErp.Modules.SwapModule
}
if (tradeNumbers.Any())
{
// 定位要点:报错只带交易编号不够定位。补打每笔缺失交易的 trade_contract_r 实际行状态
//(无行=从未生成 / IsValid=false=被重生成作废 / document 行缺失=确认书文档被清理),命中哪种一眼可辨。
var missTradeIds = flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).Select(s => s.SwapTradeId).Distinct().ToList();
var missRows = DbContext.trade_contract_r.Where(x => missTradeIds.Contains(x.TradeId)).ToList();
var missCodes = missRows.Select(r => r.ContractCode).Distinct().ToList();
var docCodes = DbContext.trade_contract_document.Where(d => missCodes.Contains(d.Code)).Select(d => d.Code).Distinct().ToList();
var detail = string.Join("", missTradeIds.Select(tid =>
{
var tradeNo = flowEvents.First(f => f.SwapTradeId == tid).SwapTradeNo;
var rows = missRows.Where(x => x.TradeId == tid).ToList();
if (!rows.Any()) return $"{tradeNo}: trade_contract_r 无任何行(从未生成交易确认书)";
return $"{tradeNo}: " + string.Join(",", rows.Select(r =>
$"[Type={r.Type},IsValid={r.IsValid},Code={r.ContractCode},doc存在={(docCodes.Contains(r.ContractCode) ? "" : "")}]"));
}));
LogFactory.GetLogger("SwapEventEmail").Error(
$"交易确认书校验失败: 交易编号[{string.Join(",", tradeNumbers.Distinct())}]找不到有效交易确认书; 库内明细: {detail}");
throw new Exception($"交易编号为{string.Join(",", tradeNumbers.Distinct())}找不到有效的交易确认书附件,请检查或生成后再发送邮件");
}
return list;
@@ -265,6 +287,24 @@ namespace YLErp.Modules.SwapModule
}
if (tradeNumbers.Any())
{
// 定位要点:结算确认书按 SwapFlowEventId 精确匹配。补打每笔缺失平仓事件的合同行状态——
// 常见原因是生成时的扩张查询覆盖了本事件但用户实际生成的是另一批,或重生成后 IsValid 被作废。
var missEventIds = flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).Select(s => s.id).Distinct().ToList();
var missRows = DbContext.trade_contract_r.Where(x => missEventIds.Contains(x.SwapFlowEventId ?? 0)).ToList();
var missTradeIds = flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).Select(s => s.SwapTradeId).Distinct().ToList();
var tradeTypeRows = DbContext.trade_contract_r.Where(x => missTradeIds.Contains(x.TradeId) && x.Type == ContractTypeEnum.Clearing).ToList();
var detail = string.Join("", flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).GroupBy(g => g.SwapTradeNo).Select(g =>
{
var rows = missRows.Where(x => x.SwapFlowEventId == g.First().id).ToList();
var byTrade = tradeTypeRows.Where(x => x.TradeId == g.First().SwapTradeId).ToList();
return $"{g.Key}(事件{g.First().id}): " + (rows.Any()
? string.Join(",", rows.Select(r => $"[Type={r.Type},IsValid={r.IsValid},Code={r.ContractCode}]"))
: $"按事件无行; 按交易的Clearing行=" + (byTrade.Any()
? string.Join(",", byTrade.Select(r => $"[SwapFlowEventId={r.SwapFlowEventId},IsValid={r.IsValid},Code={r.ContractCode}]"))
: "无"));
}));
LogFactory.GetLogger("SwapEventEmail").Error(
$"结算确认书校验失败: 交易编号[{string.Join(",", tradeNumbers.Distinct())}]找不到有效结算确认书(SwapFlowEventId匹配); 库内明细: {detail}");
throw new Exception($"交易编号为{string.Join(",", tradeNumbers.Distinct())}找不到有效的结算确认书附件,请检查或生成后再发送邮件");
}
return list;
@@ -764,7 +804,11 @@ namespace YLErp.Modules.SwapModule
if (System.IO.File.Exists(fName))
{ return fName; }
else
{ return null; }
{
// 定位要点:物理文件缺失历史上静默返回 null(附件列表混入 null,发送结果不可预期),补 warn 便于发现"库里行在、盘上文件丢"。
LogFactory.GetLogger("SwapEventEmail").Error($"邮件附件物理文件缺失: trade_contract_document.Paths={baseName} 映射后={fName} 不存在");
return null;
}
}
}
}
@@ -1,6 +1,7 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
@@ -206,16 +207,89 @@ namespace YLErp.Modules.SwapModule
return events;
}
/// <summary>
/// 获取交易操作历史
/// 获取交易操作历史。登记日创建但尚未到 EffectiveDate 的公司行为事件也保留,
/// 由 EventData.Applied=false 表示“待生效”,保证审计日志完整可追溯。
/// </summary>
/// <param name="tradeId">交易id</param>
/// <returns></returns>
public List<swap_event> GetOpreationHistorys(int tradeId)
{
List<swap_event> list = DbContext.swap_event.Where(x => x.SwapTradeId == tradeId).OrderByDescending(o => o.id).ToList();
List<swap_event> list = DbContext.swap_event
.Where(x => x.SwapTradeId == tradeId)
.OrderByDescending(o => o.id)
.ToList();
return list;
}
/// <summary>
/// 将 swap_event.EventData 安全反序列化为公司行为快照。事件为空、EventData
/// 为空白或 JSON 格式不匹配时返回 false 并将 data 置 null,调用方据此保留旧格式记录。
/// </summary>
public static bool TryDeserializeCorporateActionEventData(
swap_event swapEvent,
out CorporateActionEventData data)
{
data = null;
if (swapEvent == null || string.IsNullOrWhiteSpace(swapEvent.EventData))
{
return false;
}
try
{
data = JsonConvert.DeserializeObject<CorporateActionEventData>(swapEvent.EventData);
return data != null;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
/// 公司行为说明使用稳定的键值格式,完整保留调整前后名义本金、价格、数量、
/// 待实现分红和现金流变化,操作历史无需重新计算即可核对。
/// </summary>
public static string BuildCorporateActionEventReason(CorporateActionEventData data)
{
if (data == null)
{
return "公司行为快照为空";
}
// 使用 InvariantCulture 固定小数与日期格式,说明文本不随服务器区域设置变化。
string D(decimal value) => value.ToString(CultureInfo.InvariantCulture);
string Date(DateTime? value) => value.HasValue
? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
: "";
return string.Join("; ", new[]
{
$"公司行为[{data.UnderlyingCode}]",
$"ExDividendDate={Date(data.ExDividendDate)}",
$"EffectiveDate={Date(data.EffectiveDate)}",
$"ExDividendInfoId={data.ExDividendInfoId}",
$"PositionId={data.PositionId}",
$"GiveCashAmount={D(data.GiveCashAmount)}",
$"GiveShareAmount={D(data.GiveShareAmount)}",
$"Split={(data.Split.HasValue ? D(data.Split.Value) : "")}",
$"RationedSharesAmount={D(data.RationedSharesAmount)}",
$"RationedSharesPrice={D(data.RationedSharesPrice)}",
"调整前",
$"BeforeNotional={D(data.BeforeNotional)}",
$"BeforePrice={D(data.BeforePrice)}",
$"BeforeQuantity={D(data.BeforeQuantity)}",
$"BeforePendingDividend={D(data.BeforePendingDividend)}",
"调整后",
$"AfterNotional={D(data.AfterNotional)}",
$"AfterPrice={D(data.AfterPrice)}",
$"AfterQuantity={D(data.AfterQuantity)}",
$"AfterPendingDividend={D(data.AfterPendingDividend)}",
$"CashFlowChange={D(data.CashFlowChange)}",
$"Applied={data.Applied}"
});
}
public void DeleteEvent(int tradeId)
{
var events = DbContext.swap_event.Where(x => x.Invalid && x.SwapTradeId == tradeId).ToList();
@@ -1,6 +1,4 @@
using DotNetDBF;
using ICSharpCode.SharpZipLib.Zip;
using System;
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
@@ -29,32 +27,6 @@ namespace YLErp.Modules.SwapModule
public SwapFlowImportService(YLBaseService baseService) : base(baseService)
{
}
/// <summary>
/// 成交流水导入 dbf
/// </summary>
/// <param name="filePath">压缩包文件路径</param>
/// <param name="fileDir">压缩包解压路径</param>
/// <param name="totalNum">总条数</param>
/// <param name="successNum">成功条数</param>
[Obsolete]
public void ImportSwapTradesFromZip(string filePath, string fileDir, out int totalNum, out int successNum)
{
totalNum = 0;
successNum = 0;
var fileDirPath = ZipHelper.unZipFile(filePath, fileDir, out var msg);
var dbfFiles = GetDbfFiles(fileDirPath);
var swapFlowList = GetDbfDatas(dbfFiles);
if (swapFlowList.Count > 0)
{
DbContext.swap_flow.AddRange(swapFlowList);
DbContext.SaveChanges();
new SysJobService(UserInfo).UpdateJob("互换流水合成持仓", 11, "互换流水导入完成");
Task.Run(() =>
{
RealtimePnlCalc.RealtimeSwapPosition(new OptUserInfo(0, "互换实时持仓服务", OptUserFrom.Service));
});
}
}
/// <summary>
/// 成交流水导入 excel
@@ -170,174 +142,5 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException($"第{rowIndex}行,发生错误:{ex.Message}", ex);
}
}
/// <summary>
/// 获取解压路径下所有符合的dbf文件
/// </summary>
/// <param name="filePath"></param>
/// <returns></returns>
private List<string> GetDbfFiles(string filePath)
{
List<string> files = new List<string>();
var allFiles = Directory.GetFiles(filePath, ".", SearchOption.AllDirectories);
foreach (var file in allFiles)
{
FileInfo _file = new FileInfo(file);
var fileName = _file.Name.ToLower();
//SZ:SJSMX20518.DBF SH:jsmx03_jsx73.518
if ((fileName.StartsWith("sjsmx") && fileName.Contains(".dbf")) || fileName.StartsWith("jsmx"))
{
files.Add(file);
}
}
return files;
}
/// <summary>
/// dbf文件中获取流水数据
/// </summary>
/// <param name="files"></param>
/// <returns></returns>
private List<swap_flow> GetDbfDatas(List<string> files)
{
List<swap_flow> swap_Flows = new List<swap_flow>();
for (var i = 0; i < files.Count; i++)
{
using (var dbf = new DBFReader(files[i]))
{
var columnCount = dbf.Fields.Length;
if (columnCount != 48)
{
continue;
}
bool dbffile = dbf.Fields[0].Name == "MXJSZH";
var swapflows = new List<swap_flow>();
if (dbffile)//SZ
{
swapflows = GetSwapFlowData_SZ(dbf);
}
else //SH
{
swapflows = GetSwapFlowData_SH(dbf);
}
swap_Flows.AddRange(swapflows);
}
}
return swap_Flows;
}
/// <summary>
/// 获取SZ流水数据
/// </summary>
/// <param name="dbf"></param>
/// <returns></returns>
private List<swap_flow> GetSwapFlowData_SZ(DBFReader dbf)
{
List<swap_flow> swap_Flows = new List<swap_flow>();
var count = dbf.RecordCount;
for (var j = 0; j < count; j++)
{
var dbfRecord = dbf.NextRecord();
if (dbfRecord != null)
{
string MXZQLB = dbfRecord[18].ToString().Trim();//证券类别
if (MXZQLB != "00")//A股
{
continue;
}
decimal.TryParse(dbfRecord[12].ToString().Trim(), out decimal tradingQty);//成交数量(MXCJSL)
decimal.TryParse(dbfRecord[23].ToString().Trim(), out decimal tradingAmount);//清算本金(MXQSBJ)
decimal.TryParse(dbfRecord[33].ToString().Trim(), out decimal tradingFee);//收付净额(MXSXF)
var underlyingCode = dbfRecord[4].ToString().Trim() + ".SZ";// 证券代码(MXSFJE)
var date = dbfRecord[34].ToString().Trim();//成交日期MXCJRQ
var bstype = dbfRecord[17].ToString().Trim();//平仓标识(MXPCBS)
var bsType = 0;
if (bstype == "2" || bstype == "3")//2平仓 3强制平仓
{
bsType = 2;
}
else if (bstype == "1")//开仓
{
bsType = 1;
}
var tradeDate = ConvertDateTime(date);
swap_Flows.Add(new swap_flow()
{
FundAccount = dbfRecord[7].ToString().Trim(),//证券账户号码(MXZQZH)
OccurTime = tradeDate,
UnderlyingCode = underlyingCode,
BsType = bsType,
TradingQty = tradingQty,
TradingAmount = tradingAmount,
TradingFee = tradingFee - tradingAmount,//收付净额-清算本金
DataState = 1
});
}
}
return swap_Flows;
}
/// <summary>
/// 获取SH流水数据
/// </summary>
/// <param name="fields"></param>
/// <returns></returns>
private List<swap_flow> GetSwapFlowData_SH(DBFReader dbf)
{
List<swap_flow> swap_Flows = new List<swap_flow>();
var count = dbf.RecordCount;
for (var j = 0; j < count; j++)
{
var dbfRecord = dbf.NextRecord();
if (dbfRecord != null)
{
string SCDM = dbfRecord[0].ToString().Trim();//市场代码
if (SCDM != "01")//A股
{
continue;
}
var bstype = dbfRecord[29].ToString().Trim();//买卖标识(MMBZ)
decimal.TryParse(dbfRecord[31].ToString().Trim(), out decimal tradingQty);//成交数量(CJSL)
decimal.TryParse(dbfRecord[36].ToString().Trim(), out decimal tradingAmount);//清算金额(QSJE)
decimal.TryParse(dbfRecord[45].ToString().Trim(), out decimal tradingPay);//实际收付(SJSF)
var underlyingCode = dbfRecord[23].ToString().Trim() + ".SH";// 证券代码1(ZQDM1)
var date = dbfRecord[11].ToString().Trim();//交易日期(JYRQ)
var tradeDate = ConvertDateTime(date);
var bsType = 0;
if (bstype == "B")
{
bsType = 1;
}
else if (bstype == "S")
{
bsType = 2;
}
swap_Flows.Add(new swap_flow()
{
FundAccount = dbfRecord[32].ToString().Trim(),//资金账号(ZJZH)
OccurTime = tradeDate,
UnderlyingCode = underlyingCode,
BsType = bsType,
TradingQty = tradingQty,
TradingAmount = tradingAmount,
TradingFee = tradingPay - tradingAmount,
DataState = 1
});
}
}
return swap_Flows;
}
/// <summary>
/// 6位字符串日期转换
/// </summary>
/// <param name="date"></param>
/// <returns></returns>
private DateTime? ConvertDateTime(string date)
{
if (date.Length != 8)
{
return null;
}
DateTime.TryParse(date.Substring(0, 4) + "-" + date.Substring(4, 2) + "-" + date.Substring(6), out DateTime tradeDate);
return tradeDate;
}
}
}
+14 -3
View File
@@ -10,6 +10,7 @@ using YLErp.Helpers;
using YLErp.Model;
using YLErp.Model.Enum;
using YLErp.Modules.EodModule;
using YLErp.Modules.DataProviderModule;
using YLErp.Modules.SwapModule.Dto;
using YLErp.Office;
using YLErp.QdpModule;
@@ -70,6 +71,7 @@ namespace YLErp.Modules.SwapModule
}
DbContext.eod_commodity_future_price.Remove(frdata);
DbContext.SaveChanges();
Fr007FixingCache.Invalidate(); // 写侧失效:删除当日定盘后缓存立即重载,防旧值沿用
return true;
}
/// <summary>
@@ -108,16 +110,25 @@ namespace YLErp.Modules.SwapModule
DbContext.Add(frdata);
}
frdata.UnderlyingId = EodPriceService.ResolveUnderlyingIdForCode(frdata.UnderlyingCode, frdata.UnderlyingId ?? 0, frUnderlying.id);
frdata.ClosePrice = Math.Round(price, 4);
frdata.SettlePrice = Math.Round(price, 4);
frdata.ReferencePrice = Math.Round(price, 4);
frdata.ClosePrice = RoundFr007Price(price);
frdata.SettlePrice = RoundFr007Price(price);
frdata.ReferencePrice = RoundFr007Price(price);
frdata.OptId = UserInfo.UserId;
frdata.OptName = UserInfo.UserName;
frdata.OptDate = DateTime.Now;
DbContext.SaveChanges();
Fr007FixingCache.Invalidate(); // 写侧失效:新增/修改定盘后缓存立即重载
return true;
}
/// <summary>
/// FR007 界面手工录入定盘的落库精度。FR007 官方发布为百分数下 4 位(如 1.4150%),
/// 小数口径需 6 位(0.014150)——原 Math.Round(,4) 只保留百分数下 2 位,1.4150% 被截成
/// 1.4200%(丢 0.5bp)。取 6 位与前端 toNumber(value/100, 6) 对齐;DB 列 double(18,10) 容纳无虞;
/// bond-sync 自动同步链(BigDecimal 全精度透传)不经此函数。
/// </summary>
internal static double RoundFr007Price(double price) => Math.Round(price, 6);
/// <summary>
/// 查询互换流水导入
/// </summary>
@@ -76,7 +76,12 @@ namespace YLErp.Modules.SwapModule
/// <param name="eodPayPosition"></param>
public void UpdateSwapPositionWithRealTime(eod_swap_position eodPayPosition)
{
var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId);
// 同一 PositionId 同时对应初始腿(id=PositionId)和实时腿(IsInitial=0)。
// 日终/公司行为同步只能更新实时腿;若直接 FirstOrDefault,会随机命中初始腿,
// 造成 EOD 已是 200000/50 而交易详情仍保持 100000/100,或反向污染交易初始腿。
var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId
&& !x.IsInitial
&& !x.Invalid);
if (position != null)
{
position.PosiQuantity = eodPayPosition.PosiQuantity;
@@ -96,7 +101,13 @@ namespace YLErp.Modules.SwapModule
}
else
{
// 兼容实时腿尚未生成的首日/自动合成场景:只从初始腿克隆创建实时腿,
// 不能把初始腿当作可更新对象。
position = DbContext.swap_position.FirstOrDefault(x => x.id == eodPayPosition.PositionId);
if (position == null)
{
return;
}
var posi = position.Clone();
posi.id = 0;
posi.PositionId = position.id;
@@ -365,19 +376,18 @@ namespace YLErp.Modules.SwapModule
/// <param name="interestMode">计息方式</param>
/// <param name="interestStart">计息开始日期</param>
/// <param name="interestEnd">计息结束日期</param>
/// <returns>true=计息窗口为空(interestStart&gt;interestEnd,本次不计利息,调用方将利率与金额归零);
/// 典型触发=①不算头首日(valueDate==StartDate → StartDate+1&gt;StartDate) ②不算尾到期日回拨后窗口翻转
/// (interestEnd=到期日−1 &lt; interestStart)。判定只看日期窗口,与事件类型无关。
/// 注:当日已结息(preSettleDate==valueDate)日期相等时本函数返回 false——利息归零由
/// GetInterests 的 closeList 净额层处理,不在本判定。</returns>
public bool InitInterestDate(DateTime valueDate, DateTime? preSettleDate, trade td, bool tdClose, out DateTime interestStart, out DateTime interestEnd)
{
interestStart = td.StartDate.Value;
var exerciseDate = td.ExerciseDate.Value;
interestEnd = valueDate > exerciseDate ? exerciseDate : valueDate;
bool calcFirst = true;
bool calcLast = true;
if (td.trade_extend != null)
{
calcFirst = td.trade_extend.ExtendObj.InterestCalcMode.StartsWith("1");//算头
calcLast = td.trade_extend.ExtendObj.InterestCalcMode.EndsWith("1");//算尾
}
bool calcFirst = td.trade_extend?.ExtendObj.CalcFirst ?? true;
bool calcLast = td.trade_extend?.ExtendObj.CalcLast ?? true;
interestStart = calcFirst ? interestStart : interestStart.AddDays(1);
if (preSettleDate.HasValue && preSettleDate >= interestStart)
{
@@ -387,7 +397,9 @@ namespace YLErp.Modules.SwapModule
{
interestEnd = interestEnd.AddDays(-1);
}
if (interestStart > interestEnd || td.StartDate > interestStart)
// 原第二 OR 子句 td.StartDate > interestStart 恒 falseinterestStart 经上面调整恒 ≥ td.StartDate
// =开始日 / 不算头+1天 / preSettleDate 且仅当 ≥interestStart 才覆盖),死代码已删(2026-08-19)。
if (interestStart > interestEnd)
{
interestStart = interestEnd;
return true;//不记利息
@@ -1295,6 +1295,10 @@ namespace YLErp.Modules.SwapModule
{
return;
}
if (!new TradeApprovalOAService(this).ArchiveActiveForTrade(tradeObj, out var errorMessage))
{
throw new ServiceException(errorMessage);
}
var eventTypes = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum., (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
var lastEvent = DbContext.swap_event.Where(x => x.SwapTradeId == intid && eventTypes.Contains(x.EventType) && !x.Invalid).OrderByDescending(o => o.OptTime).FirstOrDefault();
if (tradeObj.TradeStatus == ConsTrade. || tradeObj.TradeStatus == ConsTrade.)
@@ -1587,14 +1591,34 @@ namespace YLErp.Modules.SwapModule
}
else
{
// 按日期回退时只恢复 valueDate 之前最近实际收盘的快照;当天及以后数据会在
// InvalidTradeOptionDatasByDate 中清理。这样回退到除权日 D 会回到 D-1 的
// 1000 份/100 基线并让重收盘重新应用公司行为;回退到 D+1 则保留 D 的 2000 份/50。
TradeBackByDate(td, valueDate, swapPositions);
}
if (swapEvent != null)//展期
{
swapEventService.DeleteExtensionTime(swapEvent.id);
}
var corporateActionEvents = DbContext.swap_event
.Where(x => !x.Invalid
&& x.SwapTradeId == tradeId
&& x.EventType == (int)SwapEventTypeEnum.
&& x.ValueDate >= valueDate)
.OrderByDescending(x => x.id)
.ToList();
InvalidTradeOptionDatasByDate(tradeId, valueDate, backToBegin);
swapEventService.AddSwapEventDate(valueDate, tradeId, (int)SwapEventTypeEnum.退, string.Empty, 0, false, $"交易回退至{valueDate:yyyy年MM月dd日}");
var rollbackEvent = swapEventService.AddSwapEventDate(
valueDate,
tradeId,
(int)SwapEventTypeEnum.退,
string.Empty,
0,
false,
$"交易回退至{valueDate:yyyy年MM月dd日}");
// 公司行为原事件保持有效作为不可篡改审计;回退事件通过 BackId 指向本次
// 回退影响的最新公司行为事件,后续重收盘会追加新的公司行为事件。
rollbackEvent.BackId = corporateActionEvents.FirstOrDefault()?.id ?? 0;
DbContext.SaveChanges();
if (del)
{
@@ -1745,8 +1769,14 @@ namespace YLErp.Modules.SwapModule
{
SwapEodPositionService eodPositionService = new SwapEodPositionService(this);
SwapDealService swapDealService = new SwapDealService(this);
var preDay = valueDate.AddDays(-1);
var eodSwapPositionList = DbContext.eod_swap_position.Where(x => x.ValueDate == preDay && x.SwapTradeId == td.id).ToList();
// 回退基线必须是 valueDate 之前最近一个实际 EOD,而不是 valueDate-1 自然日。
// 例如周一/节假日后的 valueDate 没有周日 EOD 时,AddDays(-1) 会得到空集合,
// 实时腿仍保留除权后的数量/价格。回退到除权日 D 选择 D 前基线并由下面的
// InvalidTradeOptionDatasByDate 删除 D 及以后 EOD;回退到 D+1 则会选择 D,
// 保留 D 已生效的公司行为。ex_dividend_info 本身不删除,重收盘 D 会再应用一次。
var eodSwapPositionList = eodPositionService.GetLatestEodPositionsBefore(td.id, valueDate);
// 没有 valueDate 之前的有效 EOD 时,eodSwapPositionList 为空;这是“没有可证明基线”的情况,
// 下面不会伪造数量/价格或重算公司行为,只保留当前实时持仓并继续清理回退日之后的数据。
var swapFlowEvents = DbContext.swap_flow_event.Where(x => x.SwapTradeId == td.id && x.EventDate >= valueDate && x.DataState > (int)SwapFlowDateStateEnum.).ToList();
var positions = swapPositions.Where(x => x.PosiDirection > 0 && !x.IsInitial).ToList();
@@ -1772,6 +1802,8 @@ namespace YLErp.Modules.SwapModule
td.TradeAmount = Convert.ToDouble(posi.PosiQuantity);
}
}
// eodPosi 为空时刻意不改 posi:回退只能使用已落库的历史快照,不能把缺失数据
// 猜成 0 或交易初始值,否则会把未验证的 Fund 除权数量带入后续收盘。
}
td.UnWindDate = null;
td.UnWindNotional = null;
@@ -1812,6 +1844,10 @@ namespace YLErp.Modules.SwapModule
/// <param name="valueDate"></param>
private void InvalidTradeOptionDatasByDate(int tradeId, DateTime valueDate, bool backToBegin)
{
// 所有清理条件都采用闭区间起点 [valueDate, +∞):回退到除权日 D 要删除 D 当天
// 已应用的 EOD/流水,随后重收盘 D 才会从 D-1 快照重新套一次系数;回退到 D+1
// 不会删除 D,因而保留 D 已生效的 2000 份/50。valueDate 之前的快照始终保留,
// 作为 TradeBackByDate 的唯一可验证基线。
var swapEvents = DbContext.swap_event.Where(x => !x.Invalid && x.SwapTradeId == tradeId && x.ValueDate >= valueDate).ToList();
var swapEodPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate >= valueDate);
var swapEods = DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId && x.ValueDate >= valueDate);
@@ -1819,6 +1855,12 @@ namespace YLErp.Modules.SwapModule
var firstConfirm = false;
swapEvents.ForEach(x =>
{
// 公司行为事件是不可篡改审计日志。回退只追加回退事件,不把原始公司
// 行为事件置无效;否则无法追溯交易曾经经历过的调整。
if (x.EventType == (int)SwapEventTypeEnum.)
{
return;
}
if (backToBegin && !firstConfirm && x.EventType == (int)SwapEventTypeEnum.)
{
firstConfirm = true;
@@ -13,7 +13,6 @@ using YLErp.DBModels;
using YLErp.Helpers;
using YLErp.Model;
using YLErp.Model.Enum;
using YLErp.Model.HengTaiModel;
using YLErp.Modules.ExchangeTradeModule;
namespace YLErp.Modules.SwapModule