Merge branch 'glms/feature/1.4.2' into glms/feature/0812_zmr_divPower

This commit is contained in:
张名锐
2026-08-21 09:50:12 +08:00
26 changed files with 1061 additions and 88 deletions
@@ -400,5 +400,18 @@ namespace YLErp.DBModels
[NotMapped] [NotMapped]
public decimal? InitYtm { get; set; } public decimal? InitYtm { get; set; }
/// <summary>
/// 期末标的结算收益率(EQD-6953 平仓)。普通债券类收益互换平仓时由债券计算器按
/// 期末标的交割全价反算(估值日=平仓日 ValueDate),允许手工覆盖。
/// 命名遵循《互换价格字段命名规范决策文档》时点维度:平仓/了结用 Exit(勿用 End/Close/Final)。
/// [NotMapped]:不落 swap_flow_event 表列;仅随 UnwindData 序列化进 swap_event.EventData JSON
/// 由平仓待复核回显(GetSwapEvent)与结算确认书 Excel(TradeSettleBillGenerator) 消费。
/// ⚠️ 存储口径为【展示态百分数】(如 6.3721 表示 6.3721%),与同页期末交割全价(展示态)一致,
/// 区别于录入页 trade.InitYtm 的存储态小数(0.063721)——两者载体不同、互不干扰,勿"顺手统一"。
/// 精度:确认书导出固定 4 位小数不去零(ToString("0.0000"));本字段保留 4 位(四舍五入)。
/// </summary>
[NotMapped]
public decimal? ExitYtm { get; set; }
} }
} }
@@ -45,5 +45,11 @@ namespace YLErp.Models
public string DividendIn { get; set; } public string DividendIn { get; set; }
public string Quantity { get; set; } public string Quantity { get; set; }
/// <summary>
/// 期末标的结算收益率(EQD-6953)。普通债券类收益互换平仓收益率,展示态百分数,
/// 固定 4 位小数不去零("0.0000");非债券/历史无值时为空串。
/// </summary>
public string ExitYtm { get; set; }
} }
} }
@@ -48,7 +48,9 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
var confirmNo = Context.Gettrade_contract_r(tradeId, ContractTypeEnum.Trade); var confirmNo = Context.Gettrade_contract_r(tradeId, ContractTypeEnum.Trade);
if (string.IsNullOrEmpty(confirmNo)) if (string.IsNullOrEmpty(confirmNo))
{ {
throw new ServiceException($"{trade.TradeNumber}未生成交易确认书"); // 定位要点:带上事件id便于与 SwapSettlementBillGenerateService 的"生成范围扩张日志"对齐——
// 报错交易常是扩张拉入的同客户同日平仓,并非用户勾选的那笔。
throw new ServiceException($"{trade.TradeNumber}未生成交易确认书(平仓事件id={flowEventGroup.id}, tradeId={tradeId}, 客户={client.Name}, 平仓日={flowEventGroup.UnwindDate?.ToString("yyyy-MM-dd")});请先为该笔交易生成交易确认书后重试");
} }
row.TradeNumber = confirmNo; row.TradeNumber = confirmNo;
row.ClientName = client.Name; row.ClientName = client.Name;
@@ -67,6 +69,9 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
decimal interestRate = unwindFlowEvents.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).Sum(s => s.InterestRate); decimal interestRate = unwindFlowEvents.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).Sum(s => s.InterestRate);
row.InterestRate = interestRate.ToString("0.00%"); row.InterestRate = interestRate.ToString("0.00%");
var PosiNotionalValue = flowEventGroup.Quantity * flowEventGroup.ContractSize * posi.PosiGrossPrice; var PosiNotionalValue = flowEventGroup.Quantity * flowEventGroup.ContractSize * posi.PosiGrossPrice;
// EQD-6953 期末标的结算收益率:平仓簿记时随 UnwindData 存进 swap_event.EventData
// 此处从浮动腿(PositionType>0)回读。存储态=展示态百分数(6.3721),导出固定 4 位不去零。
decimal? exitYtm = null;
if (flowEventGroup.EventId.HasValue) if (flowEventGroup.EventId.HasValue)
{ {
var swapEvent = Context.GetEvent(flowEventGroup.EventId.Value); var swapEvent = Context.GetEvent(flowEventGroup.EventId.Value);
@@ -74,8 +79,12 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
{ {
swapEvent.unwindData = JsonHelper.Deserialize<UnwindData>(swapEvent.EventData); swapEvent.unwindData = JsonHelper.Deserialize<UnwindData>(swapEvent.EventData);
PosiNotionalValue = swapEvent.unwindData.CloseNotionalValue; PosiNotionalValue = swapEvent.unwindData.CloseNotionalValue;
exitYtm = swapEvent.unwindData.FlowEvents?
.FirstOrDefault(f => f.PositionType > 0)?
.ExitYtm;
} }
} }
row.ExitYtm = exitYtm?.ToString("0.0000") ?? string.Empty;
row.Quantity = flowEventGroup.Quantity.ToString("0.00"); row.Quantity = flowEventGroup.Quantity.ToString("0.00");
row.PosiNotionalValue = PosiNotionalValue.ToString("0.00"); row.PosiNotionalValue = PosiNotionalValue.ToString("0.00");
@@ -11,7 +11,9 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
/// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天); /// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天);
/// ③ 到期日恰为重置日(末段 [到期,到期] 1 天); /// ③ 到期日恰为重置日(末段 [到期,到期] 1 天);
/// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同); /// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同);
/// ⑤ 起息日当天平仓(无 preEod) /// ⑤ 起息日当天平仓(无 preEod)
/// ⑥ 部分平仓 share&lt;1 + 无 preEod 兜底——钉 merger 复刻 GetInterests 本金口径的接缝
/// (现有用例全部 closePercent=1m,重放基数与复刻本金的口径偏差在 share=1 下不可见)。
/// ///
/// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率, /// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率,
/// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。 /// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。
@@ -67,7 +69,7 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
private static decimal RunFee(trade td, swap_position p, decimal settledAmount, private static decimal RunFee(trade td, swap_position p, decimal settledAmount,
eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread, eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread,
decimal interestPrincipal = 0m, bool maturityCalcLast = true) decimal interestPrincipal = 0m, bool maturityCalcLast = true, decimal closePercent = 1m)
{ {
var e = new swap_flow_event var e = new swap_flow_event
{ {
@@ -78,7 +80,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
PenaltyInterestFeeMerger.Merge( PenaltyInterestFeeMerger.Merge(
td, new List<swap_position> { p }, new List<swap_flow_event> { e }, td, new List<swap_position> { p }, new List<swap_flow_event> { e },
unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast, unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast,
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, posiNotionalValue: Notional, closePosiNotionalValue: Notional * closePercent,
closePercent: closePercent,
getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread); getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread);
return e.InterestFee; return e.InterestFee;
} }
@@ -192,6 +195,30 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
"无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)"); "无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)");
} }
[TestMethod]
public void preEod兜底_share对齐本金口径_恒等式成立()
{
// 接缝守卫:merger 的 closePrincipal 走 CalcNotional 复刻 GetInterests 口径
// (标的期初全价 = posiNotional×closePercent),而重放基数由调用方以
// closePosiNotionalValue 缩放——两处口径若有偏差,share=1 时不可见、
// share<1 时 ① 里会混入本金差。本用例以 50% 平仓钉死该对齐。
var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30);
var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结)14 天重置
var share = 0.5m;
var closedNotional = Notional * share;
// 被平份额的实结与重放基数:复利对 notional 线性,直接按半额本金重放
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14);
var replayFinalBasis = closedNotional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, notional: closedNotional, period: 14);
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed,
preEod: null, unwind: unwind, settled: false, spread: hist[^1],
interestPrincipal: replayFinalBasis, maturityCalcLast: false, closePercent: share);
var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"部分平仓+无preEod:兜底①按被平份额缩放精确,全期(被平份额)=实结+罚息(口径漂移时此式必挂)");
}
[TestMethod] [TestMethod]
public void _无preEod_恒等式成立() public void _无preEod_恒等式成立()
{ {
@@ -50,7 +50,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
private static void RunMerge( private static void RunMerge(
swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod, swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod,
Func<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null) Func<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null,
AccrualTrace? trace = null)
{ {
getSpread ??= _ => Rate; getSpread ??= _ => Rate;
tryGetFixing ??= (d, code) => Rate; tryGetFixing ??= (d, code) => Rate;
@@ -61,7 +62,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
getSpread: getSpread, getSpread: getSpread,
getPreEod: _ => preEod, getPreEod: _ => preEod,
tryGetFixing: tryGetFixing); tryGetFixing: tryGetFixing,
trace: trace);
} }
/// <summary>复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。</summary> /// <summary>复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。</summary>
@@ -152,6 +154,45 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息"); Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息");
} }
[TestMethod]
public void preEod复利段中兜底为零且账龄超重置周期_留退化告警trace()
{
// 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底已并复利本金=0。
// 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应>0,已并复利本金=0 属退化——
// 典型成因=interestWindowEmpty(当日已结息)早退未重放覆盖种子、或日终归档缺失。
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional; // GetInterests 种子值:interestWindowEmpty 早退路径不会用重放基数覆盖它
var trace = new AccrualTrace();
RunMerge(Leg(InterestTypeEnum.), e, preEod: null, trace: trace);
StringAssert.Contains(trace.ToString(), "无preEod兜底已并复利本金=0",
"已并复利本金=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因");
}
[TestMethod]
public void preEod兜底为正_不留退化告警()
{
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → 已并复利本金=100000 正常路径
var trace = new AccrualTrace();
RunMerge(Leg(InterestTypeEnum.), e, preEod: null, trace: trace);
Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "已并复利本金>0 是正常兜底路径,不得告警");
}
[TestMethod]
public void preEod真首日兜底为零_不留退化告警()
{
var p = Leg(InterestTypeEnum.);
p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,已并复利本金=0 是设计内约定(类头注)
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional;
var trace = new AccrualTrace();
RunMerge(p, e, preEod: null, trace: trace);
Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "真首日 已并复利本金=0 合法,不得告警");
}
[TestMethod] [TestMethod]
public void _跳过该腿不阻断() public void _跳过该腿不阻断()
{ {
@@ -74,7 +74,12 @@ namespace YLErp.Modules.AppModule
} }
/// <summary> /// <summary>
/// 生成邮件信息,如果template为null,返回null /// 生成邮件信息,如果template为null,返回null
/// 【设计决策·勿单边"升级"】占位符为朴素文本替换(非模板引擎),与本表另一消费者
/// bond-oms Java SwapEmailHandler.replaceMailPlaceholders 保持同构渲染语义——
/// 单边引入 FreeMarker/Thymeleaf 等更强语法或 HTML 转义,会造成两侧发出内容不一致。
/// 替换值(客户名称/文档编号等)不做 HTML 转义是有意为之:均为内部维护的可信数据;
/// 若未来要拼接用户自由输入的内容,必须两侧同步加转义。新占位符同样两侧同步加白名单。
/// </summary> /// </summary>
public static MailInfoResultModel GenerateMailInfo(EmailTemplate template, MailInfoRequestModel reqModel) public static MailInfoResultModel GenerateMailInfo(EmailTemplate template, MailInfoRequestModel reqModel)
{ {
@@ -12,9 +12,9 @@ namespace YLErp.Modules.SwapModule.Penalty;
/// getSpread(加点利差)/ getPreEod(上一日终快照行)/ tryGetFixing(定盘取价),本类零 DB 耦合、可 headless 单测。 /// getSpread(加点利差)/ getPreEod(上一日终快照行)/ tryGetFixing(定盘取价),本类零 DB 耦合、可 headless 单测。
/// ///
/// 复利承接量(精确续接口径的关键)**必须取实际计息状态**,严禁冻结利率重放推导: /// 复利承接量(精确续接口径的关键)**必须取实际计息状态**,严禁冻结利率重放推导:
/// 承接① capitalized = max(0, preEod.TdInterestPrincipal×份额 closePrincipal) —— 实际滚动复利基数中已并入部分; /// 已并复利本金 capitalized = max(0, preEod.TdInterestPrincipal×份额 closePrincipal) —— 实际滚动复利基数中已并入部分;
/// 承接② carryIn = 正常平仓流实结 InterestAmount —— 最近重置日后实际已计利息; /// 段内已计利息 carryIn = 正常平仓流实结 InterestAmount 已并复利本金 —— 最近重置日后实际已计利息;
/// 无 preEod(首日平仓):①=0、②=实结金额。 /// 无 preEod(首日平仓):已并复利本金=0、段内已计利息=实结金额。
/// 逐腿全程 trace 落盘(SwapCalcTrace),供计算过程分析与错误定位。 /// 逐腿全程 trace 落盘(SwapCalcTrace),供计算过程分析与错误定位。
/// </summary> /// </summary>
public static class PenaltyInterestFeeMerger public static class PenaltyInterestFeeMerger
@@ -48,11 +48,11 @@ public static class PenaltyInterestFeeMerger
foreach (var position in fundingPositions) foreach (var position in fundingPositions)
{ {
// 正常平仓利息流(GetInterests 刚产出)——承接②的事实源与罚息并入目标 // 正常平仓利息流(GetInterests 刚产出)——段内已计利息的事实源与罚息并入目标
var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id); var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id);
if (normalEvent == null) if (normalEvent == null)
{ {
trace?.Note($"PENALTY|p{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)"); trace?.Note($"PENALTY|融资腿{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)");
continue; continue;
} }
@@ -78,50 +78,24 @@ public static class PenaltyInterestFeeMerger
frozenRate = PenaltyLegRateResolver.ResolveFrozenRate( frozenRate = PenaltyLegRateResolver.ResolveFrozenRate(
position, getSpread(position), preEod?.FloatRate, unwindDate, position, getSpread(position), preEod?.FloatRate, unwindDate,
d => tryGetFixing(d, position.FloatRateUnderlyingCode)); d => tryGetFixing(d, position.FloatRateUnderlyingCode));
rateSource = preEod != null // 来源标签须反映实际路径:固定腿不取价(利差即冻结利率);浮动腿才有快照/取价之分
? $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}" if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
: "定盘取价(unwindDate-1区间)"; rateSource = "固定腿利差(不取价)";
else if (preEod != null)
rateSource = $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}";
else
rateSource = "定盘取价(unwindDate-1区间)";
} }
catch (Exception ex) catch (Exception ex)
{ {
trace?.Note($"PENALTY|p{position.id} 跳过 冻结利率解析失败:{ex.Message}"); trace?.Note($"PENALTY|融资腿{position.id} 跳过 冻结利率解析失败:{ex.Message}");
continue; continue;
} }
// 复利承接:实际滚动基数中已并入部分(+ 段内实际已计利息()。单利无并本金语义恒 0。 // 复利承接:实际滚动基数中已并入部分(已并复利本金+ 段内实际已计利息(段内已计利息)。单利无并本金语义恒 0。
// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): var (capitalized, carryIn) = isCompound
// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; ? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace)
// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) : (0m, 0m);
// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值;
// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取
// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。
decimal capitalized = 0m, carryIn = 0m;
if (isCompound)
{
var periodDays = position.interest_rest_days ?? 1;
var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays);
if (unwindOnResetDay)
{
capitalized = (preEod?.InterestIncomeSum ?? 0m) * share;
if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays)
trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)");
}
else if (preEod != null)
{
capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal);
}
else
{
capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal);
}
// ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息)
if (capitalized > Math.Max(0m, normalEvent.InterestAmount))
{
trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum");
capitalized = Math.Max(0m, normalEvent.InterestAmount);
}
carryIn = normalEvent.InterestAmount - capitalized;
}
var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound); var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound);
// 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate // 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate
@@ -137,12 +111,75 @@ public static class PenaltyInterestFeeMerger
normalEvent.InterestClosePnL += penalty * DirectionRatio.ReceivePay(position.InterestDirection); normalEvent.InterestClosePnL += penalty * DirectionRatio.ReceivePay(position.InterestDirection);
trace?.Note( trace?.Note(
$"PENALTY|p{position.id} 完成 mode={mode} {(isCompound ? "" : "")} " + $"PENALTY|融资腿{position.id} 完成 mode={mode} {(isCompound ? "" : "")} " +
$"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " + $"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " +
$"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " + $"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " +
$"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " + $"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " +
$"承接={capitalized:F4} ={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " + $"承接[已并复利本金]={capitalized:F4} [段内已计利息]={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " +
$"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}"); $"罚息={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);
}
} }
+12 -12
View File
@@ -1257,13 +1257,13 @@ namespace YLErp.Modules.SwapModule
isResetDay ? endDate : startDate, position.interest_rule); isResetDay ? endDate : startDate, position.interest_rule);
// 历史上有"取错重置日利率"的线上 bug,取价决策必须常驻落盘(SwapCalcTrace.Critical 无条件 Info)。 // 历史上有"取错重置日利率"的线上 bug,取价决策必须常驻落盘(SwapCalcTrace.Critical 无条件 Info)。
SwapCalcTrace.Critical( SwapCalcTrace.Critical(
$"FIX GetFloatRate p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " + $"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} " + $"重置日={isResetDay} rule={position.interest_rule} 取价日={rateDate:yyyy-MM-dd} calcLast={calcLast} " +
$"preEod={(preEod.id != 0 ? $"{preEod.ValueDate:yyyy-MM-dd}:{preEod.FloatRate:P6}" : "")}"); $"preEod={(preEod.id != 0 ? $"{preEod.ValueDate:yyyy-MM-dd}:{preEod.FloatRate:P6}" : "")}");
if (preEod.id != 0 && !isResetDay) if (preEod.id != 0 && !isResetDay)
{ {
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}"); SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}");
position.FloatRate = positionClone.FloatRate = preEod.FloatRate; position.FloatRate = positionClone.FloatRate = preEod.FloatRate;
return preEod.FloatRate; return preEod.FloatRate;
} }
@@ -1275,13 +1275,13 @@ namespace YLErp.Modules.SwapModule
{ {
var keptNoFetch = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; var keptNoFetch = preEod.id != 0 ? preEod.FloatRate : position.FloatRate;
SwapCalcTrace.Critical( SwapCalcTrace.Critical(
$"FIX GetFloatRate p{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); $"FIX GetFloatRate 融资腿{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})");
return keptNoFetch; return keptNoFetch;
} }
if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate)) if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate))
{ {
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}"); SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}");
position.FloatRate = positionClone.FloatRate = rate; position.FloatRate = positionClone.FloatRate = rate;
return position.FloatRate; return position.FloatRate;
} }
@@ -1289,17 +1289,17 @@ namespace YLErp.Modules.SwapModule
{ {
if (calcLast) if (calcLast)
{ {
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截"); SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截");
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格");
} }
// 算头不算尾(calcLast=false)endDate 当天不计息,其 FR007 利率不参与计息, // 算头不算尾(calcLast=false)endDate 当天不计息,其 FR007 利率不参与计息,
// 缺价时直接沿用已有利率,不回退取其他日期利率,不告警。 // 缺价时直接沿用已有利率,不回退取其他日期利率,不告警。
var kept = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; var kept = preEod.id != 0 ? preEod.FloatRate : position.FloatRate;
SwapCalcTrace.Critical( SwapCalcTrace.Critical(
$"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); $"FIX GetFloatRate 融资腿{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})");
return kept; return kept;
} }
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 计息窗口为空→利率不参与,返回0"); SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 计息窗口为空→利率不参与,返回0");
return 0m; return 0m;
} }
@@ -1728,15 +1728,15 @@ namespace YLErp.Modules.SwapModule
if (fixing != 0m) if (fixing != 0m)
{ {
SwapCalcTrace.Critical( SwapCalcTrace.Critical(
$"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}"); $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}");
return fixing; return fixing;
} }
SwapCalcTrace.Critical( SwapCalcTrace.Critical(
$"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}"); $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}");
return fallback; return fallback;
} }
SwapCalcTrace.Critical( SwapCalcTrace.Critical(
$"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常"); $"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日}的价格"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格");
} }
@@ -1755,7 +1755,7 @@ namespace YLErp.Modules.SwapModule
var calcDays = (endDate - startDate).Days; var calcDays = (endDate - startDate).Days;
decimal currentFloat = initialFloat; decimal currentFloat = initialFloat;
SwapCalcTrace.Critical( SwapCalcTrace.Critical(
$"FIX Segments p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " + $"FIX Segments 融资腿{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " +
$"fetchAfter={(fetchAfterDate?.ToString("yyyy-MM-dd") ?? "")} calcLast={calcLast} " + $"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}"); $"排除起点={(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) for (int i = 0; i <= calcDays; i += interestPeriod)
@@ -1774,7 +1774,7 @@ namespace YLErp.Modules.SwapModule
else if (needFetch && isExcludedEnd) else if (needFetch && isExcludedEnd)
{ {
SwapCalcTrace.Critical( SwapCalcTrace.Critical(
$"FIX Segment p{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}"); $"FIX Segment 融资腿{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}");
} }
rates.Add((resetDate, spread + currentFloat)); rates.Add((resetDate, spread + currentFloat));
} }
@@ -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(); var tradeContract = DbContext.trade_contract_r.Where(x => x.IsValid && x.TradeId == tradeId && x.Type == ContractTypeEnum.Trade).FirstOrDefault();
if (tradeContract == null) 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 = "发送中"; tradeContract.send_email_result = "发送中";
DbContext.SaveChanges(); DbContext.SaveChanges();
@@ -248,5 +256,47 @@ namespace YLErp.Modules.SwapModule
} }
return "未配置邮件接口地址"; 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; }
}
} }
} }
@@ -2247,7 +2247,7 @@ namespace YLErp.Modules.SwapModule
{ {
var ongoingFixing = ResolveOngoingResetFixing(position, valueDate); var ongoingFixing = ResolveOngoingResetFixing(position, valueDate);
SwapCalcTrace.Critical( SwapCalcTrace.Critical(
$"FIX EodCloseRefix p{position.id} {valueDate:yyyy-MM-dd} 平仓日=重置日→剩余持仓快照再定盘 {newEodPayPosition.FloatRate:P6}→{ongoingFixing:P6}"); $"FIX EodCloseRefix 融资腿{position.id} {valueDate:yyyy-MM-dd} 平仓日=重置日→剩余持仓快照再定盘 {newEodPayPosition.FloatRate:P6}→{ongoingFixing:P6}");
newEodPayPosition.FloatRate = ongoingFixing; newEodPayPosition.FloatRate = ongoingFixing;
} }
//利息端估值用信息 //利息端估值用信息
@@ -164,6 +164,12 @@ namespace YLErp.Modules.SwapModule
&& f.UnwindDate == valueDate && f.PayDirection > 0 && eventTypes.Contains(f.EventType) && f.DataState == (int)SwapFlowDateStateEnum. && f.UnwindDate == valueDate && f.PayDirection > 0 && eventTypes.Contains(f.EventType) && f.DataState == (int)SwapFlowDateStateEnum.
select f; select f;
var flowEventList = flowQuery.ToList(); 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 docs = new List<SwapTradeContractDto>();
var openFlowEventList = flowEventList.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.).ToList(); var openFlowEventList = flowEventList.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.).ToList();
var closeFlowEventList = 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()) 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())}找不到有效的交易确认书附件,请检查或生成后再发送邮件"); throw new Exception($"交易编号为{string.Join(",", tradeNumbers.Distinct())}找不到有效的交易确认书附件,请检查或生成后再发送邮件");
} }
return list; return list;
@@ -265,6 +287,24 @@ namespace YLErp.Modules.SwapModule
} }
if (tradeNumbers.Any()) 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())}找不到有效的结算确认书附件,请检查或生成后再发送邮件"); throw new Exception($"交易编号为{string.Join(",", tradeNumbers.Distinct())}找不到有效的结算确认书附件,请检查或生成后再发送邮件");
} }
return list; return list;
@@ -764,7 +804,11 @@ namespace YLErp.Modules.SwapModule
if (System.IO.File.Exists(fName)) if (System.IO.File.Exists(fName))
{ return fName; } { return fName; }
else else
{ return null; } {
// 定位要点:物理文件缺失历史上静默返回 null(附件列表混入 null,发送结果不可预期),补 warn 便于发现"库里行在、盘上文件丢"。
LogFactory.GetLogger("SwapEventEmail").Error($"邮件附件物理文件缺失: trade_contract_document.Paths={baseName} 映射后={fName} 不存在");
return null;
}
} }
} }
} }
@@ -53,6 +53,12 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule
var allFlowEvents = DbContext.swap_flow_event.Where(x => eventIds.Contains(x.EventId)).ToList(); var allFlowEvents = DbContext.swap_flow_event.Where(x => eventIds.Contains(x.EventId)).ToList();
var tradeContracts = DbContext.trade_contract_r.Where(x=> tradeIds.Contains(x.TradeId)&&x.Type== ContractTypeEnum.Clearing&&x.IsValid).ToList(); var tradeContracts = DbContext.trade_contract_r.Where(x=> tradeIds.Contains(x.TradeId)&&x.Type== ContractTypeEnum.Clearing&&x.IsValid).ToList();
flowEvents = newFlowEvents.ToList(); flowEvents = newFlowEvents.ToList();
// 定位要点:生成范围按【客户+平仓日】扩张——传入的是用户勾选的 flowEventIds
// 实际生成会包含同客户同日全部平仓流水(PositionType>0)。若某笔交易未生成过交易确认书,
// TradeSettleBillGenerator 会整批抛"XX未生成交易确认书",此日志用于对齐"勾选了什么 vs 实际生成什么"。
LogFactory.GetLogger("GenerateSingleV2").Info(
$"生成结算确认书-生成范围: 传入flowEventIds[{string.Join(",", flowEventIds)}] " +
$"扩张后待生成[{string.Join(",", flowEvents.Select(f => $"{f.id}:{f.SwapTradeNo}"))}]");
//---------------------------------- //----------------------------------
// 交易确认书数据 // 交易确认书数据
//---------------------------------- //----------------------------------
+5 -1
View File
@@ -7,8 +7,12 @@ namespace YLErp.Web.Controllers
{ {
[AllowAnonymous] [AllowAnonymous]
public JsonResult CalcBond(string underlyingCode, decimal price, string priceType, string targetDate = null) public JsonResult CalcBond(string underlyingCode, decimal price, string priceType, string targetDate = null, string source = null)
{ {
// EQD-6953source 标记调用场景(unwind=平仓页;录入页不传)。BondCalcHepler 已记录
// 完整请求参数与成败结果,此处仅补场景维度,定位问题时先按 source 区分入口再看参数。
LogFactory.GetLogger("BondController").Info(
$"CalcBond source={source ?? "()"} underlyingCode={underlyingCode} price={price} priceType={priceType} targetDate={targetDate ?? "(T+1)"}");
string errorMsg; string errorMsg;
var obj = BondCalcHepler.BondCalc(underlyingCode, price, priceType, out errorMsg, targetDate); var obj = BondCalcHepler.BondCalc(underlyingCode, price, priceType, out errorMsg, targetDate);
if (obj == null) if (obj == null)
@@ -1233,6 +1233,34 @@ namespace YLErp.Web.Controllers
return JsonSuccess(result); return JsonSuccess(result);
} }
/// <summary>
/// EQD-5320 批量发送结算确认书邮件——经服务端代理 bond-omsBondOmsInterface_BaseUrl 出口),
/// 替代前端直连 /trs_hub_api 反向代理(未配 nginx 的环境 404)。
/// </summary>
/// <param name="swapFlowEventIds">平仓流水id,逗号分隔</param>
[HttpPost]
public JsonResult BatchSendSettleEmail(string swapFlowEventIds)
{
if (string.IsNullOrWhiteSpace(swapFlowEventIds))
{
return JsonError("请选择交易");
}
var ids = new List<long>();
foreach (var s in swapFlowEventIds.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
if (long.TryParse(s.Trim(), out var id))
{
ids.Add(id);
}
}
if (ids.Count == 0)
{
return JsonError("平仓流水id解析为空");
}
var result = new SwapEndConfirmService(CurUser).BatchSendSettleEmail(ids);
return string.IsNullOrEmpty(result) ? JsonSuccess("发送成功") : JsonError(result);
}
} }
} }
+11 -1
View File
@@ -26,6 +26,8 @@
<script src="~/front/swappriceprecision?v=@HtmlUtil.JsVersion"></script> <script src="~/front/swappriceprecision?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapPricePrecisionHelper.js?v=@HtmlUtil.JsVersion"></script> <script src="~/Scripts/app/swaptrade/swapPricePrecisionHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapCalc.js?v=@HtmlUtil.JsVersion"></script> <script src="~/Scripts/app/swaptrade/swapCalc.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindBondCalc.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindLegSign.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindSwapTrade.js?v=@HtmlUtil.JsVersion"></script> <script src="~/Scripts/app/swaptrade/unwindSwapTrade.js?v=@HtmlUtil.JsVersion"></script>
} }
<div class="pb-3" id="vueDiv"> <div class="pb-3" id="vueDiv">
@@ -197,6 +199,8 @@
<td v-else>期初标的价格</td> <td v-else>期初标的价格</td>
<td v-if="deal.StructureType!='普通收益互换'">期末标的交割全价%</td> <td v-if="deal.StructureType!='普通收益互换'">期末标的交割全价%</td>
<td v-else>期末标的价格</td> <td v-else>期末标的价格</td>
@* EQD-6953 期末标的结算收益率:仅普通债券类收益互换显示,期末交割全价回车反算,可手填覆盖 *@
<td v-if="deal.StructureType=='普通债券类收益互换'">期末标的结算收益率%</td>
<td>数量</td> <td>数量</td>
<td>交易费用(平仓)</td> <td>交易费用(平仓)</td>
<td>交易费用(待结算)</td> <td>交易费用(待结算)</td>
@@ -223,11 +227,17 @@
</td> </td>
<td>{{priceFormat(floatPosition.PosiGrossPrice)}}</td> <td>{{priceFormat(floatPosition.PosiGrossPrice)}}</td>
<td> <td>
<vue-swap-price-input class="swap-delivery-price-input-unwind" v-model="floatPosition.TradingAmountAvg" v-bind:format="getDeliveryPriceInputFormat()" v-on:input="changeUnderlyingPrice"></vue-swap-price-input> <vue-swap-price-input class="swap-delivery-price-input-unwind" v-model="floatPosition.TradingAmountAvg" v-bind:format="getDeliveryPriceInputFormat()" v-on:input="onEndDeliveryPriceInput" v-on:enter="onEndBondPriceEnter('DP')" v-on:keydown="onEndBondPriceKeydown('DP',$event)"></vue-swap-price-input>
<span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="isBondUnwindLeg && floatPosition.bondDriverType==='DP'">源</span><span style="color:#1565c0;font-size:11px;margin-left:4px;" v-else-if="isBondUnwindLeg && floatPosition.bondAuto && floatPosition.bondAuto.DP">AUTO</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="isBondUnwindLeg && floatPosition.bondRev && floatPosition.bondRev.DP" title="人工输入:未回车触发计算器">REV</span>
<a href="javascript:void(0)" v-on:click="refreshUnderlyingPrice()"> <a href="javascript:void(0)" v-on:click="refreshUnderlyingPrice()">
<span title="使用系统标的价格" class="glyphicon glyphicon-refresh"></span> <span title="使用系统标的价格" class="glyphicon glyphicon-refresh"></span>
</a> </a>
</td> </td>
@* EQD-6953 期末标的结算收益率:回车以它为源反算交割全价(联动盈亏);手填未回车=REV人工输入 *@
<td v-if="deal.StructureType=='普通债券类收益互换'">
<vue-swap-price-input v-model="floatPosition.ExitYtm" v-bind:format="getYieldInputFormat()" v-on:input="onEndBondPriceEdit('YD')" v-on:enter="onEndBondPriceEnter('YD')" v-on:keydown="onEndBondPriceKeydown('YD',$event)"></vue-swap-price-input>
<span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="isBondUnwindLeg && floatPosition.bondDriverType==='YD'">源</span><span style="color:#1565c0;font-size:11px;margin-left:4px;" v-else-if="isBondUnwindLeg && floatPosition.bondAuto && floatPosition.bondAuto.YD">AUTO</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="isBondUnwindLeg && floatPosition.bondRev && floatPosition.bondRev.YD" title="人工输入:未回车触发计算器">REV</span>
</td>
<td>{{formatQuantity(deal.CloseQty)}}</td> <td>{{formatQuantity(deal.CloseQty)}}</td>
<td> <td>
<vue-number-input v-model="floatPosition.TradingFee" v-bind:format="inputFormatCloseAmount" v-on:input="changeTradingFee"></vue-number-input> <vue-number-input v-model="floatPosition.TradingFee" v-bind:format="inputFormatCloseAmount" v-on:input="changeTradingFee"></vue-number-input>
@@ -148,6 +148,15 @@ function loadVueApp(model, mockPost) {
return Number(Number(positionQty) * (Number(closePercent) / ori).toFixed(6)); return Number(Number(positionQty) * (Number(closePercent) / ori).toFixed(6));
} }
}, },
// EQD-6953:平仓页现于 unwindSwapTrade.js 之前加载 unwindBondCalc.js(见 SwapUnwind.cshtml),
// dataFormat 提交取整会引用其守卫;此处给同款行为的最小 mock(真实实现由 unwindBondCalc.test.js 覆盖)
UnwindBondCalc: {
hasValue(v) { return v !== null && v !== undefined && v !== '' && !isNaN(v); },
roundExitYtm(v) { return v; },
applyManualEdit(s) { return s; }
},
// 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖)
UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'),
_: { _: {
round(value, precision) { round(value, precision) {
return Number(Number(value || 0).toFixed(precision || 0)); return Number(Number(value || 0).toFixed(precision || 0));
+5
View File
@@ -23,8 +23,13 @@ module.exports = {
moduleDirectories: ['node_modules', '../wwwroot/Scripts'], moduleDirectories: ['node_modules', '../wwwroot/Scripts'],
// 覆盖率配置(--coverage 时生效) // 覆盖率配置(--coverage 时生效)
// 已知问题:源码在 rootDir(fe-tests) 之外,babel 提供器因 transform:{} 无插桩器、
// v8 提供器对 rootDir 逃逸路径无法归因(jest 29.7 实测,含 roots/绝对路径变体),
// 覆盖率表恒为 0 且门槛不触发。清单仍保留以固化意图,真实修复需重构 rootDir。
collectCoverageFrom: [ collectCoverageFrom: [
'../wwwroot/Scripts/app/swaptrade/swapCalc.js', '../wwwroot/Scripts/app/swaptrade/swapCalc.js',
'../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js',
'../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js',
'../wwwroot/Scripts/fast/fastVue.base.js', '../wwwroot/Scripts/fast/fastVue.base.js',
// 逐步加入更多文件 // 逐步加入更多文件
], ],
@@ -0,0 +1,178 @@
/**
* swapPricePrecisionHelper.test.js 字符串精确十进制核心的特征测试
* ============================================================================
* 目的swapPricePrecisionHelper.js 的组件事件(swapPriceInput.component.test.js)
* 配置接线(swapPrecisionConfig.test.js)已有测试字符串精确十进制核心
* (shiftDecimal / roundDecimal / multiplyDecimal / getRule / format /
* roundForSubmit) 一直只有间接覆盖精度即资损这里用 golden 值冻结现状
* 1. 全部走字符串数位运算绕开 IEEE754 浮点陷阱(0.1*0.21.005.toFixed)
* 2. roundDecimal 绝对值上四舍五入= AwayFromZero unwindBondCalc
* ExitYtm 约定同向
* 3. roundDecimal 不补零(1.2 '1.2')补零是 formatCommon/formatFixed 的职责
* 4. roundDecimal 对非法输入/负精度原样返回不归一调用方别拿它当校验器
* 5. getRule 的覆盖优先级先查 defaults 有无该品种(没有直接 null)再看
* main.swapPricePrecision 覆盖覆盖非法则回落 defaults
*
* 做法直接 require 源文件jest v8 覆盖率据此插桩readFileSync+new Function
* 的沙箱加载会让覆盖率统计看不见配置覆盖用 global.main.swapPricePrecision
* 注入模块内 global 绑定到 globalThis与本测试文件的 global 同源
* beforeEach/afterEach 清理防串扰
*
* 运行cd YLErpWeb/fe-tests && npx jest swapPricePrecisionHelper
*/
const helper = require('../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js');
function withConfig(config, extras) {
if (config !== undefined) global.main = { swapPricePrecision: config };
if (extras && extras.otcformat) global.otcformat = extras.otcformat;
}
beforeEach(() => { delete global.main; delete global.otcformat; });
afterEach(() => { delete global.main; delete global.otcformat; });
describe('shiftDecimal:纯数位平移,不经浮点', () => {
test('右移补零 / 左移进小数', () => {
expect(helper.shiftDecimal('1.5', 1)).toBe('15');
expect(helper.shiftDecimal('1.5', -1)).toBe('0.15');
expect(helper.shiftDecimal('12.34', -3)).toBe('0.01234');
expect(helper.shiftDecimal('0.001', -2)).toBe('0.00001');
expect(helper.shiftDecimal('5', 2)).toBe('500');
});
test('保留符号;places=0 或非整数只做归一化', () => {
expect(helper.shiftDecimal('-1.5', 1)).toBe('-15');
expect(helper.shiftDecimal('1.50', 0)).toBe('1.50'); // 不动尾巴零
expect(helper.shiftDecimal('1.50', 1.5)).toBe('1.50');
});
test('归一化先行:首尾零/符号/科学计数法', () => {
expect(helper.shiftDecimal('007.50', 0)).toBe('7.50');
expect(helper.shiftDecimal('+7', 0)).toBe('7');
expect(helper.shiftDecimal('-0.000', 0)).toBe('0.000'); // 负零坍缩为无符号
expect(helper.shiftDecimal('1e-7', 0)).toBe('0.0000001');
expect(helper.shiftDecimal('1.23e5', 0)).toBe('123000');
});
test('非法输入返回 null,空白串返回空串', () => {
expect(helper.shiftDecimal('abc', 2)).toBeNull();
expect(helper.shiftDecimal('1.2.3', 2)).toBeNull();
expect(helper.shiftDecimal('', 2)).toBe('');
});
});
describe('roundDecimal:绝对值四舍五入(AwayFromZero),字符串路径无浮点陷阱', () => {
test('经典浮点陷阱对照:1.005 与 0.1×0.2 场景字符串路径给正确答案', () => {
expect(Number(1.005).toFixed(2)).toBe('1.00'); // 浮点路径错(冻结对照)
expect(helper.roundDecimal('1.005', 2)).toBe('1.01');
expect(helper.roundDecimal('2.345', 2)).toBe('2.35');
expect(helper.roundDecimal('2.344', 2)).toBe('2.34');
});
test('负数远离零(与 ExitYtm 约定同向)', () => {
expect(helper.roundDecimal('-2.345', 2)).toBe('-2.35');
expect(helper.roundDecimal('-2.5', 0)).toBe('-3');
expect(helper.roundDecimal('-0.00005', 4)).toBe('-0.0001');
});
test('进位链:跨数量级与跨整数位', () => {
expect(helper.roundDecimal('9.99', 1)).toBe('10.0');
expect(helper.roundDecimal('9.99', 0)).toBe('10');
expect(helper.roundDecimal('99.999', 2)).toBe('100.00');
expect(helper.roundDecimal('0.00005', 4)).toBe('0.0001');
});
test('不补零也不主动去零:位数不足原样返回,已有位数保留尾巴零', () => {
expect(helper.roundDecimal('1.2', 4)).toBe('1.2'); // 不足4位不补零(补零是 formatCommon 的职责)
expect(helper.roundDecimal('3.10000', 4)).toBe('3.1000'); // 已到4位:只取整不去零(去零是 format 的职责)
});
test('防御姿态:负精度/非法输入原样返回,不做校验', () => {
expect(helper.roundDecimal('2.345', -1)).toBe('2.345');
expect(helper.roundDecimal('2.345', 1.5)).toBe('2.345');
expect(helper.roundDecimal('abc', 2)).toBe('abc');
});
});
describe('multiplyDecimal:字符串精确乘法', () => {
test('0.1×0.2 精确为 0.02(浮点给 0.020000000000000004)', () => {
expect(helper.multiplyDecimal('0.1', '0.2')).toBe('0.02');
expect(helper.multiplyDecimal('0.1', '0.1')).toBe('0.01');
expect(helper.multiplyDecimal('1.1', '1.1')).toBe('1.21');
});
test('符号组合;保留小数标度(1.5×2=3.0 而非 3)', () => {
expect(helper.multiplyDecimal('1.5', '2')).toBe('3.0');
expect(helper.multiplyDecimal('-1.5', '2')).toBe('-3.0');
expect(helper.multiplyDecimal('-1.5', '-2')).toBe('3.0');
expect(helper.multiplyDecimal('-0.03', '-0.02')).toBe('0.0006');
});
test('大整数精确(超出 Number.MAX_SAFE_INTEGER)', () => {
expect(helper.multiplyDecimal('123456789', '987654321')).toBe('121932631112635269');
});
test('非法输入 null;空串按 0 处理', () => {
expect(helper.multiplyDecimal('abc', '1')).toBeNull();
expect(helper.multiplyDecimal('', '1')).toBe('0');
});
});
describe('getRule / getInputFormat / roundForSubmit:字段规则解析与覆盖优先级', () => {
test('defaults 字段级规则:债券三字段 vs 股票兜底', () => {
expect(helper.getRule('Bond', 'yield')).toEqual({ integerDigits: 2, precision: 4 });
expect(helper.getRule('Bond', 'grossPrice')).toEqual({ integerDigits: 6, precision: 9 });
// 债券顶层没有 integerDigits/precision:字段拼错 → null(后续走 umprice 兜底)
// 而 Stock 顶层有 → 字段拼错回落品种级规则。这个不对称冻结于此。
expect(helper.getRule('Bond', 'unknownField')).toBeNull();
expect(helper.getRule('Stock', 'anything')).toEqual({ integerDigits: 7, precision: 2 });
expect(helper.getRule('NoSuchType', 'yield')).toBeNull();
});
test('main.swapPricePrecision 覆盖优先于 defaults,非法覆盖回落 defaults', () => {
withConfig({ Bond: { yield: { integerDigits: 3, precision: 5 } } });
expect(helper.getRule('Bond', 'yield')).toEqual({ integerDigits: 3, precision: 5 });
withConfig({ Bond: { yield: { integerDigits: 0 } } });
const bad = helper;
expect(bad.getRule('Bond', 'yield')).toEqual({ integerDigits: 2, precision: 4 });
// defaults 里没有的品种,配置了也不认:覆盖只允许白名单内微调
withConfig({ BrandNewType: { yield: { integerDigits: 3, precision: 5 } } });
const unknown = helper;
expect(unknown.getRule('BrandNewType', 'yield')).toBeNull();
});
test('getInputFormat 合并 options', () => {
expect(helper.getInputFormat('Bond', 'yield', { negative: true }))
.toEqual({ negative: true, integerDigits: 2, precision: 4 });
expect(helper.getInputFormat('NoSuchType', 'yield', { negative: true }))
.toEqual({ negative: true });
});
test('roundForSubmit:按规则精度+offset 取整;空值/无规则原样返回', () => {
expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield')).toBe('3.1416');
expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield', 2)).toBe('3.141593');
expect(helper.roundForSubmit('', 'Bond', 'yield')).toBe('');
expect(helper.roundForSubmit('1.2', 'NoSuchType', 'yield')).toBe('1.2');
expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield', 2.5)).toBe('3.1416'); // 非整数offset忽略
});
});
describe('format:字段级展示态(取整+去尾巴零)', () => {
test('按字段规则取整并去尾零', () => {
expect(helper.format('3.14159', 'Bond', 'yield')).toBe('3.1416');
expect(helper.format('3.10000', 'Bond', 'yield')).toBe('3.1');
expect(helper.format('99.5', 'Stock', 'whatever')).toBe('99.5');
expect(helper.format('', 'Bond', 'yield')).toBe('');
});
test('无规则时回落 otcformat.trading.umprice', () => {
withConfig(undefined, {
otcformat: { trading: { umprice: function (v) { return 'UM:' + v; } } }
});
expect(helper.format('1.23', 'NoSuchType', 'yield')).toBe('UM:1.23');
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* unwindBondCalc.test.js 平仓页 DPYD 两字段互算纯函数EQD-6953
* ============================================================================
* 冻结三条与录入页不同极易被"顺手统一"改坏的约定
* 1. 发计算器的价格是展示态直传 ×100录入页是存储态 ×100
* 2. ExitYtm 保留 4 四舍五入(AwayFromZero)结算确认书导出固定 4 位不去零
* 界面精度不得低于导出要求
* 3. 失败只清对方字段平仓页无净价列 DP/YD 两字段
*
* 运行cd YLErpWeb/fe-tests && npx jest unwindBondCalc
*/
const UnwindBondCalc = require('../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js');
const CALC = { cleanPrice: 98.5, dirtyPrice: 99.5, ytm: 6.3721 };
describe('getCalcRequest:展示态直传,不做录入页的 ×100 换算', () => {
test('DP 回车:price 原样 99.5(若误加 ×100 会变成 9950),priceType=DP,估值日=平仓日', () => {
const req = UnwindBondCalc.getCalcRequest('240004.IB', 99.5, 'DP', '2026-08-20');
expect(req.price).toBe(99.5);
expect(req.priceType).toBe('DP');
expect(req.targetDate).toBe('2026-08-20');
expect(req.underlyingCode).toBe('240004.IB');
expect(req.source).toBe('unwind');
});
test('YD 回车:收益率展示态直传(6.3721 而非 0.063721', () => {
const req = UnwindBondCalc.getCalcRequest('240004.IB', 6.3721, 'YD', '2026-08-20');
expect(req.price).toBe(6.3721);
expect(req.priceType).toBe('YD');
});
test('估值日为空时 targetDate 为 null(兜底,调用方应先校验)', () => {
const req = UnwindBondCalc.getCalcRequest('240004.IB', 99.5, 'DP', null);
expect(req.targetDate).toBeNull();
});
});
describe('roundExitYtm4 位小数、四舍五入(远离零)', () => {
test('第 5 位进位', () => {
expect(UnwindBondCalc.roundExitYtm(6.37215)).toBe(6.3722);
});
test('第 5 位舍去', () => {
expect(UnwindBondCalc.roundExitYtm(6.37214)).toBe(6.3721);
});
test('负收益率同样远离零(负利率债)', () => {
expect(UnwindBondCalc.roundExitYtm(-0.00005)).toBe(-0.0001);
expect(UnwindBondCalc.roundExitYtm(-1.23445)).toBe(-1.2345);
});
});
describe('applyCalcResult:源字段保持、仅回写对方字段', () => {
test('DP 为源:TradingAmountAvg 保持手输值,ExitYtm 被反算覆盖(4位)', () => {
const state = { TradingAmountAvg: 100.123456, ExitYtm: null };
UnwindBondCalc.applyCalcResult(state, { ytm: 6.37215 }, 'DP');
expect(state.TradingAmountAvg).toBe(100.123456); // 源:保持
expect(state.ExitYtm).toBe(6.3722); // 对方:反算+4位取整
});
test('YD 为源:ExitYtm 保持手输值,TradingAmountAvg 被反算覆盖(展示态直写)', () => {
const state = { TradingAmountAvg: null, ExitYtm: 6.3721 };
UnwindBondCalc.applyCalcResult(state, { dirtyPrice: 99.5023 }, 'YD');
expect(state.ExitYtm).toBe(6.3721); // 源:保持
expect(state.TradingAmountAvg).toBe(99.5023); // 对方:直写(不 ÷100
});
test('计算器未返回对方值时不覆盖(保留手工输入)', () => {
const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3 };
UnwindBondCalc.applyCalcResult(state, { ytm: null }, 'DP');
expect(state.ExitYtm).toBe(6.3);
UnwindBondCalc.applyCalcResult(state, { dirtyPrice: undefined }, 'YD');
expect(state.TradingAmountAvg).toBe(100.5);
});
test('回写值与现值相同(<1e-9)时不写,避免光标跳动', () => {
const state = { TradingAmountAvg: 100, ExitYtm: 6.3721 };
UnwindBondCalc.applyCalcResult(state, { ytm: 6.3721 }, 'DP');
expect(state.ExitYtm).toBe(6.3721);
});
});
describe('applyCalcFailure:保留源字段、只清对方字段、标识全清', () => {
test('DP 为源失败:ExitYtm 清空、全价保留', () => {
const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'DP', bondAuto: { DP: true, YD: false } };
UnwindBondCalc.applyCalcFailure(state, 'DP');
expect(state.TradingAmountAvg).toBe(100.5);
expect(state.ExitYtm).toBeNull();
expect(state.bondDriverType).toBeNull();
expect(state.bondAuto).toEqual({ DP: false, YD: false });
expect(state.bondRev).toEqual({ DP: false, YD: false });
});
test('YD 为源失败:TradingAmountAvg 清空、收益率保留', () => {
const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'YD' };
UnwindBondCalc.applyCalcFailure(state, 'YD');
expect(state.ExitYtm).toBe(6.3);
expect(state.TradingAmountAvg).toBeNull();
});
test('driver 非法(null):仅清标识、不动任何数值(setValueDate 清陈旧标识场景的防误清守卫)', () => {
const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'DP', bondAuto: { DP: false, YD: true } };
UnwindBondCalc.applyCalcFailure(state, null);
expect(state.TradingAmountAvg).toBe(100.5); // 绝不能被清
expect(state.ExitYtm).toBe(6.3);
expect(state.bondDriverType).toBeNull();
expect(state.bondAuto).toEqual({ DP: false, YD: false });
});
});
describe('标识状态机(交互约定2/3,对齐录入页)', () => {
test('成功:源字段标源、对方标 AUTO、REV 清空', () => {
const state = { TradingAmountAvg: 100.5, ExitYtm: null };
UnwindBondCalc.applyCalcSuccess(state, 'DP');
expect(state.bondDriverType).toBe('DP');
expect(state.bondAuto).toEqual({ DP: false, YD: true });
expect(state.bondRev).toEqual({ DP: false, YD: false });
});
test('手动编辑未回车:清 源/AUTO、本字段累计 REV、不清对方 REV', () => {
const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondRev: { DP: true, YD: false } };
UnwindBondCalc.applyManualEdit(state, 'YD');
expect(state.bondDriverType).toBeNull();
expect(state.bondAuto).toEqual({ DP: false, YD: false });
expect(state.bondRev).toEqual({ DP: true, YD: true }); // 累计:DP 的 REV 保留
});
test('bondRev 缺失时自动初始化', () => {
const state = {};
UnwindBondCalc.applyManualEdit(state, 'DP');
expect(state.bondRev).toEqual({ DP: true, YD: false });
});
});
describe('FIELDS 映射与 hasValue 守卫', () => {
test('字段映射', () => {
expect(UnwindBondCalc.FIELDS).toEqual({ DP: 'TradingAmountAvg', YD: 'ExitYtm' });
});
test('hasValue:空串/null/undefined/NaN 为 false0 为 true', () => {
expect(UnwindBondCalc.hasValue('')).toBe(false);
expect(UnwindBondCalc.hasValue(null)).toBe(false);
expect(UnwindBondCalc.hasValue(undefined)).toBe(false);
expect(UnwindBondCalc.hasValue(NaN)).toBe(false);
expect(UnwindBondCalc.hasValue(0)).toBe(true);
expect(UnwindBondCalc.hasValue('6.37')).toBe(true);
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* unwindLegSign.test.js 平仓页收付方向符号映射隐式约定显式化
* ============================================================================
* 冻结的领域事实2026-08-21 业务确认
* 利息腿融资成本与保证金腿返息/返还本金方向必须相反
* 利息是买方持有标的向交易商融资的成本买方付出去的钱
* 保证金是客户自己交的抵押金返息/返还是把客户自己的钱退回来
* 两者对同一 InterestDirection 枚举符号互为镜像这是业务事实而非笔误
* 任何人"顺手统一"这两个符号都会翻转保证金返还方向资损级 bug
*
* 另冻结PayDirection==1 +1PositionType==1(多头) +1 的浮动端/多空符号
*
* 运行cd YLErpWeb/fe-tests && npx jest unwindLegSign
*/
const UnwindLegSign = require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js');
describe('收付方向符号:各枚举映射', () => {
test('利息盈亏(利息腿/保证金腿通用):收取(1)→+1,支付(其他)→-1', () => {
expect(UnwindLegSign.interestPnlSign(1)).toBe(1);
expect(UnwindLegSign.interestPnlSign(0)).toBe(-1);
expect(UnwindLegSign.interestPnlSign(null)).toBe(-1);
});
test('保证金腿返还本金:与利息腿同枚举反号(返的是客户自己的钱)', () => {
expect(UnwindLegSign.marginRebatePrincipalSign(1)).toBe(-1);
expect(UnwindLegSign.marginRebatePrincipalSign(0)).toBe(1);
expect(UnwindLegSign.marginRebatePrincipalSign(null)).toBe(1);
});
test('浮动端收付:PayDirection==1(收取)→+1;多空:PositionType==1(多头)→+1', () => {
expect(UnwindLegSign.payDirectionSign(1)).toBe(1);
expect(UnwindLegSign.payDirectionSign(0)).toBe(-1);
expect(UnwindLegSign.positionTypeSign(1)).toBe(1);
expect(UnwindLegSign.positionTypeSign(0)).toBe(-1);
});
});
describe('不变量:利息腿与保证金腿符号互为镜像(业务事实,禁止统一)', () => {
test('同一 InterestDirection 下两符号之和恒为 0', () => {
[1, 0, null, undefined, 2].forEach(dir => {
expect(
UnwindLegSign.interestPnlSign(dir)
+ UnwindLegSign.marginRebatePrincipalSign(dir)
).toBe(0);
});
});
});
@@ -66,6 +66,8 @@ function loadUnwindHelpers() {
roundHalfAwayFromZero(value) { return value; }, roundHalfAwayFromZero(value) { return value; },
calcCloseQtyByOriginalPercent() { return 0; } calcCloseQtyByOriginalPercent() { return 0; }
}, },
// 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖)
UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'),
_: { _: {
round(value, precision) { round(value, precision) {
return Number(Number(value || 0).toFixed(precision || 0)); return Number(Number(value || 0).toFixed(precision || 0));
@@ -167,21 +167,32 @@ function SendEmailEitherReport() {
main.message("请选择普通交易"); main.message("请选择普通交易");
return; return;
} }
var url = "/trs_hub_api/swap/email/settle/batchSend"; // EQD-5320: 改走本系统后端代理(/swaptrade2/BatchSendSettleEmail → BondOmsInterface_BaseUrl → bond-oms)
var postData = { swapFlowEventIds: selIds } // 与债券计算器同一条服务端出口;原直连 /trs_hub_api 反向代理在未配 nginx 的环境 404
var url = "/swaptrade2/BatchSendSettleEmail";
var postData = { swapFlowEventIds: selIds.join(',') }
$.ajax({ $.ajax({
type: "post", type: "post",
url: url, url: url,
data: JSON.stringify(postData), data: postData,
dataType: "json", dataType: "json",
contentType:"application/json",
success: function (res) { success: function (res) {
if (res.success) { // 兜底非标准返回结构,绝不让点击"无反应"
main.message("发送成功"); if (res && res.success) {
main.message(res.msg || ("发送成功(共" + selIds.length + "笔)"));
SearchClick(); // 刷新列表以反映发送状态
} else { } else {
main.message(res.message); var msg = (res && (res.msg || res.message)) || "发送失败:服务返回异常结构";
main.message(msg);
console.error("[批量发送确认书] 业务失败:", res);
} }
}, },
error: function (xhr, textStatus, errThrown) {
// 请求层失败:超时/返回非JSON(如被登录页重定向)
var detail = "HTTP " + xhr.status + " " + (textStatus || "") + (errThrown ? " " + errThrown : "");
main.message("发送请求失败(" + detail + "):请检查服务是否可用,详情见控制台与网络面板");
console.error("[批量发送确认书] 请求失败:", detail, xhr);
},
beforeSend(jqXHR) { beforeSend(jqXHR) {
main.waitMe(true); main.waitMe(true);
}, },
@@ -0,0 +1,132 @@
/**
* unwindBondCalc.js 平仓页 期末标的交割全价 期末标的结算收益率(ExitYtm) 两字段互算EQD-6953
* ============================================================================
* 与录入页(swapTradeEdit.js + swapCalc.js 债券三字段互算)的关键差异"顺手统一"
* 1. 单位状态录入页三字段(CP/DP/YD)存储态小数(0.995)发计算器前 ×100回写 ÷100
* (bondPriceToCalc/bondCalcPriceToStorage)平仓页 TradingAmountAvg initDeal 已转
* 展示态百分数(99.5)ExitYtm 亦定义为展示态(6.3721)发计算器不换算回写不换算
* 提交时的存储态转换仍由既有 getStorageDeliveryPrice()/dataFormat() 负责
* 2. 字段集只有 DP(TradingAmountAvg)/YD(ExitYtm) 两个字段平仓页无净价列
* 失败只清对方字段录入页 applyBondCalcFailure 清另外两个字段名也不同故不复用
* 3. 估值日用平仓日 deal.ValueDate结算语义录入页用互换起始日 StartDate
* 4. 精度ExitYtm 保留 4 位小数四舍五入(AwayFromZero)结算确认书导出要求
* 固定 4 位不去零(ToString("0.0000"))界面精度不得低于该要求
*
* 浏览器 window.UnwindBondCalc需在 swapCalc.js 之后unwindSwapTrade.js 之前加载
* Nodemodule.exportsUMD fe-tests/unwindBondCalc.test.js 使用
* 命名遵互换价格字段命名规范决策文档平仓/了结时点 = Exit End/Close/Final
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(require('./swapCalc.js'));
} else {
root.UnwindBondCalc = factory(root.SwapCalc);
}
})(typeof self !== 'undefined' ? self : this, function (SwapCalc) {
'use strict';
// driver(回车的"源"字段) → 平仓页浮动腿(floatPosition)字段名
var FIELDS = { DP: 'TradingAmountAvg', YD: 'ExitYtm' };
function hasValue(v) {
return v !== null && v !== undefined && v !== '' && !isNaN(v);
}
/**
* 构造 /Bond/CalcBond 请求体
* price 必须传展示态百分数直传99.5不做录入页那种 ×100
* 平仓页模型里两个字段本就是展示态此约定由 fe-tests 冻结防止后人误加换算
* source='unwind'BondController 记进日志便于区分录入页/平仓页来源定位问题
*/
function getCalcRequest(underlyingCode, price, driver, valueDate) {
return {
underlyingCode: underlyingCode,
price: Number(price),
priceType: driver,
targetDate: valueDate || null,
source: 'unwind'
};
}
/**
* ExitYtm 统一取整4 四舍五入(远离零)对齐后端 decimal ToString("0.0000")
* 非数值(null/undefined/NaN)返回 null write 跳过绝不能把 null 取整成 0 回写
* 否则计算器未返回 ytm 时会清掉用户手工输入
*/
function roundExitYtm(value) {
if (value === null || value === undefined || isNaN(value)) return null;
return SwapCalc.roundHalfAwayFromZero(value, 4);
}
/**
* 计算成功回写交互约定1+2源字段保持用户手输值仅回写对方字段展示态直写
* - driver='DP'全价回车 回写 ExitYtm = ytm 取整 4 计算器未返回 ytm 则不覆盖
* - driver='YD'收益率回车 回写 TradingAmountAvg = dirtyPrice未返回则不覆盖
* 调用方回写 TradingAmountAvg 后须显式重算平仓盈亏(calcFloatClosePnl)
* 纯函数jest 可直接测
*/
function applyCalcResult(state, calc, driver) {
function write(field, value) {
if (value === undefined || value === null) return; // 计算器未返回则不覆盖
var cur = state[field];
if (typeof cur === 'number' && Math.abs(cur - value) < 1e-9) return; // 无变化不写
state[field] = value;
}
if (driver === 'DP') {
write('ExitYtm', roundExitYtm(calc && calc.ytm));
} else if (driver === 'YD') {
write('TradingAmountAvg', calc && calc.dirtyPrice);
}
return state;
}
/**
* 计算失败落地交互约定2保留源字段值用户可就地改清对方字段清全部标识
* 与录入页差异只清"对方"一个字段录入页清另外两个
* driver 非法(null/未知)时仅清标识不动任何数值防调用方误清源字段
* setValueDate 只想清陈旧标识时误传 null会把 TradingAmountAvg 清掉
*/
function applyCalcFailure(state, driver) {
if (driver === 'DP' || driver === 'YD') {
var other = driver === 'DP' ? 'YD' : 'DP';
state[FIELDS[other]] = null;
}
state.bondDriverType = null;
state.bondAuto = { DP: false, YD: false };
state.bondRev = { DP: false, YD: false };
return state;
}
/** 计算成功后的标识(交互约定2):源字段标"源",对方标"AUTO"。 */
function applyCalcSuccess(state, driver) {
state.bondDriverType = driver;
state.bondAuto = { DP: driver !== 'DP', YD: driver !== 'YD' };
state.bondRev = { DP: false, YD: false };
return state;
}
/**
* 编辑未回车失焦/按键的标识交互约定3 /AUTO仅本字段累计标"REV"
* 不联动对方字段允许计算有问题时手工覆盖 ExitYtm同录入页累计 REV 不互清
*/
function applyManualEdit(state, type) {
state.bondDriverType = null;
state.bondAuto = { DP: false, YD: false };
if (!state.bondRev || typeof state.bondRev !== 'object') {
state.bondRev = { DP: false, YD: false };
}
if (type) state.bondRev[type] = true;
return state;
}
return {
FIELDS: FIELDS,
hasValue: hasValue,
getCalcRequest: getCalcRequest,
roundExitYtm: roundExitYtm,
applyCalcResult: applyCalcResult,
applyCalcFailure: applyCalcFailure,
applyCalcSuccess: applyCalcSuccess,
applyManualEdit: applyManualEdit
};
});
@@ -0,0 +1,45 @@
/**
* unwindLegSign.js 平仓页收付方向符号纯函数
* ============================================================================
* 把散落在 unwindSwapTrade.js 各计算式里的裸三元xxx==1 ? ±1 : 1显式化为命名函数
* 为什么必须显式化利息盈亏与保证金返还本金对同一 InterestDirection 枚举符号相反
* 这是业务事实而非笔误任何"顺手统一"都会翻转保证金返还方向资损级 bug
* 利息盈亏利息腿/保证金腿通用changeInterestAmount 对两类行都生效
* 收益互换中买方为持有标的向交易商融资利息=融资成本买方付出去的钱
* InterestDirection==1(收取) 盈亏记 +1
* 保证金返还本金保证金是客户自己交的抵押金返息/返还本金是把客户自己的钱退回来
* 本金流方向与利息成本相反 同枚举符号取镜像 -1
*
* 其余两个符号PayDirection==1(浮动端收取) +1PositionType==1(多头) +1
*
* 加载浏览器 script 标签先于 unwindSwapTrade.jsNodemodule.exports jest
*/
var UnwindLegSign = (function () {
'use strict';
// Number()===1 与页面原 ==1 对 '1'/true/数字等实际入参等价,且对 null/undefined 同样落到 -1 分支
function interestPnlSign(interestDirection) {
return Number(interestDirection) === 1 ? 1 : -1;
}
function marginRebatePrincipalSign(interestDirection) {
return Number(interestDirection) === 1 ? -1 : 1;
}
function payDirectionSign(payDirection) {
return Number(payDirection) === 1 ? 1 : -1;
}
function positionTypeSign(positionType) {
return Number(positionType) === 1 ? 1 : -1;
}
return Object.freeze({
interestPnlSign: interestPnlSign,
marginRebatePrincipalSign: marginRebatePrincipalSign,
payDirectionSign: payDirectionSign,
positionTypeSign: positionTypeSign
});
}());
if (typeof module === 'object' && module.exports) module.exports = UnwindLegSign;
@@ -11,6 +11,9 @@ const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true }); const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true });
const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true }); const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true });
// EQD-6953 期末标的结算收益率(ExitYtm):展示态百分数(如 6.3721)trimTailZeros:false 不去零;
// 精度由 getInputFormat('yield') 规则给 4 位——不得低于结算确认书导出的固定 4 位(0.0000)。
const inputFormatUnwindExitYtm = Object.freeze({ append: '', negative: true, trimTailZeros: false });
const consPosiFeeType = Object.freeze({ Percent: 0, Unit: 1 }); const consPosiFeeType = Object.freeze({ Percent: 0, Unit: 1 });
const swapPosiFeeCalc = { const swapPosiFeeCalc = {
normalizeFeeType(feeType) { normalizeFeeType(feeType) {
@@ -61,11 +64,11 @@ const vue = new Vue({
marginList: [], marginList: [],
initPosiNetPrice: 0, initPosiNetPrice: 0,
multiplier: 1, multiplier: 1,
// EQD-6953 簿记模板=普通债券类收益互换 时启用 期末交割全价↔结算收益率(ExitYtm) 互算
isBondTRS: false,
// 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。 // 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。
// oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。 // oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。
oriClosePercent: 1, oriClosePercent: 1,
ratio: 1,
shortRatio: 1,
}, },
computed: { computed: {
maxUnwindDate() { maxUnwindDate() {
@@ -73,10 +76,15 @@ const vue = new Vue({
}, },
minStartDate() { minStartDate() {
return this.deal.StartDate; return this.deal.StartDate;
},
// EQD-6953:簿记模板为普通债券类收益互换 且 浮动腿标的为债券 时,才展示 源/AUTO/REV 标识并允许互算
isBondUnwindLeg() {
return this.isBondTRS && !!this.floatPosition && this.IsBond(this.floatPosition.UnderlyingInstrumentType);
} }
}, },
created() { created() {
this.multiplier = this.deal.StructureType == '普通债券类收益互换' ? 100 : 1; this.isBondTRS = this.deal.StructureType == '普通债券类收益互换';
this.multiplier = this.isBondTRS ? 100 : 1;
this.initDeal(); this.initDeal();
this.setValueDate(this.deal.ValueDate); this.setValueDate(this.deal.ValueDate);
}, },
@@ -115,8 +123,6 @@ const vue = new Vue({
this.marginList = model.FlowEvents.filter((item) => { this.marginList = model.FlowEvents.filter((item) => {
return item.InterestMode == 5 || item.InterestMode == 6; return item.InterestMode == 5 || item.InterestMode == 6;
}); });
this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1;
this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1;
this.TradeStartDate = model.TradeStartDate; this.TradeStartDate = model.TradeStartDate;
// 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1 // 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1
this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue) this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue)
@@ -151,6 +157,10 @@ const vue = new Vue({
this.floatPosition.TradingAmountAvg, this.floatPosition.TradingAmountAvg,
this.floatPosition.UnderlyingInstrumentType, this.floatPosition.UnderlyingInstrumentType,
'grossPrice'); 'grossPrice');
// EQD-6953 期末结算收益率:提交前统一 4 位四舍五入(幂等;空/非数保持原样不覆盖)
if (UnwindBondCalc.hasValue(this.floatPosition.ExitYtm)) {
this.floatPosition.ExitYtm = UnwindBondCalc.roundExitYtm(this.floatPosition.ExitYtm);
}
this.floatPosition.TradingFee = formatSwapAmount(this.floatPosition.TradingFee); this.floatPosition.TradingFee = formatSwapAmount(this.floatPosition.TradingFee);
this.floatPosition.TradingFeePending = formatSwapAmount(this.floatPosition.TradingFeePending); this.floatPosition.TradingFeePending = formatSwapAmount(this.floatPosition.TradingFeePending);
this.floatPosition.DividendIn = formatSwapAmount(this.floatPosition.DividendIn); this.floatPosition.DividendIn = formatSwapAmount(this.floatPosition.DividendIn);
@@ -174,6 +184,14 @@ const vue = new Vue({
this.deal.UnwindDate = e; this.deal.UnwindDate = e;
this.floatPosition.UnwindDate = e; this.floatPosition.UnwindDate = e;
} }
// EQD-6953 估值日(平仓日)变了,已算出的期末结算收益率随之失效:清值+清标识,待用户重新回车计算
// applyManualEdit(null) 只清 源/AUTO/REV 不动任何数值;勿用 applyCalcFailure(null)——
// 它对 null driver 会误清 TradingAmountAvg
if (this.isBondUnwindLeg && this.floatPosition.ExitYtm != null) {
this.floatPosition.ExitYtm = null;
UnwindBondCalc.applyManualEdit(this.floatPosition, null);
this.syncUnwindBondFlags();
}
if (!isUseApproval) { if (!isUseApproval) {
// 新增:刷新持仓基线(处理公司行为除权) // 新增:刷新持仓基线(处理公司行为除权)
this.refreshUnwindBaseline(); this.refreshUnwindBaseline();
@@ -301,10 +319,111 @@ const vue = new Vue({
thisObj.calcFloatClosePnl(); thisObj.calcFloatClosePnl();
}); });
}, },
//==================================================================================
// EQD-6953 期末标的交割全价 ↔ 期末标的结算收益率(ExitYtm) 互算
// 交互对齐录入页(swapTradeEdit.js):回车=以该字段为源调 /Bond/CalcBond 反算对方;
// 失败=保留源字段、清对方、标识全清;手填未回车=REV 不联动(允许计算有问题时手工覆盖)。
// 关键差异见 unwindBondCalc.js 头注:平仓页两字段均为展示态(不 ×100/÷100),估值日=平仓日 ValueDate。
//==================================================================================
getYieldInputFormat() {
return swapPricePrecision.getInputFormat(
this.floatPosition && this.floatPosition.UnderlyingInstrumentType,
'yield',
inputFormatUnwindExitYtm);
},
//期末交割全价的 input(失焦/回车都会触发):保持既有盈亏联动;债券腿按交互约定3标 REV。
onEndDeliveryPriceInput() {
this.changeUnderlyingPrice();
if (this.isBondUnwindLeg) {
UnwindBondCalc.applyManualEdit(this.floatPosition, 'DP');
this.syncUnwindBondFlags();
}
},
//收益率字段编辑(失焦/回车触发,组件内部回车时 enter 随后修正标识):约定3 标 REV 不联动。
onEndBondPriceEdit(type) {
if (!this.isBondUnwindLeg) return;
UnwindBondCalc.applyManualEdit(this.floatPosition, type);
this.syncUnwindBondFlags();
},
//按键实时标 REV(组件 input 事件只在失焦/回车触发,逐键输入期间靠 keydown 清 源/AUTO)。
//回车/修饰键/导航键/功能键不改标识(SwapCalc.isBondPriceValueKey 白名单)。
onEndBondPriceKeydown(type, event) {
if (!this.isBondUnwindLeg) return;
if (!SwapCalc.isBondPriceValueKey(event)) return;
UnwindBondCalc.applyManualEdit(this.floatPosition, type);
this.syncUnwindBondFlags();
},
//回车=以该字段为源调计算器反算对方字段。
onEndBondPriceEnter(type) { // type: 'DP'期末交割全价 / 'YD'期末结算收益率
if (!this.isBondUnwindLeg) return;
if (!this.floatPosition.UnderlyingCode) { main.message("请先选择债券标的"); return; }
var price = type === 'DP' ? this.floatPosition.TradingAmountAvg : this.floatPosition.ExitYtm;
if (!UnwindBondCalc.hasValue(price)) return; // 空/非数:不触发计算器
this.calcUnwindBond(type);
},
//以 driver(回车字段)为源调 /Bond/CalcBond。成败路由见 base/main.js __post:业务错误走 reject
//失败落地必须在 .fail(录入页曾把失败处理只写 .done 导致静默失效,勿重蹈)。
calcUnwindBond(driver) {
if (!this.isBondUnwindLeg) return;
var fp = this.floatPosition;
fp.bondDriverType = driver; // 先定源,回写时 applyCalcResult 会跳过该字段
var price = driver === 'DP' ? fp.TradingAmountAvg : fp.ExitYtm;
if (!UnwindBondCalc.hasValue(price)) {
UnwindBondCalc.applyCalcFailure(fp, driver);
this.syncUnwindBondFlags();
return;
}
if (!this.deal.ValueDate) {
main.message("请先填写平仓日期(作为债券计算器估值日)");
UnwindBondCalc.applyCalcFailure(fp, driver);
this.syncUnwindBondFlags();
return;
}
var req = UnwindBondCalc.getCalcRequest(fp.UnderlyingCode, price, driver, this.deal.ValueDate);
otcDebug.log('[unwindBondCalc] enter driver=' + driver + ' req=' + JSON.stringify(req));
var self = this;
main.post('/Bond/CalcBond', req, { alertFn: main.message }).done(function (resp) {
if (!resp || !resp.obj) { // 网络错误/响应异常:框架已提示,按约定2清对方字段+标识
UnwindBondCalc.applyCalcFailure(fp, driver);
self.syncUnwindBondFlags();
return;
}
var err = SwapCalc.getBondCalcErrorMessage(resp.obj);
if (err) { // 业务错误(债券不存在/信息不全/参数非法/值域离谱):toast 去重提示,不回写
if (SwapCalc.shouldShowBondErr(fp, err)) main.message(err);
UnwindBondCalc.applyCalcFailure(fp, driver);
self.syncUnwindBondFlags();
otcDebug.log('[unwindBondCalc] calc-error driver=' + driver + ' msg=' + err);
return;
}
fp._lastBondErr = null;
UnwindBondCalc.applyCalcResult(fp, resp.obj, driver);
UnwindBondCalc.applyCalcSuccess(fp, driver);
self.syncUnwindBondFlags();
// YD 为源时 TradingAmountAvg 被反算回填,直接赋值不触发 input 事件,须显式重算盈亏
if (driver === 'YD') self.calcFloatClosePnl();
otcDebug.log('[unwindBondCalc] success driver=' + driver +
' ExitYtm=' + fp.ExitYtm + ' TradingAmountAvg=' + fp.TradingAmountAvg +
' auto=' + JSON.stringify(fp.bondAuto));
}).fail(function () {
UnwindBondCalc.applyCalcFailure(fp, driver);
self.syncUnwindBondFlags();
otcDebug.log('[unwindBondCalc] post-fail/reject driver=' + driver);
});
},
//确保 bondDriverType/bondAuto/bondRev 的变更触发 Vue 2 响应式更新(同录入页 syncBondFlags
//这些属性不在后端模型里,$set + 全新对象引用 + $forceUpdate 兜底,缺一视图不刷新)。
syncUnwindBondFlags() {
var fp = this.floatPosition;
this.$set(fp, 'bondDriverType', fp.bondDriverType === undefined ? null : fp.bondDriverType);
this.$set(fp, 'bondAuto', { DP: !!(fp.bondAuto && fp.bondAuto.DP), YD: !!(fp.bondAuto && fp.bondAuto.YD) });
this.$set(fp, 'bondRev', { DP: !!(fp.bondRev && fp.bondRev.DP), YD: !!(fp.bondRev && fp.bondRev.YD) });
this.$forceUpdate();
},
calcFloatClosePnl() {//计算浮动端平仓盈亏 calcFloatClosePnl() {//计算浮动端平仓盈亏
var thisObj = this; var thisObj = this;
let floatRatio = thisObj.floatPosition.PayDirection == 1 ? 1 : -1; let floatRatio = UnwindLegSign.payDirectionSign(thisObj.floatPosition.PayDirection);
let longRatio = thisObj.floatPosition.PositionType == 1 ? 1 : -1; let longRatio = UnwindLegSign.positionTypeSign(thisObj.floatPosition.PositionType);
let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee);
let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending); let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending);
let deliveryPrice = thisObj.getStorageDeliveryPrice(); let deliveryPrice = thisObj.getStorageDeliveryPrice();
@@ -319,7 +438,7 @@ const vue = new Vue({
this.calcFloatClosePnl(); this.calcFloatClosePnl();
}, },
changeInterestAmount(item) {//修改利息金额 changeInterestAmount(item) {//修改利息金额
let interestRatio = item.InterestDirection == 1 ? 1 : -1; let interestRatio = UnwindLegSign.interestPnlSign(item.InterestDirection);
item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee) * interestRatio); item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee) * interestRatio);
this.calcCloseAmount(); this.calcCloseAmount();
}, },
@@ -329,8 +448,8 @@ const vue = new Vue({
// this.calcCloseAmount(); // this.calcCloseAmount();
//}, //},
calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付 calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付
let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1; let floatRatio = UnwindLegSign.payDirectionSign(this.floatPosition.PayDirection);
let ratio = this.floatPosition.PositionType == 1 ? 1 : -1; let ratio = UnwindLegSign.positionTypeSign(this.floatPosition.PositionType);
let thisObj = this; let thisObj = this;
let pnl = parseFloat(this.floatPosition.FloatPnlSum); let pnl = parseFloat(this.floatPosition.FloatPnlSum);
let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee);
@@ -347,14 +466,14 @@ const vue = new Vue({
thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * ratio; thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * ratio;
} }
this.interestList.forEach(x => { this.interestList.forEach(x => {
/*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/
let interestAmount = parseFloat(x.InterestClosePnL); let interestAmount = parseFloat(x.InterestClosePnL);
thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount;
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;
}); });
this.marginList.forEach(x => { this.marginList.forEach(x => {
let interestAmount = parseFloat(x.InterestClosePnL); let interestAmount = parseFloat(x.InterestClosePnL);
let interestRatio = x.InterestDirection == 1 ? -1 : 1; // 保证金返还本金符号与利息盈亏同枚举反号(见 unwindLegSign.js 头注——业务事实勿统一)
let interestRatio = UnwindLegSign.marginRebatePrincipalSign(x.InterestDirection);
thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount;
thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount; thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount;
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;