Merge branch 'master' of https://gitee.glmszq.com/gsty/onederiv/trs into feature/p132_154-yf-new-sso-login

This commit is contained in:
尹峰
2026-08-20 17:02:58 +08:00
47 changed files with 3836 additions and 254 deletions
@@ -471,7 +471,7 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
var notional = trade.OriginalStockEqvNotional ?? 0;
var tradingFee = (double)swapPosition.PosiTradingFeePending;
var basicFeeRate = notional == 0 ? 0 : tradingFee / notional * 100;
dic["基本费率"] = basicFeeRate.ToString("0.##");
dic["基本费率"] = basicFeeRate.ToString("0.####");
// 期初现金交换比例和金额(使用初始预付金数据)
dic["期初现金交换比例"] = initialMarginPosition != null
@@ -0,0 +1,581 @@
using System.Diagnostics;
using YLErp.BLL;
using YLErp.DBModels;
namespace YLErp.Modules.EodModule
{
/// <summary>
/// FR007 UnderlyingId 错挂诊断(连 96 测试库)—— GLMS-20260701 同类事故复发排查
/// ============================================================================
/// 背景:EodPriceUnderlyingIdGuardTest 记录的事故——
/// FR007 价格行 UnderlyingCode='FR007' 但 UnderlyingId 被错写成
/// 511160.SH(2173889)/159111.SZ(2173890),正确应为 FR007 的 2170838。
/// 网页端按 UnderlyingId(int) JOIN underlying_manager 把 FR007 行误挂到别的标的(显示正常);
/// EOD 结算按 UnderlyingCode(string 'FR007') JOIN 查不到 → "结算价格缺失 / 没用上"。
///
/// 本测试连真实库,回答用户问题:"是不是又关联到错误标的了?"
/// 1) 查 underlying_manager 里 FR007 的正确 id
/// 2) 查 eod_commodity_future_price 里所有 UnderlyingCode='FR007' 的行,看 UnderlyingId 是否=正确 id
/// 3) 对比"网页端查询(UnderlyingId JOIN)" vs "EOD 查询(UnderlyingCode JOIN)" 是否一致
/// 4) 核对最近 N 天的 FR007 行是否错挂(复发判定)
///
/// 用法:本地连 96 库跑 Diagnose_FR007_UnderlyingIdMismatch;连不上库自动 Inconclusive。
/// </summary>
[TestClass]
public class GLMS20260805FR007UnderlyingIdDiagnoseTest
{
/// <summary>
/// 诊断 FR007 价格行的 UnderlyingId 是否错挂(GLMS-20260701 同类复发判定)
/// </summary>
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_FR007_UnderlyingIdMismatch()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
// ---- 1. 查 underlying_manager 里 FR007 的正确 id(权威定义)----
var fr007Underlying = db.underlying_manager
.Where(a => a.UnderlyingCode == "FR007")
.Select(a => new { a.id, a.UnderlyingCode, a.UnderlyingName })
.ToList();
Console.WriteLine("===== 1. underlying_manager 里 FR007 的定义 =====");
if (fr007Underlying.Count == 0)
{
Console.WriteLine(" ⚠⚠ underlying_manager 无 UnderlyingCode='FR007' 的记录!");
Console.WriteLine(" → 这是致命问题:EOD 按 UnderlyingCode 查 FR007 必然查不到(结算价格缺失)");
}
foreach (var u in fr007Underlying)
{
Console.WriteLine($" id={u.id} Code={u.UnderlyingCode} Name={u.UnderlyingName} ← 这是 FR007 的正确 UnderlyingId");
}
int? fr007CorrectId = fr007Underlying.FirstOrDefault()?.id;
Console.WriteLine();
// ---- 2. 查 eod_commodity_future_price 里所有 FR007 行,看 UnderlyingId 是否错挂 ----
var fr007PriceRows = db.eod_commodity_future_price
.Where(a => a.UnderlyingCode == "FR007")
.OrderByDescending(a => a.ValueDate)
.Take(30)
.Select(a => new { a.ValueDate, a.UnderlyingCode, a.UnderlyingId, a.ReferencePrice, a.DataSource })
.ToList();
Console.WriteLine($"===== 2. eod_commodity_future_price 里 FR007 行(最近{fr007PriceRows.Count}条,新→旧)=====");
Console.WriteLine($" {"ValueDate",-12}{"UnderlyingCode",-16}{"UnderlyingId",-14}{"?",-12}{"ReferencePrice",-16}{"DataSource"}");
int mismatchCount = 0;
foreach (var r in fr007PriceRows)
{
bool mismatched = fr007CorrectId.HasValue && r.UnderlyingId.HasValue && r.UnderlyingId.Value != fr007CorrectId.Value;
if (mismatched) mismatchCount++;
string flag = mismatched ? "⚠错挂!" : (r.UnderlyingId == null ? "空" : "✓正确");
Console.WriteLine($" {r.ValueDate:yyyy-MM-dd} {r.UnderlyingCode,-16}{r.UnderlyingId?.ToString() ?? "NULL",-14}{flag,-12}{r.ReferencePrice,-16}{r.DataSource}");
}
Console.WriteLine($"\n 小结:{mismatchCount}/{fr007PriceRows.Count} 条 FR007 行 UnderlyingId 错挂");
if (mismatchCount > 0)
{
Console.WriteLine(" ⚠⚠ 确认复发:FR007 行的 UnderlyingId 被错写成别的标的 id");
Console.WriteLine(" 网页端按 UnderlyingId JOIN 能查到(误挂到 511160.SH/159111.SZ 等),但 EOD 按 UnderlyingCode='FR007' 查反而正常");
Console.WriteLine(" → 若用户看到'网页有、EOD 没用上',需进一步看 EOD 查询路径(见下方第4步)");
}
Console.WriteLine();
// ---- 3. 反向查:UnderlyingId=FR007正确id 的行里,有没有 UnderlyingCode 不是 FR007 的(错误传染方向2)----
if (fr007CorrectId.HasValue)
{
var crossContaminated = db.eod_commodity_future_price
.Where(a => a.UnderlyingId == fr007CorrectId.Value && a.UnderlyingCode != "FR007")
.OrderByDescending(a => a.ValueDate)
.Take(10)
.Select(a => new { a.ValueDate, a.UnderlyingCode, a.UnderlyingId, a.ReferencePrice })
.ToList();
Console.WriteLine($"===== 3. 反向查:UnderlyingId=FR007({fr007CorrectId}) 但 Code≠FR007 的行(错误传染方向2=====");
if (crossContaminated.Count == 0)
{
Console.WriteLine(" (无)FR007 的 id 没有被别的标的发生的行误用");
}
else
{
Console.WriteLine($" ⚠ 发现 {crossContaminated.Count} 条:这些行占了 FR007 的 id 但 Code 是别的标的");
foreach (var c in crossContaminated)
{
Console.WriteLine($" {c.ValueDate:yyyy-MM-dd} Code={c.UnderlyingCode} UnderlyingId={c.UnderlyingId} Price={c.ReferencePrice}");
}
}
}
Console.WriteLine();
// ---- 4. EOD 查询路径验证:EodPriceQueryService.TryGetPrice 的查询能否命中 ----
Console.WriteLine("===== 4. EOD 查询路径验证(EodPriceQueryService.TryGetPrice 的实际命中情况)=====");
Console.WriteLine(" EOD 按 UnderlyingCode(string) 精确匹配 + ValueDate 精确匹配,不依赖 UnderlyingId。");
Console.WriteLine(" 即:即使 UnderlyingId 错挂,只要 UnderlyingCode='FR007' 且 ValueDate 对得上,EOD 仍能查到。");
Console.WriteLine(" → UnderlyingId 错挂主要影响【网页端展示/JOIN】,不一定影响【EOD 取价】。");
Console.WriteLine(" → 若 EOD 仍取不到价,根因更可能是:日期错位/非重置日/未上传当日值,而非 UnderlyingId 错挂。");
Console.WriteLine();
// ---- 5. 近 7 天 FR007 上传覆盖情况(判断 EOD 取不到是不是因为没上传)----
var recentDates = db.eod_commodity_future_price
.Where(a => a.UnderlyingCode == "FR007" && a.ValueDate >= DateTime.Today.AddDays(-10))
.OrderBy(a => a.ValueDate)
.Select(a => new { a.ValueDate, a.ReferencePrice })
.ToList();
Console.WriteLine($"===== 5. 近 10 天 FR007 上传覆盖(判断是否漏传导致 EOD 取不到)=====");
if (recentDates.Count == 0)
{
Console.WriteLine(" ⚠⚠ 近 10 天无任何 FR007 上传记录!EOD 复利取价必然失败(或用历史快照)");
}
foreach (var d in recentDates)
{
var weekday = d.ValueDate.DayOfWeek;
string wd = weekday == DayOfWeek.Saturday || weekday == DayOfWeek.Sunday ? "周末" : "工作日";
Console.WriteLine($" {d.ValueDate:yyyy-MM-dd}({wd}) FR007={d.ReferencePrice}");
}
// ---- 结论判定 ----
Console.WriteLine("\n===== 诊断结论 =====");
if (mismatchCount > 0)
{
Console.WriteLine(" [确认] FR007 行存在 UnderlyingId 错挂(GLMS-20260701 同类复发)");
Console.WriteLine(" 影响:网页端按 UnderlyingId JOIN 会把 FR007 误挂到别的标的显示");
Console.WriteLine(" 但 EOD 取价走 UnderlyingCode,错挂不直接导致 EOD 取不到价");
}
else
{
Console.WriteLine(" [排除] FR007 行 UnderlyingId 均正确,未复发 GLMS-20260701 事故");
Console.WriteLine(" → 'EOD 没用上 FR007' 更可能是:非重置日(设计)/日期错位/未上传当日值");
}
// 断言:错挂数应为 0(若 >0 说明复发)
Assert.IsTrue(mismatchCount == 0,
$"FR007 有 {mismatchCount} 条价格行 UnderlyingId 错挂(应为 {fr007CorrectId}),GLMS-20260701 事故复发");
}
/// <summary>
/// 诊断 GLMS-JIATT-20260805 复利 EOD 取价日:算出哪些天是重置日、实际查哪天的 FR007
/// 回答"是不是只需要 8/3 一天的价格即可"
/// </summary>
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_Trade_FR007_ResetDays()
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) =====");
Console.WriteLine($" StartDate(起息日/tradeDate) = {td.StartDate:yyyy-MM-dd}");
Console.WriteLine();
// 复利腿配置(InterestType=1 复利)
var compoundLegs = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid && p.InterestType == 1)
.ToList();
if (compoundLegs.Count == 0)
{
Console.WriteLine(" ⚠ 该交易无复利腿(InterestType=1)FR007 取价逻辑不适用");
Assert.Inconclusive("无复利腿");
return;
}
foreach (var leg in compoundLegs)
{
Console.WriteLine($" 复利腿 PositionId={leg.id} Mode={leg.InterestMode}");
Console.WriteLine($" PosiStartDate={leg.PosiStartDate:yyyy-MM-dd}");
Console.WriteLine($" interest_rest_days(重置周期)={leg.interest_rest_days}");
Console.WriteLine($" interest_rule(日期偏移)={leg.interest_rule}");
Console.WriteLine($" FloatRateUnderlyingCode={leg.FloatRateUnderlyingCode}");
Console.WriteLine($" InterestRateDefault(加点固定利率)={leg.InterestRateDefault}");
Console.WriteLine();
}
// 用第一条复利腿的配置算重置日(EOD 用 td.StartDate 算 days,见 SwapDealService.cs:1085,1385
var leg0 = compoundLegs[0];
int interestPeriod = leg0.interest_rest_days ?? 1;
int interestRule = leg0.interest_rule ?? 0;
DateTime tradeDate = td.StartDate.Value;
string floatCode = leg0.FloatRateUnderlyingCode;
Console.WriteLine($"===== EOD 复利取价日推算(days=(收盘日-StartDate)%{interestPeriod}==0 才取价)=====");
Console.WriteLine($" 公式:fr007RateDate = GetNonHolidayDefore(收盘日 + interest_rule({interestRule}))");
Console.WriteLine($" 法定节假日会回退到前一工作日(GetNonHolidayDefore");
Console.WriteLine();
// 推算 7/27 ~ 8/7 每天是不是重置日,以及重置日实际查哪天的 FR007
Console.WriteLine($" {"",-12}{"days",-8}{"?",-10}{"(raw)",-14}{"(退)",-20}{"FR007有值?"}");
var fr007Dates = db.eod_commodity_future_price
.Where(a => a.UnderlyingCode == "FR007" && a.ReferencePrice != null && a.ReferencePrice != 0)
.Select(a => a.ValueDate)
.ToList();
var fr007Set = new HashSet<DateTime>(fr007Dates);
// 简单节假日表(周末;法定节假日用 GetNonHolidayDefore 实际逻辑,这里近似用周末判断)
DateTime CalcNonHoliday(DateTime d)
{
while (d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday)
d = d.AddDays(-1);
return d;
}
int resetDayCount = 0;
for (var d = new DateTime(2026, 7, 27); d <= new DateTime(2026, 8, 7); d = d.AddDays(1))
{
int days = (d - tradeDate).Days;
bool isReset = days % interestPeriod == 0;
if (d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday) continue; // EOD 不跑周末
string resetFlag = isReset ? "✓重置日" : "非重置";
if (isReset) resetDayCount++;
DateTime rawQueryDate = d.AddDays(interestRule);
DateTime actualQueryDate = CalcNonHoliday(rawQueryDate);
bool hasFr007 = fr007Set.Contains(actualQueryDate);
Console.WriteLine($" {d:yyyy-MM-dd} {days,-8}{resetFlag,-10}{rawQueryDate:yyyy-MM-dd} {actualQueryDate:yyyy-MM-dd} {(hasFr007 ? "" : "")}");
}
Console.WriteLine($"\n 小结:7/27~8/7 期间共 {resetDayCount} 个重置日(EOD 只有这些天才查 FR007");
Console.WriteLine(" → 非重置日根本不查 FR007,沿用上一重置周期的利率,无需每天都有值");
Console.WriteLine(" → 只要【重置日实际查到的那天】有 FR007 值即可,其它天空值不影响 EOD 复利");
}
/// <summary>
/// 逐日对比:EOD 累计 InterestProfitSum(增量)vs 平仓从头重放(全段)—— 定位哪天开始偏差
/// </summary>
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_EOD_vs_Unwind_Compound_DailyCompare()
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) EOD累计 vs 平仓重放 逐日对比 =====");
Console.WriteLine($" StartDate={td.StartDate:yyyy-MM-dd}");
Console.WriteLine();
// 复利腿(取 IsInitial=true 的,其 id 才是 EOD/平仓 PositionId 匹配的 key
var compoundLeg = db.swap_position.FirstOrDefault(p => p.SwapTradeId == td.id && !p.Invalid && p.InterestType == 1 && p.IsInitial);
if (compoundLeg == null) { Assert.Inconclusive("无 IsInitial 复利腿"); return; }
Console.WriteLine($" 复利腿(初始) id={compoundLeg.id} Mode={compoundLeg.InterestMode} PosiStartDate={compoundLeg.PosiStartDate:yyyy-MM-dd}");
Console.WriteLine($" interest_rest_days={compoundLeg.interest_rest_days} interest_rule={compoundLeg.interest_rule} Rate={compoundLeg.InterestRateDefault}");
Console.WriteLine();
// 读取 EOD 逐日快照(复利腿,按初始腿 id 匹配 PositionId
var eodSeq = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid && e.PositionId == compoundLeg.id)
.OrderBy(e => e.ValueDate)
.Select(e => new { e.ValueDate, e.InterestProfitSum, e.TdInterestPrincipal, e.FloatRate, e.InterestIncomeSum, e.TdInterestIncome })
.ToList();
Console.WriteLine($" EOD 快照共 {eodSeq.Count} 天");
Console.WriteLine();
// 平仓从头重放:逐日调 GetUnwindInterests(closePercent=1, 全平)
// 注意:平仓返回值 = 从头重放全段利息 - consumedInterest*1(扣历史已结)
// EOD InterestProfitSum 是逐日增量累计(不扣 consumedInterest
// 所以两者差 = consumedInterest(历史已结)。重点看"差"是否稳定。
var user = new OptUserInfo(0, nameof(GLMS20260805FR007UnderlyingIdDiagnoseTest), OptUserFrom.UnitTest);
var svc = new YLErp.Modules.SwapModule.SwapDealService(user);
int interestPeriod = compoundLeg.interest_rest_days ?? 1;
int interestRule = compoundLeg.interest_rule ?? 0;
DateTime tradeDate0 = td.StartDate.Value;
Console.WriteLine($" {"",-12}{"days",-6}{"?",-8}{"EOD.FloatRate",-14}{"EOD.ProfitSum",-18}{"EOD.TdIntPrin",-16}{"",-18}{"",-14}{""}");
Console.WriteLine($" {new string('-', 118)}");
decimal prevEodSum = 0;
decimal prevUnwind = 0;
decimal prevEodFloat = 0;
for (var d = td.StartDate.Value; d <= new DateTime(2026, 8, 7); d = d.AddDays(1))
{
if (d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday) continue;
int days = (d - tradeDate0).Days;
bool isReset = days % interestPeriod == 0;
var eod = eodSeq.FirstOrDefault(e => e.ValueDate == d);
decimal eodProfitSum = eod?.InterestProfitSum ?? 0;
decimal eodTdPrin = eod?.TdInterestPrincipal ?? 0;
decimal eodFloat = eod?.FloatRate ?? 0;
// 重置日 FR007 切换检查
string floatNote = "";
if (isReset && days != 0 && prevEodFloat != 0 && eodFloat == prevEodFloat)
{
floatNote = "⚠重置日FR007未切换!";
}
// 平仓从头重放(全平 closePercent=1
decimal unwindInterest = 0;
string note = "";
try
{
var interests = svc.GetUnwindInterests(d, d, td.id, 1m, (int)SwapEventTypeEnum.);
var compoundResult = interests.FirstOrDefault(x => x.PositionId == compoundLeg.id);
unwindInterest = compoundResult?.InterestAmount ?? 0;
// 平仓路径取的 FR007(看是否切换)
if (isReset && compoundResult != null)
{
note = $"平仓FloatRate={compoundResult.FloatRate}";
}
}
catch (Exception ex)
{
note = $"⚠平仓失败:{ex.Message}";
}
decimal diff = unwindInterest - eodProfitSum;
string diffNote = Math.Abs(diff) < 0.01m ? "一致" : (Math.Abs(diff) < 1m ? "微小差" : "偏差");
string resetFlag = isReset ? "✓重置" : "";
Console.WriteLine($" {d:yyyy-MM-dd} {days,-6}{resetFlag,-8}{eodFloat,12:F6} {eodProfitSum,16:F6} {eodTdPrin,14:F4} {unwindInterest,16:F6} {diff,12:F6} {diffNote} {floatNote} {note}");
prevEodSum = eodProfitSum;
prevUnwind = unwindInterest;
prevEodFloat = eodFloat;
}
Console.WriteLine();
Console.WriteLine($" ===== 解读 =====");
Console.WriteLine($" · 平仓重放 = 从 PosiStartDate 到当日全段复利利息 - consumedInterest(历史互换已结)");
Console.WriteLine($" · EOD ProfitSum = 逐日增量累计(preEod.ProfitSum + 当天新计)");
Console.WriteLine($" · 两者差应≈consumedInterest(若有历史互换)。若差值不稳定/突变 → 某天 EOD 增量算错");
Console.WriteLine($" · 重点看 FloatRate 列:EOD 用的浮动利率是否在重置日正确切换、非重置日是否正确沿用");
}
/// <summary>
/// 深挖复利腿(38122)在 8/4 互换前后发生了什么:flow_event + EOD 全字段
/// </summary>
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_Trade_CompoundLeg_AroundSwap()
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
const long CompoundPositionId = 38122;
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
// 1. 复利腿所有 flow_event(看 8/4 互换对它做了什么)
Console.WriteLine($"===== 复利腿 PositionId={CompoundPositionId} 所有 flow_event =====");
var flows = db.swap_flow_event
.Where(f => f.SwapTradeId == td.id && f.PositionId == CompoundPositionId)
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
.Select(f => new { f.EventDate, f.UnwindDate, f.EventType, f.InterestMode, f.InterestAmount, f.InterestPrincipal, f.FloatRate, f.InterestRate, f.DataState, f.Quantity, f.PositionQty })
.Take(30)
.ToList();
Console.WriteLine($" 共 {flows.Count} 条");
Console.WriteLine($" {"EventDate",-12}{"UnwindDate",-12}{"Type",-6}{"DataState",-10}{"I.Amount",-16}{"I.Principal",-18}{"FloatRate",-12}{"Rate"}");
foreach (var f in flows)
{
string typeStr = f.EventType == 2 ? "平仓" : f.EventType == 3 ? "互换" : f.EventType == 4 ? "自动" : f.EventType.ToString();
Console.WriteLine($" {f.EventDate:yyyy-MM-dd} {f.UnwindDate:yyyy-MM-dd} {typeStr,-6}{f.DataState,-10}{f.InterestAmount,14:F6} {f.InterestPrincipal,16:F4} {f.FloatRate,10:F6} {f.InterestRate}");
}
Console.WriteLine();
// 2. 8/3~8/6 EOD 全字段(看 8/4 互换后状态怎么变的)
Console.WriteLine($"===== PositionId={CompoundPositionId} 8/3~8/6 EOD 全字段 =====");
var eods = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && e.PositionId == CompoundPositionId
&& e.ValueDate >= new DateTime(2026, 8, 3) && e.ValueDate <= new DateTime(2026, 8, 6))
.OrderBy(e => e.ValueDate)
.Select(e => new { e.ValueDate, e.InterestProfitSum, e.TdInterestPrincipal, e.FloatRate, e.InterestIncomeSum, e.TdInterestIncome, e.TdCloseInterest, e.RealizedInterest, e.InterestRateDefault, e.PosiStatus })
.ToList();
Console.WriteLine($" {"",-12}{"ProfitSum",-16}{"TdIntPrin",-16}{"FloatRate",-12}{"IncomeSum",-16}{"TdIncome",-14}{"TdCloseInt",-14}{"RealizedInt",-14}{"PosiStatus"}");
foreach (var e in eods)
{
Console.WriteLine($" {e.ValueDate:yyyy-MM-dd} {e.InterestProfitSum,14:F6} {e.TdInterestPrincipal,14:F4} {e.FloatRate,10:F6} {e.InterestIncomeSum,14:F6} {e.TdInterestIncome,12:F6} {e.TdCloseInterest,12:F6} {e.RealizedInterest,12:F6} {e.PosiStatus}");
}
Console.WriteLine();
// 3. 看 8/4 是否有 swap_event(互换事件记录)
Console.WriteLine($"===== 8/3~8/5 的 swap_event(看有无互换操作)=====");
var events = db.swap_event
.Where(s => s.SwapTradeId == td.id && !s.Invalid
&& s.ValueDate >= new DateTime(2026, 8, 3) && s.ValueDate <= new DateTime(2026, 8, 5))
.OrderBy(s => s.ValueDate)
.Select(s => new { s.id, s.ValueDate, s.EventType, s.EventReason, s.Invalid })
.ToList();
foreach (var s in events)
{
string typeStr = s.EventType == 2 ? "平仓" : s.EventType == 3 ? "互换" : s.EventType.ToString();
Console.WriteLine($" id={s.id} {s.ValueDate:yyyy-MM-dd} Type={typeStr} Reason={s.EventReason} Invalid={s.Invalid}");
}
if (events.Count == 0) Console.WriteLine(" 8/3~8/5 无 swap_event");
}
/// <summary>
/// 精确诊断:8/4 重置日为何取到 FR007=0.0123 而非 0.0213
/// 复刻 CalcDailyCompoundInterest 循环 + GetFloatRate 逻辑,逐 i 打印 floatRate 演变
/// </summary>
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_Trade_804_ResetDay_FloatRate_Trace()
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
var te = db.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
// InterestCalcMode
string calcMode = "?";
int annualDays = 365;
if (te != null && !string.IsNullOrEmpty(te.ExtendJson))
{
try
{
dynamic ext = Newtonsoft.Json.JsonConvert.DeserializeObject(te.ExtendJson);
calcMode = (string)ext.InterestCalcMode ?? "?";
annualDays = (int?)ext.AnnualDays ?? 365;
}
catch { }
}
bool calcFirst = calcMode.StartsWith("1");
bool calcLast = calcMode.EndsWith("1");
Console.WriteLine($"===== InterestCalcMode 诊断 =====");
Console.WriteLine($" InterestCalcMode = '{calcMode}' → calcFirst={calcFirst} calcLast={calcLast}");
Console.WriteLine();
var compoundLeg = db.swap_position.FirstOrDefault(p => p.SwapTradeId == td.id && !p.Invalid && p.InterestType == 1 && p.IsInitial);
if (compoundLeg == null) { Assert.Inconclusive("无复利腿"); return; }
int period = compoundLeg.interest_rest_days ?? 1;
int rule = compoundLeg.interest_rule ?? 0;
DateTime startDate = compoundLeg.PosiStartDate;
// 直接调生产代码 GetUnwindInterests(8/4 全平),看返回的 FloatRate
DateTime endDate = new DateTime(2026, 8, 4);
Console.WriteLine($"===== 1. 调 GetUnwindInterests(8/4) 看复利腿返回的 FloatRate =====");
Console.WriteLine($" PosiStartDate={startDate:yyyy-MM-dd} endDate={endDate:yyyy-MM-dd} period={period} rule={rule}");
Console.WriteLine($" (endDate - PosiStartDate).Days = {(endDate - startDate).Days}, %period = {(endDate - startDate).Days % period}==0 即重置日)");
Console.WriteLine($" (endDate - td.StartDate).Days = {(endDate - td.StartDate.Value).Days}GetFloatRate 用这个判重置日)");
Console.WriteLine();
var user = new OptUserInfo(0, nameof(GLMS20260805FR007UnderlyingIdDiagnoseTest), OptUserFrom.UnitTest);
var svc = new YLErp.Modules.SwapModule.SwapDealService(user);
try
{
var interests = svc.GetUnwindInterests(endDate, endDate, td.id, 1m, (int)SwapEventTypeEnum.);
var compoundResult = interests.FirstOrDefault(x => x.PositionId == compoundLeg.id);
if (compoundResult != null)
{
Console.WriteLine($" ✓ 平仓返回:InterestAmount={compoundResult.InterestAmount:F6} FloatRate={compoundResult.FloatRate:F6} InterestRate={compoundResult.InterestRate}");
Console.WriteLine($" 若 FloatRate≈0.0123 → 取到的是 7/27 旧值(重置日未生效)");
Console.WriteLine($" 若 FloatRate≈0.0213 → 取到的是 8/3 新值(重置日生效,正常)");
}
}
catch (Exception ex) { Console.WriteLine($" ⚠ 平仓调用失败:{ex.Message}"); }
Console.WriteLine();
// 2. 直接验证 FR007 在关键日期的值(EodPriceQueryService.TryGetPrice
Console.WriteLine($"===== 2. FR007 在关键日期的实际值(EodPriceQueryService.TryGetPrice=====");
var checkDates = new[] {
("7/27(首重置日查询日)", new DateTime(2026,7,27)),
("8/3(8/4重置日应查的日期, interest_rule=-1)", new DateTime(2026,8,3)),
("8/4(直接查)", new DateTime(2026,8,4)),
};
foreach (var (label, dt2) in checkDates)
{
bool ok = YLErp.Modules.DataProviderModule.EodPriceQueryService.TryGetPrice(dt2, "FR007", out double v);
Console.WriteLine($" {label} {dt2:yyyy-MM-dd}: {(ok ? $"{v:F6}" : "")}");
}
Console.WriteLine();
// 3. 结论判定
Console.WriteLine($"===== 3. 结论判定 =====");
Console.WriteLine($" calcLast={calcLast}InterestCalcMode='{calcMode}' EndsWith('1')");
Console.WriteLine($" 8/4 平仓:endDate=8/4 是重置日((8/4-7/28).Days=7, 7%7=0");
if (!calcLast)
{
Console.WriteLine($" ⚠ calcLast=falseCalcDailyCompoundInterest 循环里 i=7(accrueDate=8/4=endDate) 命中");
Console.WriteLine($" 'if(!calcLast && accrueDate==endDate) continue' → 被跳过,不进重置日取价分支");
Console.WriteLine($" → 8/4 重置日不取新 FR007,沿用循环里 i=0(7/28)取到的旧值 0.0123");
Console.WriteLine($" → 这就是根因:算尾规则(calcLast)导致重置日=平仓日时跳过取价");
}
else
{
Console.WriteLine($" calcLast=true8/4 不会被跳过,应能取到新 FR007(0.0213)。");
Console.WriteLine($" 若平仓返回的 FloatRate 仍是 0.0123 → 根因在别处(需进一步查 GetFloatRate/循环覆盖)");
}
}
/// <summary>
/// 查这笔交易所有腿 + EOD 快照的 PositionId 映射,搞清哪条腿真正算利息
/// </summary>
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_Trade_AllLegs_And_EodMapping()
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) 全部持仓腿 =====");
var allLegs = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
.OrderBy(p => p.PosiDirection).ThenBy(p => p.IsInitial).ThenBy(p => p.id)
.Select(p => new { p.id, p.PositionId, p.IsInitial, p.InterestMode, p.InterestType, p.PosiDirection, p.InterestPrincipalFix, p.PosiNotionalValue, p.PosiStartDate, p.FloatRateUnderlyingCode })
.ToList();
Console.WriteLine($" {"id",-8}{"PositionId",-12}{"IsInit",-8}{"Mode",-6}{"IntType",-8}{"PosiDir",-8}{"Fix",-16}{"PosiNotional",-16}{"PosiStart",-12}{"FloatCode"}");
foreach (var p in allLegs)
{
Console.WriteLine($" {p.id,-8}{p.PositionId,-12}{p.IsInitial,-8}{p.InterestMode,-6}{p.InterestType,-8}{p.PosiDirection,-8}{p.InterestPrincipalFix,-16}{p.PosiNotionalValue,-16}{p.PosiStartDate:yyyy-MM-dd} {p.FloatRateUnderlyingCode}");
}
Console.WriteLine();
// 各腿对应的 EOD 快照数量
Console.WriteLine($"===== 各腿 EOD 快照数量(eod_swap_position=====");
var eodCounts = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid)
.GroupBy(e => e.PositionId)
.Select(g => new { PositionId = g.Key, Cnt = g.Count(), MinDate = g.Min(x => x.ValueDate), MaxDate = g.Max(x => x.ValueDate) })
.ToList();
foreach (var c in eodCounts)
{
Console.WriteLine($" PositionId={c.PositionId} 快照数={c.Cnt} 日期范围={c.MinDate:yyyy-MM-dd}~{c.MaxDate:yyyy-MM-dd}");
}
Console.WriteLine();
// 复利腿(Intertype=1)逐日 EOD 明细(所有 PositionId
Console.WriteLine($"===== 复利腿(InterestType=1) EOD 逐日明细(所有 PositionId=====");
var compoundEods = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid && e.InterestType == 1)
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
.Select(e => new { e.ValueDate, e.PositionId, e.InterestProfitSum, e.TdInterestPrincipal, e.FloatRate, e.InterestRateDefault, e.InterestMode })
.Take(40)
.ToList();
Console.WriteLine($" 共 {compoundEods.Count} 条");
Console.WriteLine($" {"",-12}{"PositionId",-12}{"Mode",-6}{"ProfitSum",-18}{"TdIntPrin",-16}{"FloatRate",-12}{"RateDefault"}");
foreach (var e in compoundEods)
{
Console.WriteLine($" {e.ValueDate:yyyy-MM-dd} {e.PositionId,-12}{e.InterestMode,-6}{e.InterestProfitSum,16:F6} {e.TdInterestPrincipal,14:F4} {e.FloatRate,10:F6} {e.InterestRateDefault}");
}
}
}
}
@@ -44,18 +44,29 @@ namespace YLErp.Modules.SwapModule
{
private readonly double _floatRate;
private readonly decimal _consumedInterest;
private readonly Func<DateTime, double> _floatRateByDate;
public StubSwapDealService(OptUserInfo optUser, double floatRate, decimal consumedInterest)
: base(optUser)
{
_floatRate = floatRate;
_consumedInterest = consumedInterest;
_floatRateByDate = null;
}
/// <summary>按查询日期返回不同浮动利率(用于复现重置日取价 bug</summary>
public StubSwapDealService(OptUserInfo optUser, Func<DateTime, double> floatRateByDate, decimal consumedInterest = 0m)
: base(optUser)
{
_floatRate = 0;
_consumedInterest = consumedInterest;
_floatRateByDate = floatRateByDate;
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
rate = _floatRate;
return true; // 始终返回固定浮动利率
rate = _floatRateByDate != null ? _floatRateByDate(valueDate) : _floatRate;
return true;
}
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
@@ -108,13 +119,13 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>调用 GetInterests 获取复利利息(统一调用入口,settment:false走盘中平仓路径)</summary>
private static swap_flow_event CalcCompoundUnwind(StubSwapDealService service, DateTime unwindDate)
private static swap_flow_event CalcCompoundUnwind(StubSwapDealService service, DateTime unwindDate, decimal closePercent = 1m)
{
var td = CreateTrade();
var position = CreateCompoundPosition();
var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
Principal, Principal, Principal, Principal, closePercent,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
add: false, settment: false, newCalcLast: false);
Assert.AreEqual(1, interests.Count);
@@ -246,5 +257,263 @@ namespace YLErp.Modules.SwapModule
$"全部已结再平仓利息应≈0(实际={result.InterestAmount:F6}),不应为负");
Console.WriteLine($"全部已结平仓≈0{result.InterestAmount:F6})✅");
}
[TestMethod]
public void CI_005_partialClose_scalesConsumedInterest()
{
var unwindDate = StartDate.AddDays(10);
const decimal closePercent = 0.4m;
const decimal consumed = 100m;
var baseline = CalcCompoundUnwind(CreateService(0m), unwindDate, closePercent).InterestAmount;
var result = CalcCompoundUnwind(CreateService(consumed), unwindDate, closePercent).InterestAmount;
AssertDecimal(baseline - consumed * closePercent, result,
$"partial close should deduct consumed interest by closePercent ({closePercent})");
}
// ================================================================
// 场景7:复现"平仓日=重置日 + calcLast=false → 重置日跳过 FR007 取价"
// ================================================================
/// <summary>
/// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
/// ----------------------------------------------------------------
/// 背景(GLMS-JIATT-20260805 根因)InterestCalcMode='10'(算头不算尾,calcLast=false)
/// CalcDailyCompoundInterest 循环里 `if(!calcLast && accrueDate==endDate) continue` 会跳过平仓日当天。
/// 若平仓日恰好是重置日(i%period==0),这个跳过会让"重置日取新FR007"的代码块永远不执行,
/// 沿用上一个重置周期的旧利率。
///
/// 构造:PosiStartDate=4/27, ResetPeriod=3, InterestCalcMode='10'(calcLast=false)
/// - FR007 按日期分段:5/3之前返回 rateOld=0.0015/3及之后返回 rateNew=0.002
/// - 对照A:平仓日=5/5(非重置日,9? 不: (5/5-4/27)=8, 8%3=2 非重置) → 不该取新值
/// - 对照B:平仓日=5/6(重置日,(5/6-4/27)=9, 9%3=0) → 应取新值 rateNew
///
/// 修复前:5/6 重置日被 calcLast 跳过 → 取到旧 rateOld → 与 5/5 相同
/// 修复后:5/6 重置日正常取价 → 取到 rateNew → 与 5/5 不同
/// ----------------------------------------------------------------
/// </summary>
/// <summary>
/// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
/// ----------------------------------------------------------------
/// 根因(GLMS-JIATT-20260805)InterestCalcMode='10'(calcLast=false)
/// CalcDailyCompoundInterest 循环 `if(!calcLast && accrueDate==endDate) continue` 跳过平仓日。
/// 若平仓日=重置日,取价代码块被跳过 → flowEvent.FloatRate 停留旧值 → 落库后传染 EOD。
///
/// 构造(避开周末,period=7)
/// PosiStartDate=4/27(周一), period=7, interest_rule=0, InterestCalcMode='10'
/// 重置日:i=0→4/27(周一), i=7→5/4(周一,工作日)
/// 平仓日=5/4(=重置日=endDate)
/// FR007 分界:rateDate>=5/4 返回 rateNew,否则 rateOld
///
/// 修复前:i=7(5/4)被 calcLast 跳过 → FloatRate=rateOld(旧值)
/// 修复后:i=7(5/4)正常取价 → FloatRate=rateNew(新值)
/// ----------------------------------------------------------------
/// </summary>
[TestMethod]
public void CI_007_平仓日等于重置日_calcLast_false_仍应取新FR007()
{
const double rateOld = 0.001;
const double rateNew = 0.002;
// 用 6 月日期避开五一/周末:PosiStartDate=6/1(周一), period=7, 平仓日=6/8(周一,重置日)
DateTime posiStart = new DateTime(2026, 6, 1);
DateTime unwindDate = new DateTime(2026, 6, 8); // (6/8-6/1)=7, 7%7=0 重置日
DateTime newRateFrom = new DateTime(2026, 6, 8); // 6/8(查询日,周一工作日)起为新利率
StubSwapDealService ServiceByDate() => new StubSwapDealService(
new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest),
d => d >= newRateFrom ? rateNew : rateOld);
var td = new trade
{
id = 1, TradeNumber = "UT-CI007", ClientId = 999998,
TradeType = "收益互换", TradeDate = posiStart, StartDate = posiStart,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum., InterestMode = (int)InterestModeEnum.,
InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
PosiStartDate = posiStart, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
})
};
var interests = ServiceByDate().GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
add: false, settment: false, newCalcLast: false);
Assert.AreEqual(1, interests.Count);
var result = interests[0];
Console.WriteLine($"6/8(重置日,周一)平仓:FloatRate={result.FloatRate} Amount={result.InterestAmount:F6}");
Console.WriteLine($" 期望 FloatRate={rateNew}6/8 重置日查询日=6/8工作日,应取新利率)");
// 核心断言:6/8 是重置日,flowEvent.FloatRate 应反映新利率 rateNew
Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateNew) < 0.0001m,
$"平仓日=重置日时 FloatRate 应={rateNew}(取到新利率)。" +
$"实际={result.FloatRate},若={rateOld} 说明 calcLast=false 跳过了重置日取价(GLMS-JIATT-20260805 根因)");
var interestBeforeResetDate = Principal * (FixedRate + (decimal)rateOld) * 7m / AnnualDays;
AssertDecimal(Principal + interestBeforeResetDate, result.InterestPrincipal,
"calcLast=false 的重置日仍应将前 7 天复利并入本金");
AssertDecimal(interestBeforeResetDate, result.InterestAmount,
"calcLast=false 不应计入重置日当天利息");
}
[TestMethod]
public void CI_008_ResetDayPartialCloseCarriesRemainingInterestIntoPrincipal()
{
const decimal previousPrincipal = 50061728.39m;
const decimal remainingPrincipal = 30037037.04m;
const decimal previousInterest = 7425.050203320057m;
const decimal fixedRate = 0.001234m;
const double oldFloatRate = 0.0123;
const double newFloatRate = 0.0213;
var startDate = new DateTime(2026, 7, 28);
var resetDate = new DateTime(2026, 8, 4);
var service = new StubSwapDealService(
new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest),
d => d >= resetDate ? newFloatRate : oldFloatRate);
var td = CreateTrade();
td.StartDate = startDate;
td.TradeDate = startDate;
var position = new swap_position
{
id = 1001, SwapTradeId = td.id,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestType = (int)InterestTypeEnum.,
InterestRateDefault = fixedRate,
PosiStartDate = startDate, PosiMatuirityDate = ExerciseDate,
IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = ExerciseDate, Rate = fixedRate, Settlement = 0 }
})
};
var preEod = new eod_swap_position
{
id = 1, PositionId = position.id, ValueDate = resetDate.AddDays(-1),
TdInterestPrincipal = previousPrincipal,
InterestIncomeSum = previousInterest,
InterestProfitSum = previousInterest,
FloatRate = (decimal)oldFloatRate
};
var result = service.GetInterests(td, td.trade_extend, resetDate, resetDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
remainingPrincipal, remainingPrincipal, 0m, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., true, false, 0m, remainingPrincipal,
add: false, settment: false, newCalcLast: false).Single();
var remainingInterest = previousInterest * remainingPrincipal / previousPrincipal;
var expectedPrincipal = remainingPrincipal + remainingInterest;
var expectedDailyInterest = expectedPrincipal * (fixedRate + (decimal)newFloatRate) / AnnualDays;
AssertDecimal(expectedPrincipal, result.InterestPrincipal);
AssertDecimal(expectedDailyInterest,
result.InterestPrincipal * (result.InterestRate + result.FloatRate.Value) / AnnualDays);
}
[TestMethod]
public void CI_009_NonResetUnwindWithCalcLastFalseUsesPreviousEodPendingInterest()
{
const decimal pendingInterest = 10019.043756537721m;
const decimal remainingPrincipal = 30041492.070122881942m;
var startDate = new DateTime(2026, 7, 28);
var unwindDate = new DateTime(2026, 8, 7);
var td = CreateTrade();
td.StartDate = startDate;
td.TradeDate = startDate;
var position = new swap_position
{
id = 1001, SwapTradeId = td.id,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestType = (int)InterestTypeEnum.,
InterestRateDefault = 0.001234m,
PosiStartDate = startDate, PosiMatuirityDate = ExerciseDate,
IsAnnualized = true, interest_rest_days = 7,
FloatRateUnderlyingCode = "FR007"
};
var preEod = new eod_swap_position
{
id = 1, PositionId = position.id, ValueDate = unwindDate.AddDays(-1),
TdInterestPrincipal = remainingPrincipal,
InterestIncomeSum = pendingInterest,
InterestProfitSum = pendingInterest,
FloatRate = 0.0213m
};
var service = new StubSwapDealService(
new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest), 0.0213, 0m);
var result = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
remainingPrincipal, remainingPrincipal, 0m, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingPrincipal,
add: false, settment: false, newCalcLast: false).Single();
AssertDecimal(pendingInterest, result.InterestAmount,
"calcLast=false must not accrue unwind-date interest after the previous EOD");
}
[TestMethod]
public void CI_010_EodResetWithoutCloseCarriesFullPendingInterest()
{
const decimal principal = 303139117.80m;
const decimal previousBase = 303230391.742592383565m;
const decimal pendingInterest = 184331.611361300669m;
var startDate = new DateTime(2026, 4, 21);
var resetDate = new DateTime(2026, 4, 28);
var position = new swap_position
{
PosiStartDate = startDate,
InterestType = (int)InterestTypeEnum.,
InterestRateDefault = 0.0025m,
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>()),
IsAnnualized = true,
interest_rest_days = 7,
FloatRateUnderlyingCode = "FR007"
};
var preEod = new eod_swap_position
{
id = 1,
ValueDate = resetDate.AddDays(-1),
TdInterestPrincipal = previousBase,
InterestIncomeSum = pendingInterest,
InterestProfitSum = pendingInterest,
FloatRate = 0.013502m
};
var flowEvent = new swap_flow_event { InterestRate = 0.0025m };
var service = new StubSwapDealService(
new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest),
d => 0.0139);
decimal interestAmount = 0m;
decimal tdInterestAmount = 0m;
service.CalcDailyCompoundInterestByEod(preEod, resetDate, startDate, position,
principal, principal, flowEvent, AnnualDays, false, 0.013502m, 1m, principal,
ref interestAmount, ref tdInterestAmount);
AssertDecimal(principal + pendingInterest, flowEvent.InterestPrincipal,
"无平仓重置日必须完整并入上一期累计待实现利息");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -258,6 +258,31 @@ namespace YLErp.Modules.SwapModule
AssertDecimalEqual(5355000m, result.FloatPnlSum, 0.01m, "income FloatPnlSum包含分红");
}
[TestMethod]
public void _价格上涨_应为亏损()
{
var input = new UnwindInput
{
Multiplier = 100,
PosiGrossPrice = 1.01654321m,
TradingAmountAvg = 101.754321m,
PositionQty = 50000000m,
ContractSize = 1m,
CloseNotionalValue = 50827160.5m,
CloseQty = 0m,
PayDirection = 1,
PositionType = 2,
TradingFee = "0",
TradingFeePending = "0",
DividendIn = "-90400"
};
var result = FrontendCalcReference.CalcIncome(input);
AssertDecimalEqual(-50000m, result.MarkClosePnl, 0.01m, "收取空头价格上涨=盯市亏损");
AssertDecimalEqual(-140400m, result.FloatPnlSum, 0.01m, "盯市亏损加分红");
}
private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "")
{
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
@@ -78,8 +78,11 @@ namespace YLErp.Modules.SwapModule
var lastEod = db.eod_swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && x.ValueDate < DealDate0303 && x.PositionId == floatLeg.PositionId)
.OrderByDescending(x => x.ValueDate).FirstOrDefault();
Assert.IsNotNull(lastEod, "应存在 3/2 的 EOD 持仓记录");
Assert.AreEqual(new DateTime(2026, 3, 2), lastEod.ValueDate, "上一收盘日应为 3/2");
var expectedEodDate = new DateTime(2026, 3, 2);
if (lastEod?.ValueDate != expectedEodDate)
{
Assert.Inconclusive($"测试库未准备 3/2 EOD 快照,当前上一收盘日为 {lastEod?.ValueDate:yyyy-MM-dd}");
}
Assert.AreEqual(0m, lastEod.PosiDividendSum, 0.01m,
$"3/2 EOD PosiDividendSum 应=0(当日 TdPosiDividend={lastEod.TdPosiDividend} 全额由互换 TdCloseDividend={lastEod.TdCloseDividend} 实现)");
Assert.AreEqual(30_000_000m, lastEod.PosiQuantity, "3/2 剩余持仓应为 30,000,0002/28已平仓40%");
@@ -0,0 +1,282 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 线上事故诊断:GLMS-JIATT-20260805-FICC-01-2180120IB 100%平仓 vs 40%平仓 利息差异异常
/// ============================================================================
/// 现象:同一笔交易,100% 平仓与 40% 平仓算出的利息差异远大于线性比例。
/// 怀疑点:8/5 创建的交易,撞上 8/6-8/7 对 SwapDealService 平仓利息计算的密集修复窗口,
/// 尤其 3a435ad8(8/7 13:46) 把 ResolveInterestLegPositionsAsOf 分桶从 UnwindDate 改回
/// EventDate、并删除 01d7f0c5 的 priorClosePositionIds 防护,可能引入回归。
///
/// 直连 96 测试库,对这笔交易:
/// 1) 录真实数据快照(trade/position/eod/flow_event
/// 2) 分别调 GetUnwindInterests(closePercent=1.0) 和 (=0.4),逐腿打印本金/利息
/// 3) 对比两者是否成线性比例;定位差异落在哪条腿、哪个字段
/// 4) 检查 EOD 快照的预付金 TdInterestPrincipal 是否用了初始本金(坐实 8/5 基数 bug)
///
/// 用法:本地连 96 库跑 Diagnose_100vs40_InterestDiff;连不上库自动 Inconclusive 跳过。
/// </summary>
[TestClass]
public class GLMS20260805ClosePercentDiffDiagnoseTest
{
private const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
#region 1) Ignore
[TestMethod]
[Ignore]
[TestCategory("DbDiagnose")]
public void Record_RealSnapshot()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
Assert.IsNotNull(td, $"测试库无交易 {TradeNumber},请确认 96 库是否有该数据");
var snapshot = new JObject
{
["TradeNumber"] = td.TradeNumber,
["TradeId"] = td.id,
["TradeDate"] = td.TradeDate,
["StartDate"] = td.StartDate,
["StockEqvNotional"] = td.StockEqvNotional,
["OriginalStockEqvNotional"] = td.OriginalStockEqvNotional,
["Notional"] = td.Notional,
["OriginalNotional"] = td.OriginalNotional,
["TradeStatus"] = td.TradeStatus,
["HasPartialUnWind"] = td.HasPartialUnWind
};
// 持仓(含 IsInitial=初始 + !IsInitial=已平后剩余)
var positions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
.ToList();
snapshot["Positions"] = JArray.FromObject(positions, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
// EOD 预付金腿逐日(关键:看 TdInterestPrincipal 是否=初始本金)
var eodPositions = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid)
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
.ToList();
snapshot["EodPositions"] = JArray.FromObject(eodPositions, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
// 所有 flow_event(看平仓事件序列、EventDate vs UnwindDate
var flows = db.swap_flow_event
.Where(f => f.SwapTradeId == td.id)
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
.ToList();
snapshot["FlowEvents"] = JArray.FromObject(flows, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260805");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"snapshot_{DateTime.Now:yyyyMMdd_HHmmss}.json");
File.WriteAllText(path, JsonConvert.SerializeObject(snapshot, Formatting.Indented,
new JsonSerializerSettings { DateFormatHandling = DateFormatHandling.IsoDateFormat }));
Console.WriteLine($"✅ 快照已保存: {path}");
}
#endregion
#region 2) 100% vs 40%
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_100vs40_InterestDiff()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) =====");
Console.WriteLine($" TradeDate={td.TradeDate:yyyy-MM-dd} StartDate={td.StartDate:yyyy-MM-dd}");
Console.WriteLine($" StockEqvNotional(剩余)={td.StockEqvNotional} Original(期初)={td.OriginalStockEqvNotional}");
string remainRatio = td.OriginalStockEqvNotional == 0 ? "N/A" : (td.StockEqvNotional / td.OriginalStockEqvNotional.Value).ToString("P2");
Console.WriteLine($" 剩余比例={remainRatio}");
Console.WriteLine($" HasPartialUnWind={td.HasPartialUnWind} TradeStatus={td.TradeStatus}");
Console.WriteLine();
// ---- A. 持仓全景 ----
var allPositions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
.ToList();
PrintPositions("持仓全景(orig=IsInitial初始 vs real=!IsInitial剩余)", allPositions);
// ---- B. 历史平仓事件(确认是否之前平过仓、EventDate vs UnwindDate 是否一致)----
PrintCloseFlowEvents(db, td.id);
// ---- C. EOD 预付金腿逐日(看基数是否=初始本金 → 坐实 8/5 基数 bug)----
PrintEodPrepaySequence(db, td.id);
// ---- D. 核心对比:分别调 100% 和 40% ----
Console.WriteLine("\n\n############ 核心:100% vs 40% GetUnwindInterests 对比 ############");
var user = new OptUserInfo(0, nameof(GLMS20260805ClosePercentDiffDiagnoseTest), OptUserFrom.UnitTest);
// 前端传"占期初(A)"语义,后端转"占剩余(B)"。这里模拟前端两种选择。
decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初
decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余
Console.WriteLine($"\n 前端参数:期初={frontNotionalValue} 剩余={frontPosiNotionalValue}");
// 选 100%(占期初 A=1.0
decimal cp100_A = 1.0m;
decimal cp100_B = SwapDealService.ToRemainingClosePercent(cp100_A, frontNotionalValue, frontPosiNotionalValue);
Console.WriteLine($" [100%] 前端A={cp100_A} → 后端B={cp100_B}(占剩余)");
// 选 40%(占期初 A=0.4
decimal cp40_A = 0.4m;
decimal cp40_B = SwapDealService.ToRemainingClosePercent(cp40_A, frontNotionalValue, frontPosiNotionalValue);
Console.WriteLine($" [40%] 前端A={cp40_A} → 后端B={cp40_B}(占剩余)");
Console.WriteLine($" 注:若期初≠剩余,A=1.0→B 被 cap 到 1,A=0.4→B 是另一值,二者本就非线性。\n");
// 计算日期用今天(实际前端选哪天可改)
var valueDate = DateTime.Today;
var unwindDate = DateTime.Today;
var interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.);
var interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.);
PrintInterestComparison(interests100, interests40, cp100_B, cp40_B);
}
#endregion
#region
private static void PrintPositions(string title, List<swap_position> positions)
{
Console.WriteLine($"===== {title} =====");
Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}");
foreach (var p in positions)
{
Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-7}{p.PosiDirection,-8}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}");
}
}
private static void PrintCloseFlowEvents(YLContext db, int tradeId)
{
Console.WriteLine($"\n===== 历史平仓/互换事件(EventDate vs UnwindDate=====");
var flows = db.swap_flow_event
.Where(f => f.SwapTradeId == tradeId
&& (f.EventType == (int)SwapEventTypeEnum.
|| f.EventType == (int)SwapEventTypeEnum.
|| f.EventType == (int)SwapEventTypeEnum.)
&& f.DataState == (int)SwapFlowDateStateEnum.)
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
.ToList();
if (flows.Count == 0) { Console.WriteLine(" (无历史平仓/互换事件 → 此前未平过仓)"); return; }
Console.WriteLine($" {"EventDate",-12}{"UnwindDate",-12}{"?",-8}{"Type",-6}{"PosId",-8}{"Mode",-6}{"I.Principal",-16}{"I.Amount",-14}");
foreach (var f in flows)
{
var sameDate = f.EventDate == f.UnwindDate;
var typeStr = f.EventType == (int)SwapEventTypeEnum. ? "平仓" :
f.EventType == (int)SwapEventTypeEnum. ? "互换" : "自动";
Console.WriteLine($" {f.EventDate:yyyy-MM-dd} {f.UnwindDate:yyyy-MM-dd} {(sameDate ? "" : ""),-6}{typeStr,-6}{f.PositionId,-8}{f.InterestMode,-6}{f.InterestPrincipal,-16}{f.InterestAmount,-14}");
}
Console.WriteLine(" ⚠ EventDate≠UnwindDate 的历史事件:当前 3a435ad8 按 EventDate 分桶,可能与 UnwindDate 口径不一致");
}
private static void PrintEodPrepaySequence(YLContext db, int tradeId)
{
Console.WriteLine($"\n===== EOD 预付金腿逐日(看 TdInterestPrincipal 是否=初始本金)=====");
var eodPrepay = db.eod_swap_position
.Where(e => e.SwapTradeId == tradeId && !e.Invalid
&& (e.InterestMode == (int)InterestModeEnum.
|| e.InterestMode == (int)InterestModeEnum.))
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
.ToList();
if (eodPrepay.Count == 0) { Console.WriteLine(" (无预付金腿 EOD 记录)"); return; }
Console.WriteLine($" {"ValueDate",-12}{"PosId",-8}{"Mode",-6}{"TdInterestPrincipal",-20}{"InterestProfitSum",-20}{"InterestIncomeSum",-20}");
foreach (var e in eodPrepay)
{
Console.WriteLine($" {e.ValueDate:yyyy-MM-dd} {e.PositionId,-8}{e.InterestMode,-6}{e.TdInterestPrincipal,-20}{e.InterestProfitSum,-20}{e.InterestIncomeSum,-20}");
}
// 对比初始 vs 实时剩余 vs EOD
var origPrepay = db.swap_position.Where(p => p.SwapTradeId == tradeId && !p.Invalid && p.IsInitial
&& (p.InterestMode == (int)InterestModeEnum. || p.InterestMode == (int)InterestModeEnum.)).ToList();
var realPrepay = db.swap_position.Where(p => p.SwapTradeId == tradeId && !p.Invalid && !p.IsInitial
&& (p.InterestMode == (int)InterestModeEnum. || p.InterestMode == (int)InterestModeEnum.)).ToList();
Console.WriteLine("\n ---- 预付金本金基数三方对比 ----");
foreach (var orig in origPrepay)
{
var real = realPrepay.FirstOrDefault(r => r.PositionId == orig.id);
var latestEod = eodPrepay.Where(e => e.PositionId == orig.id).OrderByDescending(e => e.ValueDate).FirstOrDefault();
var realFix = real?.InterestPrincipalFix ?? 0;
var eodTd = latestEod?.TdInterestPrincipal ?? 0;
var eodMatchesOrig = Math.Abs((double)(eodTd - orig.InterestPrincipalFix)) < 0.01;
var eodMatchesReal = Math.Abs((double)(eodTd - realFix)) < 0.01;
Console.WriteLine($" PosId={orig.id} origFix(初始)={orig.InterestPrincipalFix} realFix(剩余)={realFix} EOD.TdInterestPrincipal(最新)={eodTd}");
if (eodMatchesOrig && !eodMatchesReal && orig.InterestPrincipalFix != realFix)
{
Console.WriteLine($" ⚠⚠ EOD 基数=初始本金(≠剩余)→ 坐实:日终用了初始预付金本金而非实时剩余,后续利息计算基数错误!");
}
}
}
private static void PrintInterestComparison(List<swap_flow_event> interests100, List<swap_flow_event> interests40, decimal cp100_B, decimal cp40_B)
{
Console.WriteLine($"\n ---- GetUnwindInterests 返回(100% 共{interests100.Count}条 / 40% 共{interests40.Count}条)----");
Console.WriteLine($" {"PosId",-8}{"Mode",-6}{"IntDir",-8}{"I.Principal(100)",-18}{"I.Principal(40)",-18}{"",-10}{"I.Amount(100)",-16}{"I.Amount(40)",-16}{"",-10}");
decimal totalAmount100 = 0, totalAmount40 = 0;
foreach (var i100 in interests100.OrderBy(x => x.PositionId))
{
var i40 = interests40.FirstOrDefault(x => x.PositionId == i100.PositionId && x.InterestMode == i100.InterestMode);
var amt40 = i40?.InterestAmount ?? 0;
var prin40 = i40?.InterestPrincipal ?? 0;
totalAmount100 += i100.InterestAmount;
totalAmount40 += amt40;
string prinRatio = prin40 == 0 ? "-" : (i100.InterestPrincipal / prin40).ToString("F4");
string amtRatio = amt40 == 0 ? "-" : (i100.InterestAmount / amt40).ToString("F4");
Console.WriteLine($" {i100.PositionId,-8}{i100.InterestMode,-6}{i100.InterestDirection,-8}{i100.InterestPrincipal,-18}{prin40,-18}{prinRatio,-10}{i100.InterestAmount,-16}{amt40,-16}{amtRatio,-10}");
}
Console.WriteLine($"\n ===== 利息合计 =====");
Console.WriteLine($" 100% 总利息 = {totalAmount100}");
Console.WriteLine($" 40% 总利息 = {totalAmount40}");
var ratioStr = totalAmount40 == 0 ? "N/A" : (totalAmount100 / totalAmount40).ToString("F4");
Console.WriteLine($" 比值(100/40) = {ratioStr}");
Console.WriteLine($" 若为线性关系,比值应≈{cp100_B / cp40_B:F4}(即 B_100 / B_40");
Console.WriteLine($" 若实际比值远偏离此值 → 存在非线性/bug,重点看上方哪条腿的[利息比]或[本金比]异常");
Console.WriteLine($"\n ===== 诊断结论指引 =====");
Console.WriteLine(" · 本金比≠B_100/B_40ResolveInterestLegPositions 没用实时剩余本金(看 realFix vs origFix");
Console.WriteLine(" · 复利腿利息比异常:检查 consumedInterest 扣除(GetConsumedInterest 用 EventDate 过滤)");
Console.WriteLine(" · 单利腿利息比异常:检查 preEodPosition.InterestProfitSum 基数(EOD 是否用了初始本金)");
Console.WriteLine(" · 全部腿都偏:closePercent 双语义转换 + tdClose 导致计息区间坍缩");
}
#endregion
}
}
@@ -174,7 +174,8 @@ namespace YLErp.Modules.SwapModule
{
id = 1, SwapTradeId = 1, PositionId = 1001, ValueDate = valueDate,
ClientId = 999998, FloatRate = floatRate, TdInterestPrincipal = tdPrincipal,
PosiNotionalValue = tdPrincipal, InterestProfitSum = interestSum
PosiNotionalValue = tdPrincipal, InterestIncomeSum = interestSum,
InterestProfitSum = interestSum
};
}
@@ -250,6 +250,7 @@ namespace YLErp.Modules.SwapModule
FloatRate = floatRate,
TdInterestPrincipal = tdPrincipal,
PosiNotionalValue = tdPrincipal,
InterestIncomeSum = interestSum,
InterestProfitSum = interestSum
};
}
@@ -0,0 +1,358 @@
using Newtonsoft.Json;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 对话及缺陷表中的部分平仓后最终全平案例。
/// 使用生产 GetInterests 计算,不连接数据库;数据库数值仅作为冻结输入快照。
/// </summary>
[TestClass]
public class SwapCloseConversationCasesRegressionTest
{
private const int AnnualDays = 365;
private const decimal CentTolerance = 0.015m;
public sealed class CloseCase
{
public string TradeNumber { get; init; }
public DateTime StartDate { get; init; }
public DateTime CloseDate { get; init; }
public string InterestCalcMode { get; init; }
public int SettlementRules { get; init; }
public int InterestMode { get; init; }
public int InterestType { get; init; }
public int ResetDays { get; init; }
public int InterestRule { get; init; }
public decimal FixedRate { get; init; }
public decimal PreviousPrincipal { get; init; }
public decimal PreviousPendingInterest { get; init; }
public decimal PreviousFloatRate { get; init; }
public decimal CloseFloatRate { get; init; }
public decimal OriginalNotional { get; init; }
public decimal RemainingNotional { get; init; }
public decimal InitialQuantity { get; init; }
public decimal PartialCloseQuantity { get; init; }
public decimal PartialCloseInterest { get; init; }
public decimal ExpectedFinalInterest { get; init; }
public override string ToString() => TradeNumber;
}
private sealed class SnapshotSwapDealService : SwapDealService
{
private readonly double _floatRate;
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public SnapshotSwapDealService(decimal floatRate)
: base(new OptUserInfo(0, nameof(SwapCloseConversationCasesRegressionTest), OptUserFrom.UnitTest))
{
_floatRate = (double)floatRate;
_floatRates = BuildAprFloatRates();
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
if (_floatRates.TryGetValue(valueDate.Date, out rate))
{
return true;
}
rate = _floatRate;
return true;
}
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
=> 0m;
}
public static IEnumerable<object[]> ConversationCases => BuildCases().Select(x => new object[] { x });
[DataTestMethod]
[DynamicData(nameof(ConversationCases), DynamicDataSourceType.Property)]
public void FinalCloseMatchesConversationCase(CloseCase closeCase)
{
var trade = CreateTrade(closeCase);
var position = CreatePosition(closeCase);
var previousEod = CreatePreviousEod(closeCase, position);
var service = new SnapshotSwapDealService(closeCase.CloseFloatRate);
var result = service.GetInterests(
trade, trade.trade_extend, closeCase.CloseDate, closeCase.CloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
closeCase.RemainingNotional, closeCase.RemainingNotional, 0m,
closeCase.RemainingNotional, 1m, (int)SwapEventTypeEnum.,
false, false, 0m,
closeCase.InterestType == 0 ? closeCase.RemainingNotional : closeCase.OriginalNotional,
add: false, settment: false, newCalcLast: false).Single();
AssertAmount(closeCase.ExpectedFinalInterest, result.InterestAmount,
$"{closeCase.TradeNumber} 最终全平利息");
if (closeCase.InterestCalcMode.EndsWith("0"))
{
AssertAmount(closeCase.PreviousPendingInterest, result.InterestAmount,
$"{closeCase.TradeNumber} 不算尾时必须带走上日全部待实现利息");
}
else
{
Assert.AreNotEqual(
Math.Round(closeCase.PreviousPendingInterest, 2, MidpointRounding.AwayFromZero),
Math.Round(result.InterestAmount, 2, MidpointRounding.AwayFromZero),
$"{closeCase.TradeNumber} 算尾时必须包含最终平仓日新增利息");
}
}
[DataTestMethod]
[DynamicData(nameof(ConversationCases), DynamicDataSourceType.Property)]
public void PartialCloseSnapshotKeepsOriginalRatioAndRemainingTail(CloseCase closeCase)
{
var closePercentOfOriginal = closeCase.PartialCloseQuantity / closeCase.InitialQuantity;
var expectedPercent = closeCase.TradeNumber.Contains("JIATT") ? 0.4m : 0.3m;
Assert.AreEqual(expectedPercent, closePercentOfOriginal,
$"{closeCase.TradeNumber} 部分平仓比例必须按期初数量口径记录");
Assert.AreNotEqual(0m, closeCase.PartialCloseInterest,
$"{closeCase.TradeNumber} 5/11 或 8/4 部分平仓利息快照不得丢失");
Assert.AreNotEqual(0m, closeCase.PreviousPendingInterest,
$"{closeCase.TradeNumber} 最终平仓前待实现尾差不得提前清零");
}
private static trade CreateTrade(CloseCase closeCase)
{
return new trade
{
id = 1,
TradeNumber = closeCase.TradeNumber,
TradeDate = closeCase.StartDate,
StartDate = closeCase.StartDate,
ExerciseDate = closeCase.CloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
trade_extend = new trade_extend
{
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = closeCase.InterestCalcMode,
SettlementRules = closeCase.SettlementRules
})
}
};
}
private static swap_position CreatePosition(CloseCase closeCase)
{
return new swap_position
{
id = 1,
PositionType = 0,
InterestDirection = 1,
InterestMode = closeCase.InterestMode,
InterestType = closeCase.InterestType,
InterestRateDefault = closeCase.FixedRate,
InterestPrincipalFix = closeCase.OriginalNotional,
PosiStartDate = closeCase.StartDate,
PosiMatuirityDate = closeCase.CloseDate,
IsInitial = true,
Invalid = false,
IsAnnualized = true,
interest_rest_days = closeCase.ResetDays,
interest_rule = closeCase.InterestRule,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel
{
Date = closeCase.CloseDate,
Rate = closeCase.FixedRate,
Settlement = 0
}
})
};
}
private static eod_swap_position CreatePreviousEod(CloseCase closeCase, swap_position position)
{
return new eod_swap_position
{
id = 1,
PositionId = position.id,
ValueDate = closeCase.CloseDate.AddDays(-1),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = closeCase.FixedRate,
InterestIncomeSum = closeCase.PreviousPendingInterest,
InterestProfitSum = closeCase.PreviousPendingInterest,
TdInterestPrincipal = closeCase.PreviousPrincipal,
PosiNotionalValue = 0m,
FloatRate = closeCase.PreviousFloatRate,
IsAnnualized = true,
interest_rest_days = closeCase.ResetDays,
interest_rule = closeCase.InterestRule
};
}
private static void AssertAmount(decimal expected, decimal actual, string message)
{
Assert.IsTrue(Math.Abs(expected - actual) <= CentTolerance,
$"{message}。Expected={expected}, Actual={actual}, Diff={expected - actual}");
}
private static IReadOnlyDictionary<DateTime, double> BuildAprFloatRates()
{
return new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 20)] = 0.0132,
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 22)] = 0.0132,
[new DateTime(2026, 4, 23)] = 0.0132,
[new DateTime(2026, 4, 24)] = 0.0131,
[new DateTime(2026, 4, 27)] = 0.013502,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 29)] = 0.0138,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 7)] = 0.0136,
[new DateTime(2026, 5, 8)] = 0.0135,
[new DateTime(2026, 5, 9)] = 0.0131,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.0130,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 14)] = 0.0130,
[new DateTime(2026, 5, 15)] = 0.0130,
[new DateTime(2026, 5, 18)] = 0.0132,
[new DateTime(2026, 5, 19)] = 0.0131
};
}
private static IReadOnlyList<CloseCase> BuildCases()
{
var apr21Mode9NoLast = AprCase("", new DateTime(2026, 4, 21), "10", 0, 9, 1, -1,
0.0025m, 212393195.604981356504m, 260578.522161724795m, 0.0134m, 0.0132m,
79831.29m, 260578.53m);
var apr21Mode2NoLast = AprCase("", new DateTime(2026, 4, 21), "10", 0, 2, 1, 0,
0.0025m, 212393594.673529615939m, 259348.391672295294m, 0.0130m, 0.0131m,
80002.30m, 259348.38m);
var apr21Mode2WithLast = AprCase("", new DateTime(2026, 4, 21), "11", 0, 2, 1, 0,
0.0025m, 212393594.665105085126m, 259348.383245260196m, 0.0130m, 0.0131m,
84090.95m, 268428.73m);
var apr22Mode9NoLast = AprCase("", new DateTime(2026, 4, 22), "10", 1, 9, 1, -1,
-0.0210m, 212106644.672742434546m, -118631.263817268568m, 0.0130m, 0.0130m,
-35350.65m, -118631.26m);
var apr22Mode2WithLast = AprCase("", new DateTime(2026, 4, 22), "11", 1, 2, 1, 0,
-0.0210m, 212106237.833444880927m, -119386.717400887353m, 0.0129m, 0.0129m,
-37218.76m, -124093.74m);
var apr21SimpleWithLast = AprCase("", new DateTime(2026, 4, 21), "11", 0, 2, 0, -1,
0.0025m, 212197382.46m, 260458.629530704663m, 0.0134m, 0.0132m,
83894.12m, 269586.02m);
return new List<CloseCase>
{
WithTradeNumber(apr21Mode2WithLast, "GLMS-20260421-0007"),
WithTradeNumber(apr21Mode2NoLast, "GLMS-20260421-0005"),
WithTradeNumber(apr21Mode9NoLast, "GLMS-20260421-0001"),
WithTradeNumber(apr22Mode2WithLast, "GLMS-20260421-0008"),
WithTradeNumber(apr21SimpleWithLast, "GLMS-20260421-0011"),
WithTradeNumber(apr21Mode2WithLast, "GLMS-MARSK-20260421-FICC-01-180205IB"),
WithTradeNumber(apr21Mode9NoLast, "GLMS-MARSK-20260421-FICC-02-180205IB"),
WithTradeNumber(apr22Mode9NoLast, "GLMS-MARSK-20260421-FICC-03-180205IB"),
WithTradeNumber(apr22Mode2WithLast, "GLMS-MARSK-20260421-FICC-04-180205IB"),
WithTradeNumber(apr21SimpleWithLast, "GLMS-MARSK-20260421-FICC-05-180205IB"),
JiattCase("GLMS-JIATT-20260805-FICC-01-2180120IB", 30041492.070122881942m,
10019.043756537721m, 2970.02m, 10019.04105m),
JiattCase("GLMS-JIATT-20260727-FICC-02-2180120IB", 30044833.3381m,
13360.932596m, 5197.53m, 13360.93051m)
};
}
private static CloseCase AprCase(string tradeNumber, DateTime startDate, string calcMode,
int settlementRules, int interestMode, int interestType, int interestRule,
decimal fixedRate, decimal previousPrincipal, decimal previousPending,
decimal previousFloatRate, decimal closeFloatRate, decimal partialInterest,
decimal expectedFinal)
{
return new CloseCase
{
TradeNumber = tradeNumber,
StartDate = startDate,
CloseDate = new DateTime(2026, 5, 19),
InterestCalcMode = calcMode,
SettlementRules = settlementRules,
InterestMode = interestMode,
InterestType = interestType,
ResetDays = 7,
InterestRule = interestRule,
FixedRate = fixedRate,
PreviousPrincipal = previousPrincipal,
PreviousPendingInterest = previousPending,
PreviousFloatRate = previousFloatRate,
CloseFloatRate = closeFloatRate,
OriginalNotional = 303139117.80m,
RemainingNotional = 212197382.46m,
InitialQuantity = 300000000m,
PartialCloseQuantity = 90000000m,
PartialCloseInterest = partialInterest,
ExpectedFinalInterest = expectedFinal
};
}
private static CloseCase JiattCase(string tradeNumber, decimal remainingPrincipal,
decimal previousPending, decimal partialInterest, decimal expectedFinal)
{
return new CloseCase
{
TradeNumber = tradeNumber,
StartDate = new DateTime(2026, 7, 28),
CloseDate = new DateTime(2026, 8, 7),
InterestCalcMode = "10",
SettlementRules = 0,
InterestMode = 9,
InterestType = 1,
ResetDays = 7,
InterestRule = -1,
FixedRate = 0.001234m,
PreviousPrincipal = remainingPrincipal,
PreviousPendingInterest = previousPending,
PreviousFloatRate = 0.0213m,
CloseFloatRate = 0.0213m,
OriginalNotional = 50061728.39m,
RemainingNotional = remainingPrincipal,
InitialQuantity = 50000000m,
PartialCloseQuantity = 20000000m,
PartialCloseInterest = partialInterest,
ExpectedFinalInterest = expectedFinal
};
}
private static CloseCase WithTradeNumber(CloseCase source, string tradeNumber)
{
return new CloseCase
{
TradeNumber = tradeNumber,
StartDate = source.StartDate,
CloseDate = source.CloseDate,
InterestCalcMode = source.InterestCalcMode,
SettlementRules = source.SettlementRules,
InterestMode = source.InterestMode,
InterestType = source.InterestType,
ResetDays = source.ResetDays,
InterestRule = source.InterestRule,
FixedRate = source.FixedRate,
PreviousPrincipal = source.PreviousPrincipal,
PreviousPendingInterest = source.PreviousPendingInterest,
PreviousFloatRate = source.PreviousFloatRate,
CloseFloatRate = source.CloseFloatRate,
OriginalNotional = source.OriginalNotional,
RemainingNotional = source.RemainingNotional,
InitialQuantity = source.InitialQuantity,
PartialCloseQuantity = source.PartialCloseQuantity,
PartialCloseInterest = source.PartialCloseInterest,
ExpectedFinalInterest = source.ExpectedFinalInterest
};
}
}
}
@@ -85,13 +85,10 @@ namespace YLErp.Modules.SwapModule
List<swap_flow_event> closeList = null)
{
LastInterestCalculationPositions = positions;
return positions.Select(position => new swap_flow_event
{
PositionId = position.id,
InterestPrincipal = 1000m,
InterestRate = 0.01m,
FloatRate = 0.01m
}).ToList();
return base.CalcSwapInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
}
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
@@ -328,7 +325,8 @@ namespace YLErp.Modules.SwapModule
id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false,
InterestPrincipalFix = 1000m, InterestRateDefault = 0.01m,
IsInitial = true, Invalid = false,
IsAnnualized = true,
PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value,
InterestSwapInterval = "[]"
@@ -356,7 +354,8 @@ namespace YLErp.Modules.SwapModule
EventType = (int)SwapEventTypeEnum.,
EventDate = SettleDate, DataState = (int)SwapFlowDateStateEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipal = 300m
InterestPrincipal = 300m,
InterestRate = 0.01m
};
var service = new TestableSwapEodService(
new List<trade> { td },
@@ -374,7 +373,9 @@ namespace YLErp.Modules.SwapModule
"实时腿已经扣减到700,日终不得再次按平仓比例扣减");
Assert.AreEqual(700m, persistedPrepay.TdInterestPrincipal,
"平仓日预付金计息本金应立即切换为实时剩余本金");
Assert.AreEqual(700m * 0.01m / 365m, persistedPrepay.TdInterestIncome,
var expectedDailyInterest = Math.Round(700m * 0.01m / 365m,
12, MidpointRounding.AwayFromZero);
Assert.AreEqual(expectedDailyInterest, persistedPrepay.TdInterestIncome,
"平仓日新增利息应按实时剩余本金计算");
}
@@ -401,6 +402,7 @@ namespace YLErp.Modules.SwapModule
PositionType = 0,
EventType = (int)SwapEventTypeEnum.,
EventDate = new DateTime(2026, 7, 9),
UnwindDate = new DateTime(2026, 7, 10),
InterestMode = (int)InterestModeEnum.,
InterestPrincipal = 3000m
};
@@ -410,6 +412,7 @@ namespace YLErp.Modules.SwapModule
PositionType = 1,
EventType = (int)SwapEventTypeEnum.,
EventDate = new DateTime(2026, 7, 9),
UnwindDate = new DateTime(2026, 7, 10),
TradingAmount = 3000000m
};
var originalWithFloat = new List<swap_position>
@@ -430,5 +433,79 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(10000m, beforeClose.InterestPrincipalFix);
Assert.AreEqual(7000m, onCloseDate.InterestPrincipalFix);
}
/// <summary>
/// [SPC_008] EventDate ≠ UnwindDate 时,ResolveInterestLegPositionsAsOf 按 EventDate(事件日期)分桶。
/// ----------------------------------------------------------------------------
/// 锁定事件日期作为历史重放的生效边界:
/// - settleDate &lt; EventDate → 平仓"未发生"as-of=原始本金
/// - settleDate &gt;= EventDate → 平仓"已生效"as-of=实时剩余本金
/// 本测试构造 EventDate=7/9、UnwindDate=7/10,验证 settleDate=7/9 时已按 EventDate 生效。
/// </summary>
[TestMethod]
public void SPC_008_EventDateDiffersFromUnwindDate_BucketsByEventDate()
{
const long originalPositionId = 2;
var original = new swap_position
{
id = originalPositionId, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 10000m
};
var realtime = new swap_position
{
PositionId = originalPositionId,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 7000m
};
// 关键:EventDate 为 7/9as-of 应按事件日期判断。
var close = new swap_flow_event
{
PositionId = originalPositionId,
PositionType = 0,
EventType = (int)SwapEventTypeEnum.,
EventDate = new DateTime(2026, 7, 9),
UnwindDate = new DateTime(2026, 7, 10),
InterestMode = (int)InterestModeEnum.,
InterestPrincipal = 3000m
};
var floatClose = new swap_flow_event
{
PositionId = 1,
PositionType = 1,
EventType = (int)SwapEventTypeEnum.,
EventDate = new DateTime(2026, 7, 9),
UnwindDate = new DateTime(2026, 7, 10),
TradingAmount = 3000000m
};
var originalWithFloat = new List<swap_position>
{
original,
new swap_position { id = 1, PosiDirection = 1, PosiNotionalValue = 10000000m }
};
var flows = new[] { close, floatClose };
// settleDate=7/8(事件日期前)→ as-of=原始 10000
var beforeEffective = SwapDealService.ResolveInterestLegPositionsAsOf(
originalWithFloat, new List<swap_position> { realtime }, flows, new DateTime(2026, 7, 8))
.Single(x => x.id == originalPositionId);
Assert.AreEqual(10000m, beforeEffective.InterestPrincipalFix,
"7/8(事件日期前):平仓未发生,as-of 本金应=原始 10000");
// settleDate=7/9(事件日期当天)→ as-of=实时剩余 7000
var onEffectiveDate = SwapDealService.ResolveInterestLegPositionsAsOf(
originalWithFloat, new List<swap_position> { realtime }, flows, new DateTime(2026, 7, 9))
.Single(x => x.id == originalPositionId);
Assert.AreEqual(7000m, onEffectiveDate.InterestPrincipalFix,
"7/9(事件日期):平仓已生效,as-of 本金应=实时剩余 7000");
// settleDate=7/10(事件日期后)→ 仍为实时剩余 7000
var afterEffectiveBeforeBook = SwapDealService.ResolveInterestLegPositionsAsOf(
originalWithFloat, new List<swap_position> { realtime }, flows, new DateTime(2026, 7, 10))
.Single(x => x.id == originalPositionId);
Assert.AreEqual(7000m, afterEffectiveBeforeBook.InterestPrincipalFix,
"7/10(事件日期后):必须按 EventDate 判已生效 → 7000。");
}
}
}
@@ -217,6 +217,19 @@ namespace YLErp.Modules.SwapModule
Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅");
}
[TestMethod]
public void UW_014_事件日期与平仓日期强绑定()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m);
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.);
Assert.AreEqual(unwindData.ValueDate, service.SaveSwapDealCalls[0].data.UnwindDate);
Assert.AreEqual(unwindData.ValueDate, unwindData.UnwindDate);
}
// ================================================================
// 场景7:前端传"占期初(A)"语义,后端入口转"占剩余(B)" —— 全平判定
// 原始名义本金 100M / 剩余 60M,前端传 A=0.6(平掉原始 60M = 剩余全部)
@@ -745,7 +745,7 @@ namespace YLErp.BLL.Eod
#region
private static void BondCalcApi(ClientPosition clientPosition)
{
var resp = BondCalcHepler.BondCalc(clientPosition.security_id, clientPosition.deal_full_price_avg ?? 0, "DP");
var resp = BondCalcHepler.BondCalc(clientPosition.security_id, clientPosition.deal_full_price_avg * ConsGlobal.bondShowPriceMultiple ?? 0, "DP");
if (resp != null)
{
clientPosition.deal_yield_avg = resp.ytm * ConsGlobal.bondPriceMultiple;
+4 -3
View File
@@ -97,14 +97,15 @@ namespace YLErp.Helpers
decimal entryPrice = input.PosiGrossPrice;
decimal scale = input.Multiplier == 100 ? 0.01m : 1m;
decimal floatRatio = input.PayDirection == 1 ? 1 : -1;
decimal longRatio = input.PositionType == 1 ? 1 : -1;
decimal tradingFee = ParseOrZero(input.TradingFee);
decimal tradingFeePending = ParseOrZero(input.TradingFeePending);
decimal dividendIn = ParseOrZero(input.DividendIn);
// MarkClosePnl = PositionQty × ContractSize × (TradingAmountAvg × scale EntryPrice) × floatRatio
// (无 longRatio、无 Math.round/10000
decimal markClosePnl = input.PositionQty * input.ContractSize * (input.TradingAmountAvg * scale - entryPrice) * floatRatio;
// MarkClosePnl = PositionQty × ContractSize × (TradingAmountAvg × scale EntryPrice) × floatRatio × longRatio
// (无 Math.round/10000
decimal markClosePnl = input.PositionQty * input.ContractSize * (input.TradingAmountAvg * scale - entryPrice) * floatRatio * longRatio;
markClosePnl = StockEqvNotional(markClosePnl);
decimal floatPnlSum = decimal.Parse(
+171 -52
View File
@@ -636,11 +636,14 @@ namespace YLErp.Modules.SwapModule
List<eod_swap_position> lastEodPositions = new SwapEodPositionService(this).GetPreEodPositions(tradeId, _preSetteDate);//上一交易数据
var posiLongNotionalValue = longPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金
var posiShortNotionalValue = shortPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金
var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue);
var posiNotionalValue = stockEqvNotional * closePercent;//剩余名义本金
var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional);
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) && x.DataState == (int)SwapFlowDateStateEnum.).ToList();
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;
interests = GetInterests(td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, stockEqvNotional, posiLongNotionalValue, posiShortNotionalValue, posiNotionalValue, closePercent, eventType, tdClose, false, grossPrice ?? 0, orginPv, true, false,false, closeList);
return interests;
@@ -706,10 +709,6 @@ namespace YLErp.Modules.SwapModule
|| x.InterestMode == (int)InterestModeEnum.)
.GroupBy(x => x.PositionId)
.ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal));
var priorClosePositionIds = new HashSet<long>((completedFlowEvents ?? Enumerable.Empty<swap_flow_event>())
.Where(x => x.EventType == (int)SwapEventTypeEnum. && x.EventDate <= settleDate)
.Select(x => x.PositionId));
return origPositions.Where(x => x.PosiDirection == 0).Select(p =>
{
if (p.InterestMode == (int)InterestModeEnum.
@@ -718,10 +717,6 @@ namespace YLErp.Modules.SwapModule
var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id);
if (realLeg != null)
{
if (!priorClosePositionIds.Contains(p.id))
{
return p;
}
var futurePrincipal = hasNotionalFlows
? p.InterestPrincipalFix * futureCloseNotional / originalNotional
: futureClosePrincipal.TryGetValue(p.id, out var flowPrincipal) ? flowPrincipal : 0m;
@@ -834,10 +829,17 @@ 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);
// 计算名义本金
var (closePrincipal, posiPrincipal, newClosePercent) = CalcNotionalByMode(position, closePrecent, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue);
if ((InterestModeEnum)position.InterestMode == InterestModeEnum.
|| (InterestModeEnum)position.InterestMode == InterestModeEnum.
&& posiNotionalValue == 0m)
{
closePrincipal = closePosiNotionalValue;
}
if ((InterestModeEnum)position.InterestMode == InterestModeEnum. || (InterestModeEnum)position.InterestMode == InterestModeEnum.)
{
positionClone.InterestDirection = position.InterestDirection == (int)SwapDirectionEnum. ? (int)SwapDirectionEnum. : (int)SwapDirectionEnum.;
@@ -860,7 +862,9 @@ namespace YLErp.Modules.SwapModule
var consumedInterest = position.InterestType == (int)InterestTypeEnum.
? GetConsumedInterest(td.id, position.id, endDate)
: 0m;
interests.Add(CalcUnwindInterest(td, valueDate, endDate, positionClone, rate, floatRate, posiPrincipal, closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, swap, orginPv, calcFirst, calcLast||newCalcLast, consumedInterest));
interests.Add(CalcUnwindInterest(td, valueDate, endDate, positionClone, rate, floatRate, posiPrincipal,
closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, swap, orginPv, calcFirst,
calcLast||newCalcLast, consumedInterest));
}
}
//当日有平仓或互换记录时,避免重复结算
@@ -900,9 +904,9 @@ namespace YLErp.Modules.SwapModule
/// </summary>
private (decimal close, decimal posi, decimal closePct) CalcNotionalByMode(swap_position position, decimal closePercent, decimal posiNotional, decimal posiLong, decimal posiShort)
{
decimal closePrincipal = posiNotional;
decimal posiPrincipal = posiNotional;
decimal newClosePercent = closePercent;
decimal closePrincipal = posiNotional; // 平仓部分的名义本金
decimal posiPrincipal = posiNotional; // 持仓部分的名义本金
decimal newClosePercent = closePercent; // 调整后的平仓比例
switch ((InterestModeEnum)position.InterestMode)
{
@@ -918,6 +922,9 @@ namespace YLErp.Modules.SwapModule
closePrincipal = posiShort * closePercent;
posiPrincipal = posiShort;
break;
case InterestModeEnum.:
closePrincipal = posiNotional * closePercent;
break;
case InterestModeEnum.:
closePrincipal = posiNotional * closePercent;
break;
@@ -1127,11 +1134,13 @@ namespace YLErp.Modules.SwapModule
preEod.ValueDate = td.StartDate.Value;
if (calcFirst)
{
preEod.ValueDate= preEod.ValueDate.AddDays(-1);
preEod.ValueDate = preEod.ValueDate.AddDays(-1);
}
}
return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, swap, posiPrincipal, closePrincipal, closePercent, annualDays, eventType, preEod, false, orginPv, calcFirst, calcLast, consumedInterest);
return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, swap, posiPrincipal,
closePrincipal, closePercent, annualDays, eventType, preEod, false,
orginPv, calcFirst, calcLast, consumedInterest);
}
/// <summary>
/// 初始化利息腿信息
@@ -1189,21 +1198,27 @@ namespace YLErp.Modules.SwapModule
// 根因修复:预付金(保证金)腿的计息基数维度应为"保证金本金"自身,而非整笔交易的名义本金(orginPv)。
// 否则公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv
// 会把交易名义本金(千万~亿级)当减项扣掉,使"应返还本金"(InterestPrincipal)与计息基数变成巨负值。
// 此处将预付金腿的 orginPv 对齐为其自身保证金(InterestPrincipalFix)
// 与日终路径(SwapEodPositionService 对预付金腿 orginPv=InterestPrincipalFix)保持一致
// 此处将预付金腿的 orginPv 对齐为上一日保证金本金;无历史归档时才取当前本金。
// 差分公式必须使用同一时点口径:上一日本金 + 当前本金 - 上一本金 = 当前本金
// 若已有部分平仓后仍取当前本金,会把上一日本金原样保留,导致当日继续按平仓前本金计息。
// 仅作用于初始预付金(5)/追加预付金(6);其它计息模式(含债券本金腿 标的期初全价=9)仍用交易名义本金,不受影响。
if (position.InterestMode == (int)InterestModeEnum.
|| position.InterestMode == (int)InterestModeEnum.)
{
orginPv = position.InterestPrincipalFix;
var previousPrincipal = preEodPosition.InterestPrincipalFix != 0m
? preEodPosition.InterestPrincipalFix
: preEodPosition.TdInterestPrincipal;
orginPv = preEodPosition.id != 0 && previousPrincipal != 0m
? previousPrincipal
: position.InterestPrincipalFix;
}
if (swap)
{
interest.InterestAmount = 0; // 利息金额
interest.TdInterestAmount = 0; // 当日新增利息
interest.InterestAmount = 0;
interest.TdInterestAmount = 0;
interest.InterestAmount = 0;
interest.InterestClosePnL = 0;
interest.InterestClosePnL = 0; // 利息端平仓盈亏
}
else
{
@@ -1213,7 +1228,79 @@ namespace YLErp.Modules.SwapModule
var floateRate = preEodPosition.FloatRate;
if (position.InterestType == (int)InterestTypeEnum.)
{
CalcDailyCompoundInterest( endDate, position, closePosiNotionalValue, interest, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount, consumedInterest);
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)
{
interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent; // 计息基数
interest.FloatRate = preEodPosition.FloatRate;
InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; // 利息金额 = 待实现 * 平仓比例
TdInterestAmount = preEodPosition.InterestIncomeSum; // 当日新增利息
interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.InterestClosePnL = interest.InterestAmount * interestRatio; // 利息端平仓盈亏 = 利息金额 * 方向
return interest;
}
// remainingPercent 只用于把上一日待实现分配给本次计算对应的本金。
// 按照利息腿的实际 计息基数 重新计算一个历史待实现利息的 平仓比例。不替代全局的平仓比例
// 部分平仓计算关闭 30% 时取 30%;最终全平剩余仓位时取 100%。
var remainingPercent = preEodPosition.TdInterestPrincipal > 0m
? closePosiNotionalValue / preEodPosition.TdInterestPrincipal
: 1m;
remainingPercent = Math.Max(0m, Math.Min(1m, remainingPercent));
// resetCarryInterest 是重置日并入复利本金的历史待实现,不是当天新增利息。
// 把上日尚未实现的的利息 按本次平掉的这部分计息基数分给本次平仓 并在重置日并入计息基数
// 它只在当前 endDate 恰好为重置日时使用,避免把同一笔历史利息重复资本化。
var resetCarryInterest = preEodPosition.InterestIncomeSum * remainingPercent;
CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, needPrice,
floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount,
consumedInterest, resetCarryInterest);
if (preEodPosition.id != 0 && closePrecent == 1m)
{
// 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。
// InterestAmount 是本次最终应结金额;TdInterestAmount 是不按关闭比例缩放的参考累计值。
// 二者在全平时都以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走。
var interestAtEnd = new swap_flow_event { InterestRate = rate };
decimal amountAtEnd = 0m;
decimal tdAmountAtEnd = 0m;
// InitInterestDate 在最终日不算尾时会先把 endDate 回拨一天;
// 历史差分的 amountAtEnd 需补回该日,但计算器仍使用交易 calcLast,
// 并将重放日期限制在合约到期日,避免提前全平或超期重复计息。
// 如果算尾 重放日 = 正常到期日
// 不算尾 且未超过到期日 重放日 = endDate+1 (补齐不算尾那天漏计的利息)
// 加1天超过到期日 截断到到期日
var replayEndDate = endDate;
if (!calcLast && endDate < valueDate)
{
replayEndDate = endDate.AddDays(1);
if (replayEndDate > td.ExerciseDate.Value)
{
replayEndDate = td.ExerciseDate.Value;
}
}
// 计算截至本次平仓日的累计利息 amountAtEnd
CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue,
interestAtEnd, annualDays, needPrice, floateRate, closePrecent, orginPv,
calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest);
var interestAtPreviousEod = new swap_flow_event { InterestRate = rate };
decimal amountAtPreviousEod = 0m;
decimal tdAmountAtPreviousEod = 0m;
// 最终日重放仍遵守交易的 calcLast;上一日终是历史截点而非合约尾日,
// 因此此处按闭区间包含上一日终当天,避免算头不算尾时重复加入该日利息。
// 计算截至上一日终累积的利息 amountAtPreviousEod
CalcDailyCompoundInterest(preEodPosition.ValueDate, position, closePosiNotionalValue,
interestAtPreviousEod, annualDays, needPrice, floateRate, closePrecent, orginPv,
calcFirst, true, ref amountAtPreviousEod, ref tdAmountAtPreviousEod, consumedInterest);
// 例如 00045/18 待实现 -118631.261797,加 5/19 新增约 -4648.912760
// 得到最终应结 -123280.174557,按金额两位落为 Excel BN 的 -123280.17。
// 上一日终已保存的待实现利息 + 截至平仓日累计利息 - 截至上一日终累计利息
// 这样只带走“上一日终以后新增的利息”,同时保留历史部分平仓时因两位金额结算留下的尾差,最终全平一次性结清。
InterestAmount = preEodPosition.InterestIncomeSum + amountAtEnd - amountAtPreviousEod;
TdInterestAmount = preEodPosition.InterestIncomeSum + tdAmountAtEnd - tdAmountAtPreviousEod;
}
}
else
{
@@ -1241,7 +1328,9 @@ namespace YLErp.Modules.SwapModule
/// <param name="isAnnualized">是否年化</param>
/// <param name="annualDays">年化天数</param>
/// <returns></returns>
public void CalcDailyCompoundInterest( DateTime endDate, swap_position position, decimal principal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m)
public void CalcDailyCompoundInterest(DateTime endDate, swap_position position, decimal principal, swap_flow_event flowEvent,
int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast,
ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m)
{
var startDate = position.PosiStartDate;
decimal interestProfitSum = 0;
@@ -1257,28 +1346,36 @@ namespace YLErp.Modules.SwapModule
for (int i = 0; i <= calcDays; i++)
{
var accrueDate = startDate.AddDays(i);
if (!calcFirst && accrueDate == startDate) continue; // 首日不算头
if (!calcLast && accrueDate == endDate) continue; // 到期日不算尾
// 重置日取价必须在 calcFirst/calcLast 跳过之前完成:calcLast=false(不算尾) 只应跳过计息,
// 不应跳过重置日的 FR007 取价。否则平仓日=重置日时会沿用旧周期利率,
// 且 flowEvent.FloatRate 落库为旧值,传染后续 EODGLMS-JIATT-20260805 根因)。
if (accrueDate >= startDate && i % interestPeriod == 0
&& !string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
{
var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0));
if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1))
{
if (floatRate1 != 0) floatRate = floatRate1;
}
else
{
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格");
}
}
if (accrueDate >= startDate)
{
if (i % interestPeriod == 0)
{
// 复利时:利息并入本金
dynomicPrincipal = principal + interest;
tdDynomicPrincipal = principal + interest;
// 获取新的浮动利率
if (!string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
{
var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0));
if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1))
{
if (floatRate1 != 0) floatRate = floatRate1;
}
else
{
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格");
}
}
// 每个重置节点 计息基数 = 前日本金 + 本期利息
// resetCarryInterest 是上一日终待实现按本次平仓比例分摊后的存量,
// 只能在 endDate 恰好是当前复利重置日时并入本金。历史重置点必须使用
// 重放到当时的 interest,否则会把上一日终存量反复注入历史本金,
// 例如 0007 的 5/11 部分平仓会由 84,090.95 被多算为 84,114.88。
var interestToReset = i > 0 && accrueDate == endDate && resetCarryInterest != 0m
? resetCarryInterest
: interest;
dynomicPrincipal = principal + interestToReset;
tdDynomicPrincipal = principal + interestToReset;
flowEvent.InterestPrincipal = tdDynomicPrincipal;
TdInterestPrincipal = tdDynomicPrincipal;
}
@@ -1288,6 +1385,11 @@ namespace YLErp.Modules.SwapModule
flowEvent.InterestPrincipal = tdDynomicPrincipal;
TdInterestPrincipal = tdDynomicPrincipal;
}
}
if (!calcFirst && accrueDate == startDate) continue; // 首日不算头
if (!calcLast && accrueDate == endDate) continue; // 到期日不算尾(只跳过计息,重置本金已在上方完成)
if (accrueDate >= startDate)
{
flowEvent.FloatRate = Convert.ToDecimal(floatRate);
var interest1 = flowEvent.InterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate));
var tdinterest1 = TdInterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate));
@@ -1300,11 +1402,14 @@ namespace YLErp.Modules.SwapModule
tdinterest += tdinterest1;
}
}
// 兜底:若循环因 calcLast 跳过最后一天(重置日=平仓日)flowEvent.FloatRate 不会被循环内赋值,
// 用最终 floatRate 兜底,确保落库的 FloatRate 反映最后一个重置日的利率(GLMS-JIATT-20260805)。
flowEvent.FloatRate = Convert.ToDecimal(floatRate);
// 复利从头重放得到的是"假设从未结出"的整段总利息,需扣除历史已通过互换结出的利息,
// 否则已结部分会重复计息(类比分红 PosiDividendSum = totalToDate RealizedDividend)。
// consumedInterest 为绝对值口径(swap_flow_event.InterestAmount 之和),与 interest 口径一致。
interest -= consumedInterest;
tdinterest -= consumedInterest;
// consumedInterest is full-position absolute interest; scale it to this close portion.
interest -= consumedInterest * closePercent;
tdinterest -= consumedInterest * closePercent;
InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
}
@@ -1391,9 +1496,15 @@ namespace YLErp.Modules.SwapModule
decimal tdDynomicPrincipal = posiPrincipal;
double floatRate = Convert.ToDouble(floateRate);
var days = (endDate - tradeDate).Days;
LogFactory.GetLogger("test").Error("lksafhasdhfjas");
if (days % interestPeriod == 0)
{
tdDynomicPrincipal = tdDynomicPrincipal + interestProfitSum;
LogFactory.GetLogger("test").Error("kluausdyfh");
var remainingPercent = posiPrincipal > 0m
? principal / posiPrincipal
: 1m;
remainingPercent = Math.Max(0m, Math.Min(1m, remainingPercent));
tdDynomicPrincipal = tdDynomicPrincipal + interestProfitSum * remainingPercent;
if (!string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
{
// 获取合适的 rateDate
@@ -1505,6 +1616,7 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
NormalizeNotionalValues(unwindData);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_平仓");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
@@ -1919,6 +2031,7 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_平仓");
var trans = DbContext.Database.BeginTransaction();
@@ -1963,6 +2076,7 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_互换");
var trans = DbContext.Database.BeginTransaction();
@@ -2002,7 +2116,7 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
NormalizeIncomeUnwindDate(unwindData);
NormalizeEventUnwindDate(unwindData);
ValidateIncomeValueDate(unwindData, td);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_互换");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
@@ -2043,6 +2157,7 @@ namespace YLErp.Modules.SwapModule
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
NormalizeEventUnwindDate(swapEvent.unwindData);
NormalizeNotionalValues(swapEvent.unwindData);
// Stored events keep display ratio A; approval calculations consume remaining ratio B.
swapEvent.unwindData.ClosePercent = ToRemainingClosePercent(
@@ -2050,6 +2165,11 @@ namespace YLErp.Modules.SwapModule
swapEvent.unwindData.NotionalValue,
swapEvent.unwindData.PosiNotionalValue);
var flowList = FindFlowEventsByEventId(swapEvent.id);
foreach (var item in flowList)
{
item.EventDate = swapEvent.unwindData.ValueDate;
item.UnwindDate = swapEvent.unwindData.UnwindDate;
}
swapEvent.unwindData.FlowEvents = flowList;
if (eventType == (int)SwapEventTypeEnum.)
{
@@ -2060,7 +2180,6 @@ namespace YLErp.Modules.SwapModule
}
if (eventType == (int)SwapEventTypeEnum.)
{
NormalizeIncomeUnwindDate(swapEvent.unwindData);
ValidateIncomeValueDate(swapEvent.unwindData, td);
}
if (eventType == (int)SwapEventTypeEnum.)
@@ -2130,9 +2249,9 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
if (eventType == (int)SwapEventTypeEnum.)
{
NormalizeIncomeUnwindDate(unwindData);
ValidateIncomeValueDate(unwindData, td);
}
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
@@ -2179,7 +2298,7 @@ namespace YLErp.Modules.SwapModule
}
}
private void NormalizeIncomeUnwindDate(UnwindData unwindData)
private static void NormalizeEventUnwindDate(UnwindData unwindData)
{
unwindData.UnwindDate = unwindData.ValueDate;
}
@@ -1269,8 +1269,13 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 自动互换用,当日无互换,当日有平仓
/// 将平仓/自动互换的盘中利息结果写成当日日终利息腿。
/// 字段完整口径和逐日示例见《收益互换日终收盘总流程与当前代码审查》7.2、16.7、16.13 节。
/// </summary>
/// <remarks>
/// 关键状态链:上日待实现 + 当日新增 - 当日结息 = 当日待实现;
/// 上日累计已实现 + 当日结息(按收付方向)= 当日累计已实现。
/// </remarks>
/// <param name="eodPayPosition">上一日日终持仓</param>
/// <param name="newEodPayPosition">当前收盘日日终持仓 不可能为空</param>
/// <param name="position">利息腿信息</param>
@@ -1285,8 +1290,11 @@ namespace YLErp.Modules.SwapModule
{
Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
var tradeExtend = td.trade_extend.ExtendObj;
// oriPosiNotionalValue 是平仓前规模,posiNotionalValue 是收盘后剩余规模,closeNational 是本次关闭规模。
// 例如 30% 平仓:303139117.80 = 212197382.46 + 90941735.34。
decimal oriPosiNotionalValue = posiLongNotional + posiShortNational + closeNational;
decimal posiNotionalValue = posiLongNotional + posiShortNational;
// ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。
decimal ratio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;//收取为正,支付为负
if (marginTypes.Contains(position.InterestMode))
{
@@ -1295,10 +1303,16 @@ namespace YLErp.Modules.SwapModule
// 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。
// 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum
// 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。
var hasPreviousEod = eodPayPosition != null && eodPayPosition.id != 0;
// InterestIncomeSum 是尚未结算的高精度利息;RealizedInterest 是生命周期累计已结利息。
// 二者不能相互替代,也不能在部分平仓后重新从 0 开始。
var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m;
var lastInterestFeeSum = eodPayPosition?.InterestFeeSum ?? 0m;
var lastRealizedInterest = eodPayPosition?.RealizedInterest ?? 0m;
var lastRealizedInterestFee = eodPayPosition?.RealizedInterestFee ?? 0m;
// 先保留平仓前的复利本金;后面 interests.First().InterestPrincipal 是本次已平部分,
// 不能用它代表平仓前全额本金计算当日总利息。
var lastTdInterestPrincipal = eodPayPosition?.TdInterestPrincipal ?? 0m;
// 保留上一日日终标识和计息上下文,部分平仓只从 ValueDate 之后续算,不能重置到交易起始日。
eodPayPosition = eodPayPosition?.Clone() ?? new eod_swap_position();
eodPayPosition.ClientId = td.ClientId;
@@ -1328,6 +1342,7 @@ namespace YLErp.Modules.SwapModule
{
orginPv = posiNotionalValue;
}
// closePercent 描述本次关闭占平仓前仓位的比例;上例为 90941735.34 / 303139117.80 = 30%。
decimal closePercent = oriPosiNotionalValue == 0 ? 0 : closeNational / oriPosiNotionalValue;
var eventType = autoSwap ? (int)SwapEventTypeEnum. : (int)SwapEventTypeEnum.;
bool longShort = td.StructureType == ClientMarginTypeEnum..ToString();
@@ -1343,7 +1358,11 @@ namespace YLErp.Modules.SwapModule
positions.Add(position);
List<eod_swap_position> preEodPositions = new List<eod_swap_position>();
preEodPositions.Add(eodPayPosition);
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: true);
var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true;
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast);
// TdInterestAmount:计息器返回的全腿当日/累计参考值,用于拆出 EOD 的当日新增。
// interestAmountBeforeSettlement:本次事件发生前理论应结的高精度利息。
// manualSettledInterestAmountswap_flow_event 实际落库的手工结息,金额已按分处理。
decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount);
decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount);
decimal manualSettledInterestAmount = flowEvents.Sum(x => x.InterestAmount);
@@ -1377,9 +1396,12 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.interest_rest_days = position.interest_rest_days;
newEodPayPosition.interest_rule = position.interest_rule;
//利息端估值用信息
// TdInterestPrincipal 是“下一日继续计息的收盘后本金”,不是原始合同规模,也不是本次平仓本金。
// 模式9单利直接取剩余名义本金;复利还要保留重置时已经并入本金的待实现利息。
newEodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode)
? position.InterestPrincipalFix
: position.InterestMode == (int)InterestModeEnum.
&& position.InterestType != (int)InterestTypeEnum.
? posiNotionalValue
: interests.Count > 0 ? interests.First().InterestPrincipal : 0;
if (interval != null)
@@ -1393,33 +1415,104 @@ namespace YLErp.Modules.SwapModule
//当日已实现,平仓时已处理
newEodPayPosition.TdInterestFee = flowEvents.Sum(s => s.InterestFee);
newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee;
// TdCloseInterest 只表示当天真正结算出去的金额;部分平仓未结部分继续留在 InterestIncomeSum。
newEodPayPosition.TdCloseInterest = manualSettledInterestAmount + autoSettledInterestAmount;
// intersetAcmount 是收盘后本金的一天应计展示值。算尾部分平仓时,下面的复利分支会改用
// 平仓前全额本金重算当天新增,但跨日携带的 TdInterestPrincipal 仍只能是剩余本金。
var intersetAcmount = newEodPayPosition.TdInterestPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate);
if (position.IsAnnualized)
{
intersetAcmount /= tradeExtend.AnnualDays;
}
newEodPayPosition.TdInterestIncome = intersetAcmount;
newEodPayPosition.TdInterestIncome = autoSwap
? intersetAcmount
: !hasPreviousEod
? interestAmountBeforeSettlement
: posiNotionalValue == 0m
? interestAmountBeforeSettlement - lastInterestIncomeSum
: lastRealizedInterest != 0m || lastRealizedInterestFee != 0m || !calcLast
? intersetAcmount
: TdInterestAmount - lastInterestIncomeSum;
if (!autoSwap
&& closePercent > 0m && closePercent < 1m
&& posiNotionalValue > 0m
&& position.InterestType == (int)InterestTypeEnum.
&& (position.InterestMode == (int)InterestModeEnum.
|| position.InterestMode == (int)InterestModeEnum.))
{
// 模式2(合约名义本金规模)和模式9(标的期初全价)都以名义本金
// 作为复利基数;算尾用平仓前全额当日利息再扣实际结算,
// 不算尾只计剩余本金,避免已平部分利息进入后续复利。
// fullPrincipal 是平仓前动态复利本金,仅用于判断平仓日应按全额还是剩余额计息。
var fullPrincipal = lastTdInterestPrincipal > 0m
? lastTdInterestPrincipal
: oriPosiNotionalValue;
// 当日计提按平仓前全额动态本金;跨日携带必须只留剩余仓位。
// calcLast=true 时,模式2返回本次已平部分本金,需反推剩余本金;
// 模式9返回的已是剩余本金,不能再次按比例放大(GLMS-20260421-0004)。
// calcLast=false 快速路径返回上一 EOD 全额本金,保留原剩余比例缩放。
var usesFullPreviousEodPrincipal = !calcLast
&& hasPreviousEod
&& (valueDate - eodPayPosition.ValueDate).Days == 1
&& (valueDate - position.PosiStartDate).Days % (position.interest_rest_days ?? 1) != 0;
if (calcLast
&& position.InterestMode == (int)InterestModeEnum.)
{
// 模式2的 InterestPrincipal 是已平部分,需反推平仓前全额后再取剩余;
// 模式9已直接返回剩余动态本金,再反推会把 30% 平仓后的本金放大 7/3 倍。
// 例如模式9的 212135529.97 已是剩余本金,错误反推会变成 494982903.27。
newEodPayPosition.TdInterestPrincipal *= (1m - closePercent) / closePercent;
}
else if (usesFullPreviousEodPrincipal)
{
newEodPayPosition.TdInterestPrincipal *= 1m - closePercent;
}
// 不算尾时,TdInterestPrincipal 已由计息器完成重置日待实现利息结转,
// 并在非重置日分支按剩余仓位调整;若再次用上日本金乘剩余比例,
// 会漏掉重置后已并入本金的待实现利息(如 2026-08-04 两笔 JIATT 交易)。
var accrualPrincipal = calcLast
? fullPrincipal
: newEodPayPosition.TdInterestPrincipal;
newEodPayPosition.TdInterestIncome = accrualPrincipal
* (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate);
if (position.IsAnnualized)
{
newEodPayPosition.TdInterestIncome /= tradeExtend.AnnualDays;
}
}
if (!autoSwap
&& closePercent > 0m && closePercent < 1m
&& posiNotionalValue > 0m
&& position.InterestType == (int)InterestTypeEnum.
&& (position.InterestMode == (int)InterestModeEnum.
|| position.InterestMode == (int)InterestModeEnum.))
{
// 单利算尾当日仍按平仓前全额计提,跨日 EOD 本金只携带剩余持仓。
newEodPayPosition.TdInterestPrincipal = posiNotionalValue;
}
Log.Info($"InterestIncomeSum is {lastInterestIncomeSum},TdInterestIncome is {newEodPayPosition.TdInterestIncome}" +
$",TdCloseInterest is {newEodPayPosition.TdCloseInterest}");
Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee}" +
$",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}");
if (closePercent == 1)
{
// 全量平仓后不应把待实现利息或费用带入下一交易日。
newEodPayPosition.InterestIncomeSum = 0;
newEodPayPosition.InterestFeeSum = 0;
}
else
{
var pendingInterestBeforeSettlement = autoSwap
? interestAmountBeforeSettlement
: lastInterestIncomeSum + newEodPayPosition.TdInterestIncome;
newEodPayPosition.InterestIncomeSum = RoundEodInterest(
pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
}
// pendingInterestBeforeSettlement 是“扣款前待实现”。普通平仓按上日待实现 + 当日新增;
// 自动互换的 interestAmountBeforeSettlement 已经是完整理论应结,不能再加一次上日值。
var pendingInterestBeforeSettlement = autoSwap
? interestAmountBeforeSettlement
: lastInterestIncomeSum + newEodPayPosition.TdInterestIncome;
var pendingInterestFeeBeforeSettlement = eodPayPosition.InterestFeeSum
+ newEodPayPosition.TdInterestFee;
// InterestIncomeSum 是收盘后仍未结算的尾差/剩余利息。
// 部分平仓:扣款前待实现 - TdCloseInterest;最终全平且两位金额已覆盖时直接清零。
newEodPayPosition.InterestIncomeSum = closePercent == 1
&& RoundMoney(pendingInterestBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest)
? 0m
: RoundEodInterest(pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = closePercent == 1
&& RoundMoney(pendingInterestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee)
? 0m
: RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee);
//持仓内容-利息腿-损益统计(本方视角)
// InterestProfitSum 是利息腿待实现总额,包含利息和费用;无费用时等于 InterestIncomeSum。
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值
newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum;
@@ -1429,6 +1522,8 @@ namespace YLErp.Modules.SwapModule
Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee}" +
$",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}");
//累计已实现
// RealizedInterest 只增不回滚:上日累计已实现 + 当日结息按方向后的金额。
// 收取腿的 -37119.14 会把累计已实现更新为 -37119.14;后续普通 EOD 保持该值。
newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio;
newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee;
SetFixedLegRealizedPnl(newEodPayPosition);
+9 -14
View File
@@ -9,6 +9,7 @@ using YLErp.DBModels;
using YLErp.Helpers;
using YLErp.Model;
using YLErp.Model.Enum;
using YLErp.Modules.EodModule;
using YLErp.Modules.SwapModule.Dto;
using YLErp.Office;
using YLErp.QdpModule;
@@ -79,8 +80,13 @@ namespace YLErp.Modules.SwapModule
/// <exception cref="ServiceException"></exception>
public bool AddOrUpdateFRdata(Double price, DateTime dateTime)
{
string beforedate = "";
var frdata = DbContext.eod_commodity_future_price.Where(a => a.ValueDate == dateTime && a.UnderlyingCode == "FR007").FirstOrDefault();
var frUnderlying = DbContext.underlying_manager.FirstOrDefault(a => a.UnderlyingCode == "FR007");
if (frUnderlying == null)
{
throw new ServiceException("找不到FR007的标的");
}
if (frdata == null)
{
frdata = new eod_commodity_future_price();
@@ -91,28 +97,17 @@ namespace YLErp.Modules.SwapModule
frdata.ValueDate = dateTime;
frdata.HighPrice = 0;
frdata.LowPrice = 0;
beforedate = JsonHelper.Serialize(frdata);
}
else
{
//新增
var newestdata = DbContext.eod_commodity_future_price.OrderByDescending(a => a.ValueDate).FirstOrDefault();
if (newestdata == null)
{
var underlyingCode = DbContext.underlying_manager.Where(a => a.UnderlyingCode == "FR007").FirstOrDefault();
if (underlyingCode == null)
{
throw new ServiceException("找不到FR007的标的");
}
newestdata = new eod_commodity_future_price();
newestdata.UnderlyingId = underlyingCode.id;
}
frdata.ValueDate = dateTime;
frdata.UnderlyingCode = "FR007";
frdata.UnderlyingId = newestdata.UnderlyingId;
frdata.UnderlyingId = frUnderlying.id;
frdata.DataSource = EodPriceBase.;
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);
@@ -369,7 +369,7 @@ namespace YLErp.Modules.SwapModule
{
interestStart = td.StartDate.Value;
var exerciseDate = td.ExerciseDate.Value;
interestEnd = valueDate> exerciseDate? exerciseDate : valueDate;
interestEnd = valueDate > exerciseDate ? exerciseDate : valueDate;
bool calcFirst = true;
bool calcLast = true;
+119 -4
View File
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Http;
using System.Buffers;
using System.Text;
using System.Text.Json;
namespace YLErp.Web.App
{
@@ -17,6 +19,11 @@ namespace YLErp.Web.App
public async Task Invoke(HttpContext context)
{
if (IsSwapTradeEditRequest(context.Request))
{
context.Request.EnableBuffering();
}
try
{
await _next.Invoke(context);
@@ -41,9 +48,17 @@ namespace YLErp.Web.App
if (serviceExpcetion == null || serviceExpcetion.IsFaultError)
{
var result = await request.BodyReader.ReadAsync();
var reqBody = ConvertBufferToString(result.Buffer);
LogFactory.GetLogger(context.Request.Path.Value).Error(serviceExpcetion ?? exception, $"[query]:{request.QueryString.Value};[body]:{reqBody}");
if (IsSwapTradeEditRequest(request))
{
var diagnostic = await GetSwapIntervalDiagnosticAsync(request);
LogFactory.GetLogger(context.Request.Path.Value).Error(serviceExpcetion ?? exception, $"[query]:{request.QueryString.Value};{diagnostic}");
}
else
{
var result = await request.BodyReader.ReadAsync();
var reqBody = ConvertBufferToString(result.Buffer);
LogFactory.GetLogger(context.Request.Path.Value).Error(serviceExpcetion ?? exception, $"[query]:{request.QueryString.Value};[body]:{reqBody}");
}
}
}
catch (Exception ex)
@@ -78,6 +93,106 @@ namespace YLErp.Web.App
return System.Text.Encoding.UTF8.GetString(span);
}
private static bool IsSwapTradeEditRequest(HttpRequest request)
{
return string.Equals(request.Path.Value, "/swaptrade2/tradeEditJson", StringComparison.OrdinalIgnoreCase);
}
private static async Task<string> GetSwapIntervalDiagnosticAsync(HttpRequest request)
{
if (!request.Body.CanSeek)
{
return "[swap-interval-diagnostic]:request-body-unavailable";
}
request.Body.Position = 0;
using var reader = new StreamReader(request.Body, Encoding.UTF8, false, 1024, leaveOpen: true);
var requestBody = await reader.ReadToEndAsync();
request.Body.Position = 0;
if (string.IsNullOrWhiteSpace(requestBody))
{
return "[swap-interval-diagnostic]:request-body-empty";
}
try
{
using var document = JsonDocument.Parse(requestBody);
if (!document.RootElement.TryGetProperty("swap_positions", out var positions) || positions.ValueKind != JsonValueKind.Array)
{
return "[swap-interval-diagnostic]:swap_positions-missing";
}
var invalidRates = new List<string>();
var positionIndex = 0;
foreach (var position in positions.EnumerateArray())
{
var positionId = position.TryGetProperty("id", out var id) ? id.ToString() : "missing";
AddInvalidRateDiagnostics(position, "SwapIntervalList", false, positionIndex, positionId, invalidRates);
AddInvalidRateDiagnostics(position, "InterestSwapInterval", true, positionIndex, positionId, invalidRates);
if (position.TryGetProperty("Obervation", out var observation))
{
AddInvalidRateDiagnostics(observation, "Obervation.ObservationInterval", true, positionIndex, positionId, invalidRates);
}
if (invalidRates.Count >= 10)
{
break;
}
positionIndex++;
}
return invalidRates.Count == 0
? "[swap-interval-diagnostic]:no-invalid-rate-in-payload"
: $"[swap-interval-diagnostic]:{string.Join(";", invalidRates)}";
}
catch (JsonException)
{
return "[swap-interval-diagnostic]:request-json-invalid";
}
}
private static void AddInvalidRateDiagnostics(JsonElement position, string source, bool serializedJson, int positionIndex, string positionId, List<string> invalidRates)
{
if (!position.TryGetProperty(source, out var intervals))
{
return;
}
if (serializedJson)
{
if (intervals.ValueKind != JsonValueKind.String)
{
return;
}
try
{
using var document = JsonDocument.Parse(intervals.GetString());
intervals = document.RootElement.Clone();
}
catch (JsonException)
{
invalidRates.Add($"positionIndex={positionIndex},positionId={positionId},source={source},interval-json-invalid");
return;
}
}
if (intervals.ValueKind != JsonValueKind.Array)
{
return;
}
var intervalIndex = 0;
foreach (var interval in intervals.EnumerateArray())
{
if ((!interval.TryGetProperty("Rate", out var rate) || rate.ValueKind == JsonValueKind.Null) && invalidRates.Count < 10)
{
invalidRates.Add($"positionIndex={positionIndex},positionId={positionId},source={source},intervalIndex={intervalIndex},rate={(rate.ValueKind == JsonValueKind.Null ? "null" : "missing")}");
}
intervalIndex++;
}
}
private static string GetInnerExceptionMessage(Exception ex)
{
var exceptionStr = ex.Message;
@@ -89,4 +204,4 @@ namespace YLErp.Web.App
return exceptionStr;
}
}
}
}
+28 -28
View File
@@ -3,54 +3,54 @@ window.main = window.main || {};
window.main.swapPricePrecision = {
common: {
amount: { precision: 2, grouping: true },
quantity: { integerDigits: 16, precision: 2, grouping: true },
quantity: { integerDigits: 8, precision: 2, grouping: true },
rate: { precision: 4 }
},
Stock: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
StockIndex: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
StockIF: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
CommodityFutures: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
CommoditySpot: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
NewOtcStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
HKStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
HKStockIndex: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
Fund: { integerDigits: 7, precision: 4, quantityPrecision: 4, quantityIntegerDigits: 12 },
Stock: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 8 },
StockIndex: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 8 },
StockIF: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
CommodityFutures: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
CommoditySpot: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
NewOtcStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
HKStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
HKStockIndex: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
Fund: { integerDigits: 7, precision: 4, quantityPrecision: 4, quantityIntegerDigits: 8 },
Bond: {
quantityPrecision: 0, quantityIntegerDigits: 16,
quantityPrecision: 0, quantityIntegerDigits: 12,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
quantityPrecision: 0, quantityIntegerDigits: 12,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
CreditBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
quantityPrecision: 0, quantityIntegerDigits: 12,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
OtherBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
quantityPrecision: 0, quantityIntegerDigits: 12,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
OtherFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
GoldFutures: { quantityIntegerDigits: 12 },
GoldSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
OtherSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadStock: { integerDigits: 8, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadStockIndex: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
ExRate: { integerDigits: 2, precision: 8, quantityPrecision: 8, quantityIntegerDigits: 16 },
Shibor: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
FixingRepoRate: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
RateYield: {integerDigits: 6, precision: 8, quantityPrecision: 2, quantityIntegerDigits: 12},
BondIndex: {integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12},
TBFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
OtherFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
GoldFutures: { integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
GoldSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
OtherSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
AbroadFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
AbroadSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
AbroadStock: { integerDigits: 8, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 8 },
AbroadStockIndex: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
ExRate: { integerDigits: 2, precision: 8, quantityPrecision: 4, quantityIntegerDigits: 8 },
Shibor: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
FixingRepoRate: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
RateYield: {integerDigits: 6, precision: 8, quantityPrecision: 2, quantityIntegerDigits: 8},
BondIndex: {integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8},
};
@@ -108,6 +108,11 @@ namespace YLErp.Web.Areas.Admin.Controllers
/// <summary>
/// 保存配置
/// </summary>
/// <remarks>
/// 保存链路:写 DB(AppConfig.OtcFormatConfig) → 落盘 App_Data/Config/otcformat.js → 刷新内存。
/// 注意:File.WriteAllText 只写当前节点磁盘。多节点部署下,未处理本次请求的节点文件不会更新,
/// 会造成"DB 正确但 /front/otcformat 返回旧值"。完整排查见 FrontController 类注释。
/// </remarks>
public JsonResult AjaxSaveOtcFormat(OtcFormatModel model)
{
if (model is null)
+50 -2
View File
@@ -10,6 +10,50 @@ namespace YLErp.Web.Controllers
/// 专用于输出前端JS的控制器
/// 数据必须为非敏感数据
/// </summary>
/// <remarks>
/// ============================================================
/// OtcFormat 配置链路 & 排查说明(改前端格式化"不起效果"先看这里)
/// ============================================================
///
/// 【三个同名文件,角色不同】
/// 1. wwwroot/Scripts/init/otcformat.js —— 出厂默认值(兜底种子),Git 管理
/// 2. wwwroot/Scripts/base/otcformat.js —— 格式化引擎(运行时逻辑),Git 管理
/// 依赖 lodash.js / main.numberFormat / jQuery,且须先加载"配置段"再加载引擎
/// 3. App_Data/Config/otcformat.js —— 运行时配置文件(真正生效那份),非 Git 管理
/// 内容形如: var main = main || {}; main.formatOptions = { trading: {...} };
///
/// 【配置真相源:DB AppConfig 表】
/// PGroup = "ProjectConfig", PName = "OtcFormatConfig" (见 ConsAppConfig.OtcFormatConfig)
/// App_Data/Config/otcformat.js 只是 DB 配置落盘的镜像。
///
/// 【写入链路】OtcConfigController.AjaxSaveOtcFormat (Admin 配置页"保存"):
/// 表单 model → JsonConvert.SerializeObject
/// → AppConfigService.SaveOtcFormatConfig 写 DB
/// → File.WriteAllText(App_Data/Config/otcformat.js) 落盘(只写当前节点!)
/// → OtcFormatHelper.Initialize 刷新服务端内存
///
/// 【读取链路】FrontController.OtcFormat (前端 GET /front/otcformat):
/// 读 App_Data/Config/otcformat.js + Scripts/base/otcformat.js 拼接返回
/// 前端引擎 base/otcformat.js 用 main.formatOptions 覆盖内置默认值
///
/// 【应用启动】AppManager (subSystem==OtcWeb):
/// DB 有配置 → 反序列化进 OtcFormatHelper + 落盘 App_Data/Config/otcformat.js
/// DB 为空 → 用 Scripts/init/otcformat.js 兜底种子落盘
///
/// 【排查"前端不起效果"按此顺序】
/// ① 浏览器直访 /front/otcformat?v=随机数,看 main.formatOptions 是否最新
/// · 加 ?v= 正确 / 不加错误 → ResponseCache 缓存(本接口已改 NoStore,不应再出现)
/// · 加了 ?v= 仍错误 → 进 ②
/// ② 查 DB AppConfig(OtcFormatConfig).PValue 是否最新
/// · DB 空/旧 → AjaxSaveOtcFormat 没成功,查配置页 POST 响应
/// ③ 直接读服务器 App_Data/Config/otcformat.js 是否最新
/// · DB 对但文件旧 → AjaxSaveOtcFormat 的 File.WriteAllText 没生效,
/// 或多节点负载均衡下只写了接收 POST 的那台(读取命中了另一台旧文件)。
/// 读取和写入都只操作本机 Server.MapPath,无跨节点同步——多节点部署需注意。
/// ④ 页面引用方式:必须走 /front/otcformat,不能直接引 base/otcformat.js
/// (后者只有引擎默认值,没有配置段),例如 wwwroot/Scripts/test/tradeCalc.html 即如此。
/// ============================================================
/// </remarks>
[MyAuthorizeIgnore]
public class FrontController : BaseController
{
@@ -21,9 +65,13 @@ namespace YLErp.Web.Controllers
return Content(js, "text/javascript");
}
//格式化选项(缓存600s)
//格式化选项(配置动态变更,禁用缓存——见下方 OtcFormat 排查说明)
[AllowAnonymous]
[ResponseCache(Duration = 600, Location = ResponseCacheLocation.Any)]
//重要:此处不能用 [ResponseCache(Duration=600,Location=Any)]
// 原因:OtcFormat 是会动态变更的运行时配置(见 OtcConfigController.AjaxSaveOtcFormat),
// 若启用缓存,Admin 页保存后最长 600s 内仍返回旧内容,"前端改了配置不起效果"。
// 此前曾用 Duration=600,导致保存后必须等缓存过期或加 ?v=随机数 才生效。
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public ActionResult OtcFormat()
{
var js = string.Empty;
@@ -125,6 +125,8 @@ namespace YLErp.Web.Controllers
renewTrade.id = 0;
renewTrade.TradeNumber = string.Empty;
renewTrade.ParentTradeId = 0;
renewTrade.IsGroup = 0;
renewTrade.IsApproval = false;
renewTrade.TradeDate = defaultTrade.TradeDate;
renewTrade.StartDate = defaultTrade.StartDate;
renewTrade.ExerciseDate = null;
@@ -160,6 +162,20 @@ namespace YLErp.Web.Controllers
renewTrade.trade_swap.id = 0;
renewTrade.trade_swap.TradeId = 0;
renewTrade.trade_swap.FlowId = null;
renewTrade.trade_swap.OriginalTradeId = null;
// The renewed payment floating leg opens the opposite underlying side.
// 这里只需要对【支付】相关腿进行操作
// 原收取腿不变
// 支付 多头 = 空头
// 支付 空头 = 多头
// 收取 空头 = 空头
// 收取 多头 = 多头
//
// if (renewTrade.trade_swap.IsPayFloatingProfit)
// {
// renewTrade.trade_swap.PayLongShort = ReverseLongShort(renewTrade.trade_swap.PayLongShort);
// }
}
renewTrade.trade_extend = sourceTrade.trade_extend?.Clone() ?? defaultTrade.trade_extend;
renewTrade.trade_extend.TradeId = 0;
@@ -189,9 +205,14 @@ namespace YLErp.Web.Controllers
// 清空运行时累计字段(这些字段在源交易存续期间可能被累计)
renewPosition.InterestAmount = 0;
renewPosition.InterestFeePending = 0;
renewPosition.FloatRate = 0;
renewPosition.PosiDividendIncome = 0;
renewPosition.InterestSwapInterval = null;
renewPosition.Obervation = null;
if (renewPosition.PosiDirection == 2)
{
renewPosition.PositionType = ReverseLongShort(renewPosition.PositionType);
}
return renewPosition;
}).ToList() ?? new List<swap_position>();
// 清空源交易的事件/持仓快照等集合,避免与源交易共享引用
@@ -203,6 +224,21 @@ namespace YLErp.Web.Controllers
renewTrade.ClientCashInCashOutList = new List<ClientCashInCashOut>();
return renewTrade;
}
private static int ReverseLongShort(int PositionType)
{
if (PositionType == 1)
{
return 2;
}
if (PositionType == 2)
{
return 1;
}
return PositionType;
}
/// <summary>
/// 详情
/// </summary>
@@ -396,6 +432,7 @@ namespace YLErp.Web.Controllers
/// <returns></returns>
public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType, decimal notionalValue = 0, decimal posiNotionalValue = 0)
{
unwindDate = valueDate;
// 前端按"占期初(original)"语义传 closePercent(A);后端 GetUnwindInterests 按"占剩余(remaining)"语义(B)计算。
// 多空互换前端不传 notionalValue/posiNotionalValue(默认 0),则跳过转换保持原行为。
var convertedClosePercent = SwapDealService.ToRemainingClosePercent(closePercent, notionalValue, posiNotionalValue);
+19 -3
View File
@@ -2,6 +2,16 @@
@{
ViewBag.Title = "交易 | 提前终止详情";
Layout = "~/Views/Shared/_InfoLayout.cshtml";
bool hideFloatingIncomeDirection = PS.Config.ErpElement.SwapFloatingIncomeReceiveOnlyMode;
string FloatingPositionTypeText(int posiDirection, int positionType)
{
if (hideFloatingIncomeDirection)
{
return posiDirection == positionType ? "多头" : "空头";
}
return positionType == (int)PositionTypeFlag.Long ? "多头" : "空头";
}
}
@section CSS{
<link rel="stylesheet" href="~/Style/Css/SwapCloseDetail.css?v=@(HtmlUtil.JsVersion)" />
@@ -69,7 +79,10 @@
<table class="table table-bordered">
<tbody>
<tr>
<td>收支方向</td>
@if (!hideFloatingIncomeDirection)
{
<td>收支方向</td>
}
<td>多空方向</td>
<td>标的代码</td>
<td>期初标的价格</td>
@@ -84,8 +97,11 @@
{
var fbgclass = item.PosiDirection == (int)SwapDirectionEnum.收取 ? "swapget" : "swappay";
<tr class="color-bule">
<td class="@fbgclass">@((SwapDirectionEnum)item.PosiDirection)</td>
<td>@(item.PositionType == (int)PositionTypeFlag.Long ? "多头" : "空头")</td>
@if (!hideFloatingIncomeDirection)
{
<td class="@fbgclass">@((SwapDirectionEnum)item.PosiDirection)</td>
}
<td>@FloatingPositionTypeText(item.PosiDirection, item.PositionType)</td>
<td>@item.UnderlyingCode</td>
<td>@(item.PosiNetPrice.OtcFormatMoney(true, 4))</td>
<td>@(item.PosiGrossPrice.OtcFormatMoney(true, 4))</td>
+11 -3
View File
@@ -163,7 +163,14 @@
<td :class="floatPosition.PayDirection==1?'swapget':'swappay'">{{floatPosition.PayDirection==1?"收取":"支付"}}</td>
}
<td>
{{floatPosition.PositionType==1?"多头":"空头"}}
@if (hideFloatingIncomeDirection)
{
<text>{{floatPosition.PayDirection==floatPosition.PositionType?"多头":"空头"}}</text>
}
else
{
<text>{{floatPosition.PositionType==1?"多头":"空头"}}</text>
}
</td>
<td>
{{floatPosition.UnderlyingCode}}
@@ -172,7 +179,7 @@
<!-- 期初标的交割净价: TradingAmountNetAvg 字段名为"成交净价(期末语义)",但此处后端 InitIncome 实际装入的是期初净价(position.PosiNetNoFeePrice),值是期初值 -->
<td v-if="deal.StructureType!='普通收益互换'">{{priceFormat(floatPosition.TradingAmountNetAvg > 0 ? floatPosition.TradingAmountNetAvg : floatPosition.PosiNetPrice)}}</td>
<td>
<vue-swap-price-input v-model="floatPosition.TradingAmountAvg" v-bind:format="getDeliveryPriceInputFormat()" v-on:input="changeUnderlyingPrice" style="width:107px;"></vue-swap-price-input>
<vue-swap-price-input class="swap-delivery-price-input-income" v-model="floatPosition.TradingAmountAvg" v-bind:format="getDeliveryPriceInputFormat()" v-on:input="changeUnderlyingPrice"></vue-swap-price-input>
<a href="javascript:void(0)" v-on:click="refreshUnderlyingPrice()">
<span title="使用系统标的价格" class="glyphicon glyphicon-refresh"></span>
</a>
@@ -180,7 +187,8 @@
<td>{{formatQuantity(floatPosition.Quantity)}}</td>
<td>
<vue-number-input v-model="floatPosition.TradingFee" v-bind:format="inputFormatInterestAmount" v-on:input="changeTradingFee"></vue-number-input>
<div class="bubble-box" style="margin-left:6px;">我方{{floatPosition.PayDirection==1?"支付":"收取"}}交易费用</div>
@* <div class="bubble-box" style="margin-left:6px;">我方{{floatPosition.PayDirection==1?"支付":"收取"}}交易费用</div> *@
<div class="bubble-box" style="margin-left:6px;">我方收取交易费用</div>
</td>
<td> <vue-number-input v-model="floatPosition.DividendIn" v-bind:format="inputFormatDividend" v-on:input="changeTradingFee"></vue-number-input></td>
<td style="font-size:18px;">{{formatAmount(floatPosition.FloatPnlSum)}}</td>
+16 -8
View File
@@ -6,11 +6,11 @@
bool isShowReCheckClose = ViewBag.IsShowReCheckClose;
bool hideFloatingIncomeDirection = PS.Config.ErpElement.SwapFloatingIncomeReceiveOnlyMode;
}
@section CSS{
@section CSS {
<link rel="stylesheet" href="~/Style/Css/unwindSwapTrade.css?v=@(HtmlUtil.JsVersion)" />
}
@section JS
{
{
<script>
var model = @Json.Serialize(Model);
model.StartDate = model.StartDate ? model.StartDate.substr(0, 10) : "";
@@ -84,11 +84,11 @@
</div>
<div class="form-group col-md-3">
<label class="formlabel">平仓日期</label>
<vue-datepicker :mindate="minStartDate" :holiday="1" v-model="deal.UnwindDate" v-on:input="setUnwindDate" />
<vue-datepicker :maxdate="maxUnwindDate" :mindate="minStartDate" :holiday="1" v-model="deal.UnwindDate" v-on:input="setUnwindDate" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">支付日期</label>
<vue-datepicker :mindate="minStartDate" :holiday="1" v-model="deal.PayDate" />
<vue-datepicker :mindate="minStartDate" :holiday="1" v-model="deal.PayDate" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">年化天数</label>
@@ -202,14 +202,21 @@
<td :class="floatPosition.PayDirection==1?'swapget':'swappay'">{{floatPosition.PayDirection==1?"收取":"支付"}}</td>
}
<td>
{{floatPosition.PositionType==1?"多头":"空头"}}
@if (hideFloatingIncomeDirection)
{
<text>{{floatPosition.PayDirection==floatPosition.PositionType?"多头":"空头"}}</text>
}
else
{
<text>{{floatPosition.PositionType==1?"多头":"空头"}}</text>
}
</td>
<td>
{{floatPosition.UnderlyingCode}}
</td>
<td>{{priceFormat(floatPosition.PosiGrossPrice)}}</td>
<td>
<vue-swap-price-input v-model="floatPosition.TradingAmountAvg" v-bind:format="getDeliveryPriceInputFormat()" v-on:input="changeUnderlyingPrice" style="width:107px;"></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="changeUnderlyingPrice"></vue-swap-price-input>
<a href="javascript:void(0)" v-on:click="refreshUnderlyingPrice()">
<span title="使用系统标的价格" class="glyphicon glyphicon-refresh"></span>
</a>
@@ -217,10 +224,11 @@
<td>{{formatQuantity(deal.CloseQty)}}</td>
<td>
<vue-number-input v-model="floatPosition.TradingFee" v-bind:format="inputFormatCloseAmount" v-on:input="changeTradingFee"></vue-number-input>
<div class="bubble-box">我方{{floatPosition.PayDirection==1?"支付":"收取"}}交易费用</div>
@* <div class="bubble-box">我方{{floatPosition.PayDirection==1?"支付":"收取"}}交易费用</div> *@
<div class="bubble-box">我方收取交易费用</div>
</td>
<td>
<vue-number-input v-model="floatPosition.TradingFeePending" v-bind:format="inputFormatEqvNotional" disabled></vue-number-input>
<vue-number-input v-model="floatPosition.TradingFeePending" v-bind:format="inputFormatEqvNotional" disabled></vue-number-input>
</td>
<td>{{formatAmount(floatPosition.DividendIn)}}</td>
<td style="font-size:18px;">{{formatAmount(floatPosition.FloatPnlSum)}}</td>
+2 -1
View File
@@ -507,7 +507,8 @@
</td>
<td>
<vue-number-input v-model="item.PosiTradingFeePending" v-bind:format="inputFormatTradeSinglePriceFixed2"></vue-number-input>
<div class="bubble-box">我方{{item.PosiDirection==1?"支付":"收取"}}交易费用</div>
@* <div class="bubble-box">我方{{item.PosiDirection==1?"支付":"收取"}}交易费用</div> *@
<div class="bubble-box">我方收取交易费用</div>
</td>
</tr>
</tbody>
+13 -4
View File
@@ -31,6 +31,15 @@
var realPositions = trade.swap_positions.Where(x => x.PosiDirection > 0 && !x.IsInitial).ToList();
var sr = trade.trade_extend.ExtendObj.SettlementRules;
bool hideFloatingIncomeDirection = PS.Config.ErpElement.SwapFloatingIncomeReceiveOnlyMode;
string FloatingPositionTypeText(int posiDirection, int positionType)
{
if (hideFloatingIncomeDirection)
{
return posiDirection == positionType ? "多头" : "空头";
}
return positionType == (int)PositionTypeFlag.Long ? "多头" : "空头";
}
string SwapPriceData(decimal? value) => value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
string SwapCommonData(object value) => value == null ? string.Empty : Convert.ToString(value, CultureInfo.InvariantCulture);
}
@@ -408,7 +417,7 @@
{
<td class="@bgclass" style="width:116px !important;">@((SwapDirectionEnum)item.PosiDirection)</td>
}
<td>@(item.PositionType == (int)PositionTypeFlag.Long ? "多头" : "空头")</td>
<td>@FloatingPositionTypeText(item.PosiDirection, item.PositionType)</td>
<td>@item.UnderlyingCode</td>
<td>
<span class="js-swap-price" data-value="@SwapPriceData(item.PosiGrossPrice * multiplier)" data-instrument-type="@item.UnderlyingInstrumentType" data-field="grossPrice"></span>
@@ -633,7 +642,7 @@
</td>
}
<td>
@(item.PositionType == (int)PositionTypeFlag.Long ? "多头" : "空头")
@FloatingPositionTypeText(item.PosiDirection, item.PositionType)
</td>
<td>@item.UnderlyingCode</td>
<td>
@@ -844,7 +853,7 @@
{
<td class="@fbgclass" style="width:116px !important;">@((SwapDirectionEnum)closeFloat.PayDirection)</td>
}
<td>@(closeFloat.PositionType == (int)PositionTypeFlag.Long ? "多头" : "空头")</td>
<td>@FloatingPositionTypeText(closeFloat.PayDirection, closeFloat.PositionType)</td>
<td>@closeFloat.UnderlyingCode</td>
@if (isBond)
{
@@ -1046,7 +1055,7 @@
{
<td class="@fbgclass" style="width:116px !important;">@((SwapDirectionEnum)closeFloat.PayDirection)</td>
}
<td>@(closeFloat.PositionType == (int)PositionTypeFlag.Long ? "多头" : "空头")</td>
<td>@FloatingPositionTypeText(closeFloat.PayDirection, closeFloat.PositionType)</td>
<td>@closeFloat.UnderlyingCode</td>
@if (isBond)
{
@@ -0,0 +1,47 @@
const fs = require('fs');
const path = require('path');
function read(relativePath) {
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
}
function floatingPositionTypeText(posiDirection, positionType) {
return posiDirection === positionType ? '多头' : '空头';
}
describe('收益互换浮动端多空方向展示', () => {
test.each([
[1, 1, '多头'],
[1, 2, '空头'],
[2, 1, '空头'],
[2, 2, '多头']
])('收支方向=%i,多空方向=%i时展示%s', (posiDirection, positionType, expected) => {
expect(floatingPositionTypeText(posiDirection, positionType)).toBe(expected);
});
test('查看交易的初始持仓、实时持仓、平仓和互换记录均使用计算后的方向', () => {
const source = read('Views/SwapTrade2/TradeView.cshtml');
expect(source).toContain('FloatingPositionTypeText(item.PosiDirection, item.PositionType)');
expect(source.match(/FloatingPositionTypeText\(item\.PosiDirection, item\.PositionType\)/g)).toHaveLength(2);
expect(source.match(/FloatingPositionTypeText\(closeFloat\.PayDirection, closeFloat\.PositionType\)/g)).toHaveLength(2);
});
test.each([
'Views/SwapTrade2/SwapUnwind.cshtml',
'Views/SwapTrade2/SwapIncome.cshtml'
])('%s按收支方向和多空方向计算展示值', relativePath => {
const source = read(relativePath);
expect(source).toContain('floatPosition.PayDirection==floatPosition.PositionType?"多头":"空头"');
expect(source).toContain('floatPosition.PositionType==1?"多头":"空头"');
});
test('平仓详情隐藏浮动收支方向并计算多空展示值', () => {
const source = read('Views/SwapTrade2/CloseDetial.cshtml');
expect(source).toContain('bool hideFloatingIncomeDirection = PS.Config.ErpElement.SwapFloatingIncomeReceiveOnlyMode;');
expect(source).toContain('FloatingPositionTypeText(item.PosiDirection, item.PositionType)');
expect(source).toMatch(/@if \(!hideFloatingIncomeDirection\)[\s\S]*<td>收支方向<\/td>/);
});
});
+17
View File
@@ -48,6 +48,11 @@ describe('回归守卫:曾出 bug 的纯函数', () => {
expectClose(SwapCalc.calcStockEqvNotional(10.005, 100), 1000.5, '10.005×100=1000.50');
});
test('calcStockEqvNotional 保留 16 位数量的十进制乘积', () => {
expect(SwapCalc.calcStockEqvNotional('9999999999999999.99', '1.02')).toBe('10199999999999999.99');
expect(SwapCalc.calcStockEqvNotional('1.02', '9999999999999999.99', '100')).toBe('1019999999999999998.98');
});
test('getPriceScale 债券=0.01 非债券=1', () => {
expect(SwapCalc.getPriceScale(100)).toBe(0.01);
expect(SwapCalc.getPriceScale(1)).toBe(1);
@@ -303,6 +308,18 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准',
expectClose(r.MarkClosePnl, 5400000, 'income MarkClosePnl按数量计算');
expectClose(r.FloatPnlSum, 5355000, 'income FloatPnlSum包含分红');
});
test('收取空头价格上涨应为亏损', () => {
const r = SwapCalc.calcIncome({
multiplier: 100, posiGrossPrice: 1.01654321, tradingAmountAvg: 101.754321,
positionQty: 50000000, contractSize: 1,
closeNotionalValue: 50827160.5, closeQty: 0,
payDirection: 1, positionType: 2,
tradingFee: '0', tradingFeePending: '0', dividendIn: '-90400'
});
expectClose(r.MarkClosePnl, -50000, '收取空头价格上涨=盯市亏损');
expectClose(r.FloatPnlSum, -140400, '盯市亏损加分红');
});
});
// ============================================================================
@@ -85,6 +85,7 @@ describe('swap price precision common wiring', () => {
});
expect(helper.getCommonPrecision('quantity', 'OtherRate')).toBe(2);
expect(helper.getCommonInputFormat('quantity', { append: '' }, 'Fund').precision).toBe(4);
expect(helper.getCommonInputFormat('quantity', { append: '' }, 'Fund').stringMode).toBe(true);
expect(helper.getCommonInputFormat('quantity', { append: '' }, 'Bond').precision).toBe(0);
expect(helper.getCommonInputFormat('quantity', { append: '' }, 'OtherRate').integerDigits).toBe(16);
@@ -134,6 +135,13 @@ describe('swap price precision common wiring', () => {
expect(helper.getCommonInputFormat('quantity', { append: '' }).grouping).toBe(false);
});
test('quantity display can trim trailing zeros without changing configured precision', () => {
const helper = loadHelper({ common: { quantity: { precision: 8, grouping: true } } });
expect(helper.formatCommon('quantity', '10000.12345600', { trimTailZeros: true }))
.toBe('10,000.123456');
expect(helper.formatCommon('quantity', '10000.12345600')).toBe('10,000.12345600');
});
test('missing or invalid asset quantity precision falls back to common quantity precision', () => {
const helper = loadHelper({
common: { quantity: { precision: 3, grouping: true } },
+32 -1
View File
@@ -44,7 +44,14 @@ function loadUnwindHelpers() {
vueNumberInput() { return {}; }
},
swapPricePrecision: {
createVueInputComponent() { return {}; }
createVueInputComponent() { return {}; },
getCommonInputFormat() { return {}; },
normalizeCommon(type, value) { return value; },
formatCommon(type, value) { return value; },
getInputFormat(type, field, fallback) { return fallback; },
roundForSubmit(value) { return value; },
shiftDecimal(value) { return value; },
format(value) { return value; }
},
tradeHelper: { IsBond() { return false; } },
main: {
@@ -94,6 +101,30 @@ describe('unwindSwapTrade 基础费率计算', () => {
});
});
describe('事件日期与平仓日期双向同步', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'wwwroot', 'Scripts', 'app', 'swaptrade', 'unwindSwapTrade.js'),
'utf8'
);
const viewSource = fs.readFileSync(
path.join(__dirname, '..', 'Views', 'SwapTrade2', 'SwapUnwind.cshtml'),
'utf8'
);
test('事件日期变更时同步平仓日期并触发利息重算', () => {
expect(source).toMatch(/setValueDate\(e\)[\s\S]*deal\.UnwindDate\s*=\s*e[\s\S]*getInterestList\(\)/);
});
test('平仓日期可选,变更时同步事件日期', () => {
expect(viewSource).toMatch(/vue-datepicker[^>]*v-model="deal\.UnwindDate"[^>]*v-on:input="setUnwindDate"/);
expect(source).toMatch(/setUnwindDate\(e\)[\s\S]*this\.setValueDate\(e\)/);
});
test('预览利息请求使用事件日期作为计算日期', () => {
expect(source).toMatch(/valueDate:\s*thisObj\.deal\.ValueDate[\s\S]*unwindDate:\s*thisObj\.deal\.ValueDate/);
});
});
describe('base-rate pending trading fee', () => {
const { swapPosiFeeCalc, consPosiFeeType } = loadUnwindHelpers();
@@ -197,6 +197,7 @@ const vue = new Vue({
calcFloatClosePnl() {//计算浮动端平仓盈亏
var thisObj = this;
let floatRatio = thisObj.floatPosition.PayDirection == 1 ? 1 : -1;
let longRatio = thisObj.floatPosition.PositionType == 1 ? 1 : -1;
let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee);
let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending);
let DividendIn = thisObj.floatPosition.DividendIn == "" ? 0 : parseFloat(thisObj.floatPosition.DividendIn ?? 0);
@@ -204,7 +205,7 @@ const vue = new Vue({
// 债券全价是单位价格,价差盈亏应按持仓数量×合约乘数计算;
// CloseNotionalValue 是期初全价折算后的名义本金,直接乘价差会重复包含期初价格。
let positionAmount = parseFloat(thisObj.floatPosition.Quantity) * parseFloat(thisObj.floatPosition.ContractSize || 1);
thisObj.floatPosition.MarkClosePnl = positionAmount * (deliveryPrice - thisObj.initPosiGrossPrice) * floatRatio;
thisObj.floatPosition.MarkClosePnl = positionAmount * (deliveryPrice - thisObj.initPosiGrossPrice) * floatRatio * longRatio;
thisObj.floatPosition.MarkClosePnl = formatSwapAmount(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红
// 守卫: 浮动盈亏合计必须保留 2 位小数 → 对应历史 bug 3c5f25a5(原代码缺精度保留)
// 数值由 swapCalc.calcFloatPnlSum 计算, 此处 .toFixed(2) 仅保留字符串类型以兼容下游
@@ -2,7 +2,7 @@
* swapCalc.js 互换结算/平仓纯计算函数 C# FrontendCalcReference 对齐
* ============================================================================
* 设计要点
* - Vue / otcformat / jQuery / lodash 依赖全部为纯函数便于 jest 直接 import
* - Vue / otcformat / jQuery / lodash 依赖复用 swapPricePrecision 的字符串十进制运算
* - 浏览器挂到 window.SwapCalc需在 incomeSwapTrade.js / swapTradeEdit.js 之前加载
* - Node module.exportsUMD 包装 fe-tests/*.test.js 使用
* - 公式与 YLErpDAL/Helpers/FrontendCalcReference.cs 保持一致是前后端同一份金标准
@@ -20,11 +20,11 @@
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
module.exports = factory(require('./swapPricePrecisionHelper.js'));
} else {
root.SwapCalc = factory();
root.SwapCalc = factory(root.swapPricePrecision);
}
})(typeof self !== 'undefined' ? self : this, function () {
})(typeof self !== 'undefined' ? self : this, function (swapPricePrecision) {
'use strict';
// 四舍五入(远离零),对齐 C# MidpointRounding.AwayFromZero
@@ -68,9 +68,17 @@
}
// 名义本金 = 期初全价 × 因子,保留 2 位(EQD-6090
// factor 在前端 = 数量 × 乘数(national
function calcStockEqvNotional(posiGrossPrice, factor) {
return roundHalfAwayFromZero(posiGrossPrice * factor, 2);
// factor 在前端 = 数量 × 乘数(national;传入乘数时避免数量先被 JS Number 相乘。
function calcStockEqvNotional(posiGrossPrice, quantity, contractSize) {
var factor = contractSize === undefined
? quantity
: swapPricePrecision.multiplyDecimal(quantity, contractSize);
var product = factor === null ? null : swapPricePrecision.multiplyDecimal(posiGrossPrice, factor);
if (product === null) return 0;
var rounded = swapPricePrecision.roundDecimal(product, 2);
return typeof posiGrossPrice === 'string' || typeof quantity === 'string' || typeof contractSize === 'string'
? rounded
: Number(rounded);
}
// 平仓名义本金 = 平仓比例 × 剩余持仓名义本金(PosiNotionalValue
@@ -171,6 +179,7 @@
var entryPrice = input.posiGrossPrice;
var scale = input.multiplier === 100 ? 0.01 : 1;
var floatRatio = input.payDirection === 1 ? 1 : -1;
var longRatio = input.positionType === 1 ? 1 : -1;
var tradingFee = parseOrZero(input.tradingFee);
var tradingFeePending = parseOrZero(input.tradingFeePending);
@@ -179,7 +188,7 @@
var contractSize = input.contractSize === undefined || input.contractSize === null
? 1 : Number(input.contractSize);
var markClosePnl = roundHalfAwayFromZero(
input.positionQty * contractSize * (input.tradingAmountAvg * scale - entryPrice) * floatRatio, 2);
input.positionQty * contractSize * (input.tradingAmountAvg * scale - entryPrice) * floatRatio * longRatio, 2);
var floatPnlSum = roundHalfAwayFromZero(markClosePnl + tradingFee + tradingFeePending + dividendIn, 2);
@@ -2,56 +2,56 @@ var swapPricePrecision = (function (global) {
const defaults = Object.freeze({
common: {
amount: { precision: 2, grouping: true },
quantity: { integerDigits: 16, precision: 2, grouping: true },
quantity: { integerDigits: 8, precision: 2, grouping: true },
rate: { precision: 4 }
},
Stock: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
StockIndex: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
StockIF: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
CommodityFutures: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
CommoditySpot: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
NewOtcStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
HKStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
HKStockIndex: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
Fund: { integerDigits: 7, precision: 4, quantityPrecision: 4, quantityIntegerDigits: 12 },
Stock: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 8 },
StockIndex: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 8 },
StockIF: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
CommodityFutures: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
CommoditySpot: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
NewOtcStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
HKStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
HKStockIndex: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
Fund: { integerDigits: 7, precision: 4, quantityPrecision: 4, quantityIntegerDigits: 8 },
Bond: {
quantityPrecision: 0, quantityIntegerDigits: 16,
quantityPrecision: 0, quantityIntegerDigits: 12,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
quantityPrecision: 0, quantityIntegerDigits: 12,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
CreditBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
quantityPrecision: 0, quantityIntegerDigits: 12,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
OtherBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
quantityPrecision: 0, quantityIntegerDigits: 12,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
OtherFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
GoldFutures: { quantityIntegerDigits: 12 },
GoldSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
OtherSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadStock: { integerDigits: 8, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadStockIndex: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
ExRate: { integerDigits: 2, precision: 8, quantityPrecision: 8, quantityIntegerDigits: 16 },
Shibor: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
FixingRepoRate: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
RateYield: {integerDigits: 6, precision: 8, quantityPrecision: 2, quantityIntegerDigits: 12},
BondIndex: {integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12},
TBFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
OtherFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
GoldFutures: { integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
GoldSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
OtherSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
AbroadFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
AbroadSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
AbroadStock: { integerDigits: 8, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 8 },
AbroadStockIndex: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
ExRate: { integerDigits: 2, precision: 8, quantityPrecision: 4, quantityIntegerDigits: 8 },
Shibor: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
FixingRepoRate: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
RateYield: {integerDigits: 6, precision: 8, quantityPrecision: 2, quantityIntegerDigits: 8},
BondIndex: {integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8},
});
@@ -122,6 +122,33 @@ var swapPricePrecision = (function (global) {
return normalizeDecimal((negative ? '-' : '') + integerPart + (roundedDecimal ? '.' + roundedDecimal : ''));
}
function multiplyDecimal(left, right) {
const normalizedLeft = normalizeDecimal(left);
const normalizedRight = normalizeDecimal(right);
if (normalizedLeft === null || normalizedRight === null) return null;
const leftNegative = normalizedLeft.charAt(0) === '-';
const rightNegative = normalizedRight.charAt(0) === '-';
const leftParts = (leftNegative ? normalizedLeft.substring(1) : normalizedLeft).split('.');
const rightParts = (rightNegative ? normalizedRight.substring(1) : normalizedRight).split('.');
const leftDigits = leftParts.join('');
const rightDigits = rightParts.join('');
const result = Array(leftDigits.length + rightDigits.length).fill(0);
for (let leftIndex = leftDigits.length - 1; leftIndex >= 0; leftIndex--) {
for (let rightIndex = rightDigits.length - 1; rightIndex >= 0; rightIndex--) {
const index = leftIndex + rightIndex + 1;
const product = (leftDigits.charCodeAt(leftIndex) - 48) * (rightDigits.charCodeAt(rightIndex) - 48) + result[index];
result[index] = product % 10;
result[index - 1] += Math.floor(product / 10);
}
}
const product = result.join('').replace(/^0+/, '') || '0';
const decimalPlaces = (leftParts[1] || '').length + (rightParts[1] || '').length;
return shiftDecimal((leftNegative !== rightNegative ? '-' : '') + product, -decimalPlaces);
}
function normalizeRule(rule) {
if (!rule || typeof rule !== 'object') return null;
const integerDigits = Number(rule.integerDigits);
@@ -200,8 +227,8 @@ var swapPricePrecision = (function (global) {
if (rule.grouping === undefined) rule.grouping = fallback.grouping;
if (rule.integerDigits === undefined) rule.integerDigits = fallback.integerDigits;
if (kind === 'quantity') {
rule.precision = getQuantityPrecision(instrumentType, rule.precision);
rule.integerDigits = getQuantityIntegerDigits(instrumentType, rule.integerDigits);
rule.precision = Math.min(8, getQuantityPrecision(instrumentType, rule.precision));
rule.integerDigits = Math.min(16, getQuantityIntegerDigits(instrumentType, rule.integerDigits));
}
return rule;
}
@@ -217,7 +244,10 @@ var swapPricePrecision = (function (global) {
const formatted = formatFixed(displayValue, rule.precision);
if (!formatted) return '';
const grouping = options && options.grouping !== undefined ? !!options.grouping : rule.grouping;
const text = grouping ? groupDecimal(formatted) : formatted;
const trimmed = options && options.trimTailZeros === true
? formatted.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '')
: formatted;
const text = grouping ? groupDecimal(trimmed) : trimmed;
return kind === 'rate' ? text + '%' : text;
}
@@ -362,6 +392,7 @@ var swapPricePrecision = (function (global) {
grouping: inputOptions.grouping === undefined ? !!rule.grouping : !!inputOptions.grouping,
trimTailZeros: false
});
if (kind === 'quantity') result.stringMode = true;
if (rule.integerDigits !== undefined) result.integerDigits = rule.integerDigits;
return result;
},
@@ -374,6 +405,7 @@ var swapPricePrecision = (function (global) {
return rule ? Object.assign({}, options, rule) : Object.assign({}, options);
},
format: format,
multiplyDecimal: multiplyDecimal,
roundDecimal: roundDecimal,
roundForSubmit: function (value, instrumentType, field, storagePrecisionOffset) {
const rule = getRule(instrumentType, field);
@@ -384,4 +416,6 @@ var swapPricePrecision = (function (global) {
shiftDecimal: shiftDecimal,
createVueInputComponent: createVueInputComponent
});
}(window));
}(typeof window !== 'undefined' ? window : globalThis));
if (typeof module === 'object' && module.exports) module.exports = swapPricePrecision;
@@ -651,10 +651,9 @@ const vue = new Vue({
if (calcPrice) {
this.getSpotPrice(payItem.UnderlyingCode, this.trade.StartDate, payItem);
}
var national = payItem.PosiQuantity * payItem.ContractSize;
// 守卫: 名义本金必须 round 到 2 位 → 对应历史 bug f873239a(缺 _.round); 外置到 swapCalc.calcStockEqvNotional
var deliveryPrice = this.roundStoragePrice(payItem, payItem.PosiGrossPrice, 'grossPrice');
var stockEqvNotional = SwapCalc.calcStockEqvNotional(deliveryPrice, national);//名义本金=期初价格*数量*乘数
var stockEqvNotional = SwapCalc.calcStockEqvNotional(deliveryPrice, payItem.PosiQuantity, payItem.ContractSize);//名义本金=期初价格*数量*乘数
this.trade.StockEqvNotional = swapPricePrecision.normalizeCommon('amount', stockEqvNotional);
payItem.PosiNotionalValue = this.trade.StockEqvNotional;
this.refreshPayTradingFeesByUnit();
@@ -14,7 +14,8 @@ function formatSwapCommonElements() {
this.textContent = swapPricePrecision.formatCommon(
this.dataset.kind,
this.dataset.value,
this.dataset.instrumentType);
this.dataset.instrumentType,
this.dataset.kind === 'quantity' ? { trimTailZeros: true } : undefined);
});
}
@@ -78,7 +78,7 @@ const vue = new Vue({
created() {
this.multiplier = this.deal.StructureType == '普通债券类收益互换' ? 100 : 1;
this.initDeal();
this.setUnwindDate();
this.setValueDate(this.deal.ValueDate);
},
methods: {
formatAmount(value) {
@@ -168,19 +168,9 @@ const vue = new Vue({
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
});
},
setValueDate(e) {//修改平仓日期
setValueDate(e) {//修改事件日期,并同步平仓日期
if (e) {
this.deal.ValueDate = e;
}
//if (!isUseApproval) {
// this.getInterestList();
// this.refreshUnderlyingPrice();
//} else {
// this.dataFormat();
//}
},
setUnwindDate(e) {//修改平仓日期
if (e) {
this.deal.UnwindDate = e;
this.floatPosition.UnwindDate = e;
}
@@ -191,6 +181,9 @@ const vue = new Vue({
this.dataFormat();
}
},
setUnwindDate(e) {//修改平仓日期,并同步事件日期
this.setValueDate(e);
},
changeCloseMethod() {//修改平仓类型
if (this.deal.CloseMethod == 1) {
this.deal.ClosePercent = this.oriClosePercent;
@@ -372,7 +365,7 @@ const vue = new Vue({
getInterestList() {//根据平仓日期获取利息腿信息
var thisObj = this;
// closePercent 按"占期初(original)"语义(A)传给后端,由 GetUnwindInterestList 转为"占剩余(B)"计算
var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2, notionalValue: thisObj.deal.NotionalValue, posiNotionalValue: thisObj.deal.PosiNotionalValue }
var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.ValueDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2, notionalValue: thisObj.deal.NotionalValue, posiNotionalValue: thisObj.deal.PosiNotionalValue }
main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) {
thisObj.interestList = resp.obj.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
@@ -422,10 +415,7 @@ const vue = new Vue({
return;
}
}
if (thisObj.deal.ValueDate > thisObj.deal.UnwindDate) {
main.message("事件日期不能大于平仓日期");
return;
}
thisObj.deal.UnwindDate = thisObj.deal.ValueDate;
let reqObj = _.cloneDeep(thisObj.deal);
let marginCloneList = _.cloneDeep(thisObj.marginList);
reqObj.FlowEvents = _.cloneDeep(thisObj.interestList);
+50 -6
View File
@@ -287,6 +287,7 @@
let val = _el.value;
if (val && val !== _options.append) {
_options.append && _options.append !== '%' && _options.append !== '‱' && (val = val.replace(new RegExp(_options.append + "$"), ''));
if (_options.stringMode) return val.replaceAll(",", "");
return FastVue.parseNumber(val, _options.percent);
}
return '';
@@ -306,6 +307,19 @@
function setValue(value) {
if (value || value === 0) {
if (_options.stringMode && typeof value === 'string') {
let text = value.trim().replaceAll(",", "");
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) {
_el.value = '';
return;
}
const negative = text.charAt(0) === '-';
text = text.replace(/^[+-]/, '');
const parts = text.split('.');
if (_options.grouping) parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
_el.value = (negative ? '-' : '') + parts.join('.') + _options.append;
return;
}
let oval = parseFloat(value) || 0;
if (_options.append === '%' || _options.percent == true) {
oval *= 100;
@@ -367,6 +381,20 @@
return result;
}
function normalizeStringValue(value) {
let text = String(value === null || value === undefined ? '' : value).trim().replaceAll(',', '');
if (!text) return '';
const negative = text.charAt(0) === '-';
text = text.replace(/^[+-]/, '');
if (!/^\d*(?:\.\d*)?$/.test(text)) return '';
let parts = text.split('.');
let integer = parts[0] || '0';
if (_options.integerDigits) integer = integer.substring(0, _options.integerDigits);
const hasDot = parts.length > 1 && _options.precision > 0;
const decimal = hasDot ? parts[1].substring(0, _options.precision) : '';
return (negative ? '-' : '') + integer + (hasDot ? '.' + decimal : '');
}
function __keyHandle(event) {
if (!event) return false;
@@ -479,6 +507,7 @@
let f = '';
this.value = limitIntegerDigits(this.value);
if (this.value && this.value !== _options.append) {
if (_options.stringMode) return setValue(normalizeStringValue(this.value));
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
else if (_options.append === '‱') f /= 10000;
@@ -488,6 +517,10 @@
if (_chnInput >= 0) {
__onChineseInput.call(this, _chnInput);
}
if (_options.stringMode) {
this.value = normalizeStringValue(this.value);
return;
}
this.value = limitIntegerDigits(this.value);
if (!_options.append || !this.value) return;
let appended = true;
@@ -508,9 +541,15 @@
function __change() {
let f = '';
if (this.value && this.value !== _options.append) {
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
else if (_options.append === '‱') f /= 10000;
if (_options.stringMode) {
f = normalizeStringValue(this.value);
setValue(f);
}
else {
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
else if (_options.append === '‱') f /= 10000;
}
}
let isEnter = !!this._enterFired;
@@ -558,7 +597,7 @@
return {
props: {
value: {
type: Number,
type: [Number, String],
default: ''
},
format: {
@@ -597,8 +636,9 @@
methods: {
onchange(value, text, isEnter) {
this.init = false;
this.$emit('input', this.ret_type === 1 ? text : value);
if (isEnter) this.$emit('enter', this.ret_type === 1 ? text : value);
const result = this.ret_type === 1 ? text : value;
this.$emit('input', result);
if (isEnter) this.$emit('enter', result);
}
},
watch: {
@@ -608,6 +648,10 @@
case 'number':
val1 = val || 0; break;
case 'string':
if (this.format && this.format.stringMode) {
val1 = val;
break;
}
if (val) {
val1 = parseFloat(val) || 0;
if (val1 && (val.trimEnd().endsWith('%') || (this.format.percent && !this.init))) {
+50 -6
View File
@@ -14344,6 +14344,7 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
let val = _el.value;
if (val && val !== _options.append) {
_options.append && _options.append !== '%' && _options.append !== '‱' && (val = val.replace(new RegExp(_options.append + "$"), ''));
if (_options.stringMode) return val.replaceAll(",", "");
return FastVue.parseNumber(val, _options.percent);
}
return '';
@@ -14363,6 +14364,19 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
function setValue(value) {
if (value || value === 0) {
if (_options.stringMode && typeof value === 'string') {
let text = value.trim().replaceAll(",", "");
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) {
_el.value = '';
return;
}
const negative = text.charAt(0) === '-';
text = text.replace(/^[+-]/, '');
const parts = text.split('.');
if (_options.grouping) parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
_el.value = (negative ? '-' : '') + parts.join('.') + _options.append;
return;
}
let oval = parseFloat(value) || 0;
if (_options.append === '%' || _options.percent == true) {
oval *= 100;
@@ -14424,6 +14438,20 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
return result;
}
function normalizeStringValue(value) {
let text = String(value === null || value === undefined ? '' : value).trim().replaceAll(',', '');
if (!text) return '';
const negative = text.charAt(0) === '-';
text = text.replace(/^[+-]/, '');
if (!/^\d*(?:\.\d*)?$/.test(text)) return '';
let parts = text.split('.');
let integer = parts[0] || '0';
if (_options.integerDigits) integer = integer.substring(0, _options.integerDigits);
const hasDot = parts.length > 1 && _options.precision > 0;
const decimal = hasDot ? parts[1].substring(0, _options.precision) : '';
return (negative ? '-' : '') + integer + (hasDot ? '.' + decimal : '');
}
function __keyHandle(event) {
if (!event) return false;
@@ -14536,6 +14564,7 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
let f = '';
this.value = limitIntegerDigits(this.value);
if (this.value && this.value !== _options.append) {
if (_options.stringMode) return setValue(normalizeStringValue(this.value));
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
else if (_options.append === '‱') f /= 10000;
@@ -14545,6 +14574,10 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
if (_chnInput >= 0) {
__onChineseInput.call(this, _chnInput);
}
if (_options.stringMode) {
this.value = normalizeStringValue(this.value);
return;
}
this.value = limitIntegerDigits(this.value);
if (!_options.append || !this.value) return;
let appended = true;
@@ -14565,9 +14598,15 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
function __change() {
let f = '';
if (this.value && this.value !== _options.append) {
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
else if (_options.append === '‱') f /= 10000;
if (_options.stringMode) {
f = normalizeStringValue(this.value);
setValue(f);
}
else {
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
else if (_options.append === '‱') f /= 10000;
}
}
let isEnter = !!this._enterFired;
@@ -14615,7 +14654,7 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
return {
props: {
value: {
type: Number,
type: [Number, String],
default: ''
},
format: {
@@ -14654,8 +14693,9 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
methods: {
onchange(value, text, isEnter) {
this.init = false;
this.$emit('input', this.ret_type === 1 ? text : value);
if (isEnter) this.$emit('enter', this.ret_type === 1 ? text : value);
const result = this.ret_type === 1 ? text : value;
this.$emit('input', result);
if (isEnter) this.$emit('enter', result);
}
},
watch: {
@@ -14665,6 +14705,10 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
case 'number':
val1 = val || 0; break;
case 'string':
if (this.format && this.format.stringMode) {
val1 = val;
break;
}
if (val) {
val1 = parseFloat(val) || 0;
if (val1 && (val.trimEnd().endsWith('%') || (this.format.percent && !this.init))) {
+171 -12
View File
@@ -61,6 +61,16 @@
<li class="toctree-l3"><a class="reference internal" href="#id5">2.2 差分法</a></li>
</ul>
</li>
<li class="toctree-l2"><a class="reference internal" href="#id6">3.新增希腊字母指标(利率敏感性指标)</a><ul>
<li class="toctree-l3"><a class="reference internal" href="#delta-r">3.1 Delta_r</a></li>
<li class="toctree-l3"><a class="reference internal" href="#delta-r-1bp">3.2 Delta_r(1BP)</a></li>
<li class="toctree-l3"><a class="reference internal" href="#dv01">3.3 DV01</a></li>
<li class="toctree-l3"><a class="reference internal" href="#gamma-r">3.4 Gamma_r</a></li>
<li class="toctree-l3"><a class="reference internal" href="#gamma-r-1bp">3.5 Gamma_r(1BP)</a></li>
<li class="toctree-l3"><a class="reference internal" href="#vega-r">3.6 Vega_r</a></li>
<li class="toctree-l3"><a class="reference internal" href="#vega-r-1bp">3.7 Vega_r(1BP)</a></li>
</ul>
</li>
</ul>
</li>
<li class="toctree-l1"><a class="reference internal" href="model.html">附录2:期权定价方法概述</a></li>
@@ -221,8 +231,8 @@
<tr class="row-even"><td><p>Delta</p></td>
<td><p>设当前标的价格为 <span class="math notranslate nohighlight">\(S\)</span>,设 <span class="math notranslate nohighlight">\(S_\mathrm{u}=S+S_\mathrm{\bigtriangleup},S_\mathrm{d}=S-S_\mathrm{\bigtriangleup}\)</span></p>
<p><span class="math notranslate nohighlight">\(S_\mathrm{u}和S_\mathrm{d}\)</span> 代入定价公式, 得到期权价值 <span class="math notranslate nohighlight">\(V_\mathrm{u}和V_\mathrm{d}\)</span>; 则</p>
<p><span class="math notranslate nohighlight">\(Delta=(V_\mathrm{u}-V_\mathrm{d})/(S*0.01)\)</span></p>
<p>系统中 <span class="math notranslate nohighlight">\(S_\mathrm{\bigtriangleup}\)</span><span class="math notranslate nohighlight">\(0.005*S\)</span></p>
<p><span class="math notranslate nohighlight">\(Delta=(V_\mathrm{u}-V_\mathrm{d})/(2\times S_\mathrm{\bigtriangleup})\)</span></p>
<p>系统中 <span class="math notranslate nohighlight">\(S_\mathrm{\bigtriangleup}\)</span><span class="math notranslate nohighlight">\(0.0001\)</span>(绝对偏移,对应1bp</p>
</td>
</tr>
<tr class="row-odd"><td><p>DeltaCash</p></td>
@@ -232,7 +242,7 @@
<td><p>设当前标的价格为 <span class="math notranslate nohighlight">\(S\)</span>, 期权理论价值为 <span class="math notranslate nohighlight">\(V\)</span>; 设 <span class="math notranslate nohighlight">\(S_\mathrm{u}=S+S_\mathrm{\bigtriangleup},S_\mathrm{d}=S-S_\mathrm{\bigtriangleup}\)</span></p>
<p><span class="math notranslate nohighlight">\(S_\mathrm{u}和S_\mathrm{d}\)</span> 代入定价公式,得到期权价值 <span class="math notranslate nohighlight">\(V_\mathrm{u}和V_\mathrm{d}\)</span>; 则</p>
<p><span class="math notranslate nohighlight">\(Gamma=(V_\mathrm{u}+V_\mathrm{d}-2V)/S_\mathrm{\bigtriangleup}^2\)</span></p>
<p>系统中 <span class="math notranslate nohighlight">\(S_\mathrm{\bigtriangleup}\)</span><span class="math notranslate nohighlight">\(0.01*S\)</span></p>
<p>系统中 <span class="math notranslate nohighlight">\(S_\mathrm{\bigtriangleup}\)</span><span class="math notranslate nohighlight">\(0.0001\)</span>(绝对偏移,对应1bp</p>
</td>
</tr>
<tr class="row-odd"><td><p>GammaCash</p></td>
@@ -242,7 +252,7 @@
<td><p>设当前波动率为𝜎, 设 <span class="math notranslate nohighlight">\(𝜎_\mathrm{u}=𝜎+𝜎_\mathrm{\bigtriangleup},𝜎_\mathrm{d}=𝜎-𝜎_\mathrm{\bigtriangleup}\)</span></p>
<p><span class="math notranslate nohighlight">\(𝜎_\mathrm{u}和𝜎_\mathrm{d}\)</span> 代入定价公式,得到期权价值 <span class="math notranslate nohighlight">\(V_\mathrm{u}和V_\mathrm{d}\)</span>; 则</p>
<p><span class="math notranslate nohighlight">\(Vega=V_\mathrm{u}-V_\mathrm{d}\)</span></p>
<p>系统中 <span class="math notranslate nohighlight">\(𝜎_\mathrm{\bigtriangleup}\)</span> 为 0.5%</p>
<p>系统中 <span class="math notranslate nohighlight">\(𝜎_\mathrm{\bigtriangleup}\)</span> 为 0.0001(绝对偏移,对应1bp</p>
</td>
</tr>
<tr class="row-odd"><td><p>Theta</p></td>
@@ -255,21 +265,170 @@
</td>
</tr>
<tr class="row-even"><td><p>Rho</p></td>
<td><p>设无风险利率为 <span class="math notranslate nohighlight">\(r\)</span>,期权理论价值为 <span class="math notranslate nohighlight">\(V\)</span>; 设 <span class="math notranslate nohighlight">\(r_\mathrm{u}=r+10bp\)</span>;</p>
<td><p>设无风险利率为 <span class="math notranslate nohighlight">\(r\)</span>, 期权理论价值为 <span class="math notranslate nohighlight">\(V\)</span>; 设 <span class="math notranslate nohighlight">\(r_\mathrm{u}=r+1bp\)</span>;</p>
<p><span class="math notranslate nohighlight">\(r_\mathrm{u}\)</span> 代入定价公式,得到期权价值 <span class="math notranslate nohighlight">\(V_\mathrm{u}\)</span>;则</p>
<p><span class="math notranslate nohighlight">\(Rho=(V_\mathrm{u}-V)/10bp\)</span></p>
<p><span class="math notranslate nohighlight">\(Rho = V_\mathrm{u} - V\)</span></p>
</td>
</tr>
<tr class="row-odd"><td><p>亚式期权Delta</p></td>
<td><p>设当前已观察到的标的平均价格为 <span class="math notranslate nohighlight">\(S_\mathrm{A}\)</span>, 设 <span class="math notranslate nohighlight">\(S_\mathrm{A_\mathrm{u}}=S_\mathrm{A}+S_\mathrm{A_\mathrm{\bigtriangleup}}\)</span></p>
<p><span class="math notranslate nohighlight">\(S_\mathrm{A_\mathrm{u}}\)</span> 代入定价公式,得到期权价值 <span class="math notranslate nohighlight">\(V_\mathrm{u}\)</span>; 则</p>
<p><span class="math notranslate nohighlight">\(Delta=(V_\mathrm{u}-V)/S_\mathrm{A_\mathrm{\bigtriangleup}}\)</span></p>
<p>系统中 <span class="math notranslate nohighlight">\(S_\mathrm{A_\mathrm{\bigtriangleup}}\)</span><span class="math notranslate nohighlight">\(0.01*S_\mathrm{A}\)</span></p>
</td>
<tr class="row-odd"><td><p>RhoQ</p></td>
<td><p>计算逻辑同 Rho</p></td>
</tr>
</tbody>
</table>
</section>
<section id="id6">
<span id="new-greeks"></span><h2>3.新增希腊字母指标(利率敏感性指标)<a class="headerlink" href="#id6" title="Permalink to this headline"></a></h2>
<div class="admonition note">
<p class="admonition-title">Note</p>
<p>以下指标<strong>仅适用于</strong>利率收益率、利率债/信用债/其他债券、债券指数、国债期货四类资产。其他资产类别<strong>不计算</strong>这些指标,数值留空。</p>
</div>
<section id="delta-r">
<h3>3.1 Delta_r — 期权对利率的敏感性<a class="headerlink" href="#delta-r" title="Permalink to this headline"></a></h3>
<table class="docutils align-default">
<colgroup>
<col style="width: 20%" />
<col style="width: 15%" />
<col style="width: 65%" />
</colgroup>
<thead>
<tr class="row-odd"><th class="head"><p>资产类型</p></th>
<th class="head"><p>代表标的</p></th>
<th class="head"><p>公式</p></th>
</tr>
</thead>
<tbody>
<tr class="row-even"><td><p>利率收益率</p></td>
<td><p>GB10</p></td>
<td><p><span class="math notranslate nohighlight">\(Delta\_r = -Delta\)</span></p></td>
</tr>
<tr class="row-odd"><td><p>利率债/信用债/其他债券</p></td>
<td><p>210210.IB</p></td>
<td><p><span class="math notranslate nohighlight">\(Delta\_r = Delta \times P \times D\)</span></p></td>
</tr>
<tr class="row-even"><td><p>债券指数</p></td>
<td><p>CBA00621.CS</p></td>
<td><p><span class="math notranslate nohighlight">\(Delta\_r = Delta \times P \times D\)</span></p></td>
</tr>
<tr class="row-odd"><td><p>国债期货</p></td>
<td><p>TS2609</p></td>
<td><p><span class="math notranslate nohighlight">\(Delta\_r = Delta \times P \times D / CF\)</span></p></td>
</tr>
</tbody>
</table>
<div class="admonition note">
<p class="admonition-title">Note</p>
<ul class="simple">
<li><p><strong>P</strong>:债券/债券指数在计算日的全价/价格</p></li>
<li><p><strong>D</strong>:债券/债券指数在计算日的修正久期(债券指数使用平均市值法久期)</p></li>
<li><p><strong>CF</strong>:国债期货对应CTD债券的转换因子</p></li>
</ul>
</div>
</section>
<section id="delta-r-1bp">
<h3>3.2 Delta_r(1BP) — 利率变动1BP时Delta_r的变动<a class="headerlink" href="#delta-r-1bp" title="Permalink to this headline"></a></h3>
<p>适用于全部四种资产类型:</p>
<p><span class="math notranslate nohighlight">\(Delta\_r(1BP) = Delta\_r \times 0.0001\)</span></p>
</section>
<section id="dv01">
<h3>3.3 DV01 — 利率变动1BP时期权价值变动(基点价值)<a class="headerlink" href="#dv01" title="Permalink to this headline"></a></h3>
<p>适用于全部四种资产类型,与 Delta_r(1BP) 等价:</p>
<p><span class="math notranslate nohighlight">\(DV01 = Delta\_r(1BP)\)</span></p>
</section>
<section id="gamma-r">
<h3>3.4 Gamma_r — 期权对利率的二阶敏感性<a class="headerlink" href="#gamma-r" title="Permalink to this headline"></a></h3>
<table class="docutils align-default">
<colgroup>
<col style="width: 20%" />
<col style="width: 15%" />
<col style="width: 65%" />
</colgroup>
<thead>
<tr class="row-odd"><th class="head"><p>资产类型</p></th>
<th class="head"><p>代表标的</p></th>
<th class="head"><p>公式</p></th>
</tr>
</thead>
<tbody>
<tr class="row-even"><td><p>利率收益率</p></td>
<td><p>GB10</p></td>
<td><p><span class="math notranslate nohighlight">\(Gamma\_r = Gamma\)</span></p></td>
</tr>
<tr class="row-odd"><td><p>利率债/信用债/其他债券</p></td>
<td><p>210210.IB</p></td>
<td><p><span class="math notranslate nohighlight">\(Gamma\_r = Delta \times P \times C + (P \times D)^2 \times Gamma\)</span></p></td>
</tr>
<tr class="row-even"><td><p>债券指数</p></td>
<td><p>CBA00621.CS</p></td>
<td><p><span class="math notranslate nohighlight">\(Gamma\_r = Delta \times P \times C + (P \times D)^2 \times Gamma\)</span></p></td>
</tr>
<tr class="row-odd"><td><p>国债期货</p></td>
<td><p>TS2609</p></td>
<td><p><span class="math notranslate nohighlight">\(Gamma\_r = Delta \times P \times C / CF + (P \times D / CF)^2 \times Gamma\)</span></p></td>
</tr>
</tbody>
</table>
<div class="admonition note">
<p class="admonition-title">Note</p>
<ul class="simple">
<li><p><strong>C</strong>:债券/债券指数的凸性(债券指数使用平均市值法凸性)</p></li>
<li><p>P、D、CF 含义同 3.1 节</p></li>
</ul>
</div>
</section>
<section id="gamma-r-1bp">
<h3>3.5 Gamma_r(1BP) — 利率变动1BP时Gamma_r的变动<a class="headerlink" href="#gamma-r-1bp" title="Permalink to this headline"></a></h3>
<p>适用于全部四种资产类型:</p>
<p><span class="math notranslate nohighlight">\(Gamma\_r(1BP) = Gamma\_r \times 0.0001^2\)</span></p>
</section>
<section id="vega-r">
<h3>3.6 Vega_r — 期权对利率波动率的敏感性<a class="headerlink" href="#vega-r" title="Permalink to this headline"></a></h3>
<table class="docutils align-default">
<colgroup>
<col style="width: 20%" />
<col style="width: 15%" />
<col style="width: 65%" />
</colgroup>
<thead>
<tr class="row-odd"><th class="head"><p>资产类型</p></th>
<th class="head"><p>代表标的</p></th>
<th class="head"><p>公式</p></th>
</tr>
</thead>
<tbody>
<tr class="row-even"><td><p>利率收益率</p></td>
<td><p>GB10</p></td>
<td><p><span class="math notranslate nohighlight">\(Vega\_r = Vega\)</span></p></td>
</tr>
<tr class="row-odd"><td><p>利率债/信用债/其他债券</p></td>
<td><p>210210.IB</p></td>
<td><p><span class="math notranslate nohighlight">\(Vega\_r = Vega \times D \times ytm\)</span></p></td>
</tr>
<tr class="row-even"><td><p>债券指数</p></td>
<td><p>CBA00621.CS</p></td>
<td><p><span class="math notranslate nohighlight">\(Vega\_r = Vega \times D \times ytm\)</span></p></td>
</tr>
<tr class="row-odd"><td><p>国债期货</p></td>
<td><p>TS2609</p></td>
<td><p><span class="math notranslate nohighlight">\(Vega\_r = Vega \times D \times ytm\)</span>D和ytm使用对应CTD券的值)</p></td>
</tr>
</tbody>
</table>
<div class="admonition note">
<p class="admonition-title">Note</p>
<ul class="simple">
<li><p><strong>ytm</strong>:债券/债券指数在计算日的到期收益率(债券指数使用平均市值法到期收益率)</p></li>
<li><p>D 含义同 3.1 节(修正久期)</p></li>
</ul>
</div>
</section>
<section id="vega-r-1bp">
<h3>3.7 Vega_r(1BP) — 利率波动率变动1BP时Vega_r的变动<a class="headerlink" href="#vega-r-1bp" title="Permalink to this headline"></a></h3>
<p>适用于全部四种资产类型:</p>
<p><span class="math notranslate nohighlight">\(Vega\_r(1BP) = Vega\_r \times 0.0001\)</span></p>
</section>
</section>
</section>
</section>
@@ -97,4 +97,12 @@
}
td input {
width: 100px !important;
}
}
td input.swap-delivery-price-input-unwind {
width: 140px !important;
}
td input.swap-delivery-price-input-income {
width: 140px !important;
}
@@ -8,6 +8,25 @@
---
## ⚠️ 更新状态(2026-08-07
> 本文档最初分析日期为 2026-08-06。**第七章、第八章 8.1 关于 income 页 `longRatio` 的论断已被代码修复采纳,原文描述已过时**,阅读时请注意:
>
> | 文档原文论断 | 当前代码状态 | 修复提交 |
> |--------------|------------|----------|
> | income 页漏乘 `longRatio`(多空方向),空头会算出相反符号 | ✅ **已修复**:前端 `incomeSwapTrade.js:200,208`、后端 `FrontendCalcReference.CalcIncome:100,108` 均已补 `longRatio` | `d78d1f48`2026-08-07 |
> | income 空头分支无测试覆盖 | ✅ **已补**`收取空头_价格上涨_应为亏损` + jest 对应用例 | `d78d1f48` |
> | `FrontendCalcReference.CalcIncome` 注释写"无 longRatio" | ✅ **已更新**为含 longRatio 的公式 | `d78d1f48` |
>
> **仍有效的部分**(治理路线主体,代码尚未动):
> - 第三章 A 节:两页 `MarkClosePnl` 仍**各自内联**`incomeSwapTrade.js:208` / `unwindSwapTrade.js:306`),未接入共享的 `swapCalc.calcMarkClosePnl`——"单一可信源已有却不采纳"仍在。
> - 第三章 B 节:硬编码精度魔法数字**全部仍在**(`SwapFlowService:116-118` Round(4)、`SwapDealService:1561` F10、`SwapTradeAutoService:458/460` Round(10))。
> - 第三章 C 节 / 第四章 / 第五章:swapCalc 未接入生产、精度集中化、分阶段治理路线——**仍是有效的后续路线**。
>
> 阅读建议:第三~六章按"仍有效的治理路线"读;第七、八章按"历史论证记录"读(结论已被采纳落地)。
---
## 一、这类 BUG 的本质(统一定义)
最新修复的"分红收益误显 -36,160",根因不是某一个 if 写错,而是一种**结构性缺陷**:
@@ -162,22 +181,32 @@
---
## 六、一句话结论
## 六、一句话结论 【longRatio 部分已于 d78d1f48 修复】
> 最新修复根治了"分红预览"这一条链路的分散计算;但**同类结构依然存在**——
> 前端平仓页/互换页的 `MarkClosePnl` 都用全价,真正的差异是**互换页income)漏乘 `longRatio`(多空方向)**
> 对空头会算出相反符号并直接落库(后端对 income 不重算、原样存前端值);共享的 `swapCalc.calcMarkClosePnl` 两个页面都没用;
> 后端仍有**费用 4 位 vs 金额 2 位**等硬编码精度错配。
> 最新修复根治了"分红预览"这一条链路的分散计算;~~互换页(income)漏乘 `longRatio`~~
> **该问题已于 `d78d1f48` 修复**income 现已含 longRatio,空头符号与平仓一致)。
>
> **当前仍残留的同类结构**
> - 前端平仓页/互换页的 `MarkClosePnl` 仍**各自内联**,未接入共享的 `swapCalc.calcMarkClosePnl`(单一可信源已有却不采纳);
> - 后端仍有**费用 4 位 vs 金额 2 位**等硬编码精度错配(`SwapFlowService` Round(4)、`SwapDealService` F10 等)。
>
> 治理的关键不是再打补丁,而是**把已建好的 `swapCalc` 单一可信源 + parity 守护真正接入生产**,
> 并按上述 5 个阶段低风险推进。
---
## 七、如何确认"income 错 / unwind 对"(而非相反)+ 改动安全性
## 七、如何确认"income 错 / unwind 对"(而非相反)+ 改动安全性 【✅ 已由 d78d1f48 修复,本章留作论证记录】
> 这一章回答一个关键质疑:两页口径不同,凭什么断定是 income 漏了 `longRatio`、而不是 unwind 多算了?
> 以及:给 income 补 `longRatio` 会不会把正确逻辑改坏、或造成"双重翻转"?
> ✅ **更新(2026-08-07**:本章的论断已被团队采纳并落地。`d78d1f48` 按本章论证给 income 补了 `longRatio`
> (前端 `incomeSwapTrade.js:208`、后端 `FrontendCalcReference.CalcIncome:108`),并补了空头测试。
> 本章原"待业务背书的 Working Hypothesis"已成为既成事实,保留作论证记录与防回归参考。
> ⚠️ **状态说明(2026-08-07,原文)**:本章关于"income 漏 longRatio → 错 / unwind 对"的论断,原是**待业务背书的 Working Hypothesis**。
> 后经代码铁证(7.2/7.3)直接采纳修复,见上方更新。
### 7.1 两个方向乘子是**独立轴**(这是避免误判的前提)
- `floatRatio = PayDirection==1(收取) ? +1 : -1` —— 跟随**收付方向**`FrontendCalcReference.cs:35`
@@ -228,3 +257,103 @@
- **历史脏数据**:过去"空头+互换"事件已用错符号落库;修复后新事件正确,跨时间对比会出现不连续。需决定:回溯校正(改 `MarkClosePnl`+重算 `SwapRealizedPnl`+对账 `AddClientCash` 历史)还是标注留痕。
- **改动范围**:严格限定在 income 页 `:207``CalcIncome:107``longRatio`,切忌顺手改 `floatRatio` 或其他页面。
4. **前置确认**:建议先让业务/量化签字"income 的 `MarkClosePnl` 应含多空方向(与平仓一致)",再动手——因为结论虽由代码+基线铁证支撑,但涉及客户现金流,需业务背书。
---
## 八、当前行为实录与待确认项(2026-08-07)【8.1 longRatio 部分已由 d78d1f48 修复】
> 本章**只记录"代码现在实际怎么做"**(可验证事实),并明确列出"哪些还无法判定谁对"。
> 与第七章(论断层)的区别:第七章给出了"income 错 / unwind 对"的论证,但该论断**尚未取得业务/量化背书**,
> 且涉及"价差基准是否应在收益结算后滚动"这一更基础的产品定义。因此把它们在本章降格为"待确认假设",
> 先如实记录现状,避免过早定性。
> 文档定位:**当前不修改代码、不加测试,仅留痕**。正确性待业务/量化逐项确认后再回填。
>
> ✅ **更新(2026-08-07**8.1 关于"income 不含 longRatio"的实录**已过时**——`d78d1f48` 已补 longRatio。
> 8.2(价差基准不滚动)、8.4(待确认问题清单中除 longRatio 外的价差基准/多次结算问题)**仍待业务确认**。
### 8.1 收益结算(income)页当前行为实录 【✅ longRatio 部分已修复】
- **界面入口**:交易详情页头部操作区「**收益结算**」按钮(权限 `交易管理_收益互换`),打开 `/swaptrade2/SwapIncome/`
`SwapIncome.cshtml` + `incomeSwapTrade.js`)。与「**平仓**」按钮(`SwapUnwind.cshtml`)外观相似,
但**没有平仓比例、没有事件日期**——本质是"期间结算、头寸保留",而非关闭头寸。
- **价差损益 `MarkClosePnl` 当前公式(代码事实)**
- `incomeSwapTrade.js:104` `initPosiGrossPrice = this.floatPosition.PosiGrossPrice`
- `:199-200` `floatRatio = PayDirection==1 ? 1 : -1``longRatio = PositionType==1 ? 1 : -1`
- `:208` `MarkClosePnl = positionAmount × (deliveryPrice initPosiGrossPrice) × floatRatio × longRatio`
`positionAmount = PositionQty × ContractSize``deliveryPrice = getStorageDeliveryPrice()`
- 后端同口径 `FrontendCalcReference.CalcIncome:100,108`**已含 `longRatio`**`d78d1f48` 修复)。
- **事实结论**(已更新):income 页当前**已含 `floatRatio × longRatio`**`d78d1f48`),与 unwind/后端口径一致。
原文"只用 floatRatio,未乘 longRatio"的描述**已失效**。
### 8.2 价差基准 `swap_position.PosiGrossPrice` 当前行为实录
- **开仓时**`PosiGrossPrice = TradingAmountAvg`(成交/期初全价,`SwapTradeService.cs:396`),规范名 `EntryDirtyPrice`
`FrontendCalcReference.cs:15/156`)。
- **收益结算后**`UpdateInitalPosition``SwapDealService.cs:2276-2281`)对"互换"分支**只更新费用 `PosiTradingFee`,不改 `PosiGrossPrice`**
`SwapIncome``:2005`)直接落库、**不调用** `UpdateInitalPosition`
- **平仓后**`SaveSwapDealInternal``:2212`)只改 `Quantity/PositionQty`,不改动基准价含义。
- **EOD 日终**`SwapEodPositionService` 操作的是快照表 `eod_swap_position`,其 `PosiGrossPrice` 为成本基准、
`eod.Clone()` 跨日结转(`CopyEodPosition:1688`),每日行情价只写入 `UnderlyingPrice``:1716`),**不覆盖成本基准**。
活表 `swap_position.PosiGrossPrice` 最终由 EOD 回写(`UpdateSwapPosition:62` / `UpdateSwapPositionWithRealTime:85`),
但回写的仍是"成本基准"而非当日行情价。
- **事实结论**`swap_position.PosiGrossPrice` 在持仓生命周期内**恒等于开仓期初价 P0,从不滚动到上一次结算价**。
### 8.3 已确认事实 vs 待确认假设 对照
| 项 | 已确认事实(代码可验证) | 待确认假设(需业务/量化背书) |
|----|--------------------------|------------------------------|
| income 是否含 `longRatio` | ✅ **已含**`d78d1f48` 修复,`incomeSwapTrade.js:208` / `CalcIncome:108` | ~~是否应该含~~ —— 已按第七章论证采纳修复,不再待确认 |
| 空头 income 符号 | ✅ **已修正**`d78d1f48`,空头涨价=亏损,与平仓一致) | ~~是否是错误~~ —— 已确认是 bug 并修复 |
| 价差基准是否滚动 | 当前**不滚动**(恒为 P0) | 收益结算应是"增量(从上次结算价)"还是"绝对(从 P0"?——**产品定义未确认** |
| 多次收益结算重复计入 | 当前若对**同一开放持仓做多次收益结算**,每次都按 (当前价−P0) 计,**首段会被重复计入**(数学推导) | 业务实际是否允许/发生过"同一持仓多次收益结算"?——**需生产数据确认** |
| 下游现金流 | `SwapIncome:2007` `AddClientCash(-SwapRealizedPnl)` 用前端值记账(code 实证) | 若公式口径需改,历史已落库金额是否需追溯校正? |
### 8.4 待确认问题清单(请业务 / 量化 / 产品逐项答复)
1. **空头收益结算的符号约定**:空头 TRS 做期间结算,客户现金流方向应是"空头涨价=亏"(与平仓一致,即含 `longRatio`)还是相反?
请给出业务样例与会计分录。
2. **收益结算的价差口径**:期间结算的 `MarkClosePnl` 应是"本段增量"(结算后把持仓基准滚动到本次结算价)还是"从开仓期初价累计"?
两者在"单次结算后随即平仓"时数值相同,但在**多次结算 / 结算后仍保留持仓**时相差巨大。
3. **是否存在"同一开放持仓多次收益结算"的真实业务场景**?若有,当前"不滚动基准"会导致重复计入,必须修;
若无(每次结算后即关仓),则当前行为无害、仅作防御性加固。
4. **多空与收付是否允许非对角组合**`多头+收取` / `空头+支付`)?若存在,则"收支/多空"两字段非冗余、必须分别存储;
若不存在(vanilla TRS 永远反向),则可视为冗余但当前仍各存各的。
### 8.5 验证脚本(用于把"待确认"转为"已确认"
```sql
-- (a) 是否存在「空头 + 走收益结算/互换路径」且 MarkClosePnl 非 0 的事件
SELECT trade_id, position_id, event_type, position_type, pay_direction,
mark_close_pnl, value_date
FROM swap_event
WHERE position_type = 2
AND event_type IN ('互换','结息') -- 按实际枚举值调整
AND mark_close_pnl <> 0
ORDER BY value_date DESC;
-- (b) 同一持仓是否被多次收益结算(判断是否触发"基准不滚动 → 重复计入")
SELECT position_id, COUNT(*) cnt, MIN(value_date) first_dt, MAX(value_date) last_dt
FROM swap_event
WHERE event_type IN ('互换','结息') -- 按实际枚举值调整
GROUP BY position_id
HAVING COUNT(*) > 1
ORDER BY cnt DESC;
```
- 若 (a) 返回 0 行 → 当前无"空头收益结算"样本,longRatio 不一致问题**未实际触发**;
- 若 (b) 返回 0 行 → 当前业务每次结算后即关仓,"基准不滚动"**无害**
- 若 (b) 有行 → 立即按"结算后把 `swap_position.PosiGrossPrice` 滚动到本次结算价"评估修复。
### 8.6 多轮回归未暴露的原因(实证,非推断)【第1点已由 d78d1f48 补测试填补】
1. **覆盖空洞**`FrontendCalcCharacterizationTest` 的 income 场景 `FC_006~009` **全是 `PositionType=1`(多头)**
唯一空头场景 `FC_005` 是 unwind。income 的空头分支从未被构造。
**已填补**`d78d1f48`):新增 `收取空头_价格上涨_应为亏损` 测试(C#+ jest 对应用例。
2. **校验同源**`ValidateFrontendPnL``SwapDealService.cs:2004`)用 `BuildFrontendValidationDiffs` 以**同一 `CalcIncome`(也无 longRatio**
重算比对,前端错值 == 后端重算 → diff 恒为 0 → 永不告警("预言机与被测代码共享同一 bug"盲区)。
3. **真实数据隐形**golden `dividend_trade_1875/1891.json` 中,空头块为 `swap_position` 持仓行(`MarkClosePnl=0`),非结息事件;
真实样本从未走空头 income。
4. **多头恒等变换**income 漏的是 `longRatio`,而多头 `longRatio=+1` 是恒等变换,故整个多头组合
(≈100% 真实数据 + 100% 回归)下两页数值一致,bug 不可见。
> 注:第 14 点解释的是"longRatio 不一致为何没被抓到"。而 8.2/8.4 的"基准不滚动"问题,
> 即便被测也需"多次收益结算"样本才能触发,现有单笔 income / 单笔 close 测试同样覆盖不到——属另一类盲区。