Merge branch 'master' of https://gitee.glmszq.com/gsty/onederiv/trs into feature/p132_154-yf-new-sso-login
This commit is contained in:
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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=false:CalcDailyCompoundInterest 循环里 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=true:8/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.001,5/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,000(2/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_40:ResolveInterestLegPositions 没用实时剩余本金(看 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 < EventDate → 平仓"未发生",as-of=原始本金
|
||||
/// - settleDate >= 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/9,as-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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
// 例如 0004:5/18 待实现 -118631.261797,加 5/19 新增约 -4648.912760,
|
||||
// 得到最终应结 -123280.174557,按金额两位落为 Excel BN 的 -123280.17。
|
||||
// 上一日终已保存的待实现利息 + 截至平仓日累计利息 - 截至上一日终累计利息
|
||||
// 这样只带走“上一日终以后新增的利息”,同时保留历史部分平仓时因两位金额结算留下的尾差,最终全平一次性结清。
|
||||
InterestAmount = preEodPosition.InterestIncomeSum + amountAtEnd - amountAtPreviousEod;
|
||||
TdInterestAmount = preEodPosition.InterestIncomeSum + tdAmountAtEnd - tdAmountAtPreviousEod;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -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 落库为旧值,传染后续 EOD(GLMS-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:本次事件发生前理论应结的高精度利息。
|
||||
// manualSettledInterestAmount:swap_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,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>/);
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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.exports(UMD 包装),供 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);
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+135
-6
@@ -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 不可见。
|
||||
|
||||
> 注:第 1–4 点解释的是"longRatio 不一致为何没被抓到"。而 8.2/8.4 的"基准不滚动"问题,
|
||||
> 即便被测也需"多次收益结算"样本才能触发,现有单笔 income / 单笔 close 测试同样覆盖不到——属另一类盲区。
|
||||
|
||||
Reference in New Issue
Block a user