diff --git a/Framework/YLErp.Core/ConsGlobal.cs b/Framework/YLErp.Core/ConsGlobal.cs index bdf59a00..049817e7 100644 --- a/Framework/YLErp.Core/ConsGlobal.cs +++ b/Framework/YLErp.Core/ConsGlobal.cs @@ -64,6 +64,10 @@ namespace YLErp public const int PriceRound = 11; /// + /// 互换期初、期末交割价四舍五入保留位数 + /// + public const int SwapDeliveryPriceRound = 9; + /// /// 金额四舍五入保留位数 /// diff --git a/UnitTestProject/Modules/SwapModule/ClearSwapPositionsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/ClearSwapPositionsScenarioTest.cs index cbcc0f5f..7e1fb330 100644 --- a/UnitTestProject/Modules/SwapModule/ClearSwapPositionsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/ClearSwapPositionsScenarioTest.cs @@ -21,7 +21,7 @@ namespace YLErp.Modules.SwapModule #region Stub - private sealed class StubService : SwapEodPositionService + private sealed class StubService : TestableSwapEodPositionService { // 注入的内存数据 public List FlowEvents { get; set; } = new(); @@ -30,7 +30,7 @@ namespace YLErp.Modules.SwapModule public List DeletedRecords { get; } = new(); - public StubService() : base(new OptUserInfo(0, nameof(ClearSwapPositionsScenarioTest), OptUserFrom.UnitTest)) + public StubService() : base(nameof(ClearSwapPositionsScenarioTest)) { } diff --git a/UnitTestProject/Modules/SwapModule/ComposePageScenarioTest.cs b/UnitTestProject/Modules/SwapModule/ComposePageScenarioTest.cs index ccbb7672..020ec6c0 100644 --- a/UnitTestProject/Modules/SwapModule/ComposePageScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/ComposePageScenarioTest.cs @@ -22,13 +22,13 @@ namespace YLErp.Modules.SwapModule #region Stub - private sealed class StubEodService : SwapEodPositionService + private sealed class StubEodService : TestableSwapEodPositionService { - public List CreatedEodPositions { get; } = new(); - public int ClientCashCallCount { get; private set; } - private int _nextId = 1; + // 输出别名(转发到基类捕获属性,保持测试断言不变) + public List CreatedEodPositions => PersistedPositions; + public int ClientCashCallCount => ClientCashCalls.Count; - public StubEodService() : base(new OptUserInfo(0, nameof(ComposePageScenarioTest), OptUserFrom.UnitTest)) + public StubEodService() : base(nameof(ComposePageScenarioTest)) { } @@ -56,13 +56,7 @@ namespace YLErp.Modules.SwapModule protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) { - return new swap_event { id = _nextId++, SwapTradeId = swapTradeId, EventType = eventType, ValueDate = tradeDate, EventData = data }; - } - - protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) - { - ClientCashCallCount++; - return _nextId++; + return new swap_event { id = 1, SwapTradeId = swapTradeId, EventType = eventType, ValueDate = tradeDate, EventData = data }; } protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) @@ -75,17 +69,6 @@ namespace YLErp.Modules.SwapModule // 不做任何事(测试无历史事件需清理) } - protected override void PersistEodSwapPosition(eod_swap_position position) - { - if (position.id == 0) position.id = _nextId++; - CreatedEodPositions.Add(position); - } - - protected override void SaveAllChanges() { } - - protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) - => 1.0; - // override SaveEodPosition:捕获生成的 eod,绕过 UpdateSwapPosition 连库 protected override decimal SaveEodPosition(eod_swap_position newEodPayPosition, trade td, swap_flow_event eventFlow, diff --git a/UnitTestProject/Modules/SwapModule/DealFloatPositionsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealFloatPositionsScenarioTest.cs index 4c509c8f..a0b076f1 100644 --- a/UnitTestProject/Modules/SwapModule/DealFloatPositionsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealFloatPositionsScenarioTest.cs @@ -25,7 +25,7 @@ namespace YLErp.Modules.SwapModule #region Stub - private sealed class StubEodService : SwapEodPositionService + private sealed class StubEodService : TestableSwapEodPositionService { // 可注入的外部数据 public decimal UnderlyingPrice { get; set; } = 1.00m; @@ -34,7 +34,7 @@ namespace YLErp.Modules.SwapModule public decimal TaxRate { get; set; } = 0m; public string UnderlyingCode { get; set; } = "210210.IB"; - public StubEodService() : base(new OptUserInfo(0, nameof(DealFloatPositionsScenarioTest), OptUserFrom.UnitTest)) + public StubEodService() : base(nameof(DealFloatPositionsScenarioTest)) { } @@ -55,11 +55,6 @@ namespace YLErp.Modules.SwapModule return BondPayment; } - protected override void SaveAllChanges() { } - - protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) - => 1.0; - // DealFloatPositions 和子方法都是 protected,通过 public 包装暴露 public List ExecuteDealFloatPositions( List posiList, List realPosiList, diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs index 0542fb5b..dafec3db 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs @@ -26,29 +26,29 @@ namespace YLErp.Modules.SwapModule #region Stub(复用 DealInterestsScenarioTest 的模式) - private sealed class StubEodService : SwapEodPositionService + private sealed class StubEodService : TestableSwapEodPositionService { - public List PersistedPositions { get; } = new(); - private int _nextId = 1; - - public StubEodService() : base(new OptUserInfo(0, nameof(DealInterestsGoldenReplayTest), OptUserFrom.UnitTest)) + public StubEodService() : base(nameof(DealInterestsGoldenReplayTest)) { } - protected override void PersistEodSwapPosition(eod_swap_position position) - { - if (position.id == 0) position.id = _nextId++; - PersistedPositions.Add(position); - } - protected override void SaveAllChanges() { } - protected override double GetCurrencyRate(string q, string s, DateTime d, bool p, CurrencyRateType t) => 1.0; - public void ExecuteSaveEodInterestPosition( eod_swap_position eodPayPosition, swap_position position, trade td, DateTime valueDate, List flowEvents) { SaveEodInterestPosition(eodPayPosition, null, position, td, valueDate, flowEvents); } + + // public 包装:直接调用 protected virtual DealInterests(录制场景2用) + public void ExecuteDealInterestsForRecord( + List interestList, List eodPositions, + DateTime settleDate, trade td, + decimal posiLongNational, decimal grossPrice, decimal orginPv) + { + DealInterests(interestList, eodPositions, new List(), + settleDate, td, new List(), new List(), null, + posiLongNational, 0m, 0m, grossPrice, orginPv); + } } #endregion @@ -189,17 +189,11 @@ namespace YLErp.Modules.SwapModule }; var service = new StubEodService(); - // 通过反射调 DealInterests(copy 分支需要 CalcSwapInterests) - var method = typeof(SwapEodPositionService).GetMethod("DealInterests", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method.Invoke(service, new object[] - { + // 直接调用 protected virtual DealInterests(copy 分支需要 CalcSwapInterests) + service.ExecuteDealInterestsForRecord( new List { position }, new List { preEod }, - new List(), - settleDate, td, new List(), new List(), null, - Principal, 0m, 0m, 1m, Principal - }); + settleDate, td, Principal, 1m, Principal); if (service.PersistedPositions.Count == 0) { diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index 1631b512..52a1eaac 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -40,31 +40,12 @@ namespace YLErp.Modules.SwapModule /// - PersistEodSwapPosition:收集到列表而非写库 /// - GetCurrencyRate:返回 1.0(本币) /// - private sealed class StubEodPositionService : SwapEodPositionService + private sealed class StubEodPositionService : TestableSwapEodPositionService { - public List PersistedPositions { get; } = new(); - - public StubEodPositionService() : base(new OptUserInfo(0, nameof(DealInterestsScenarioTest), OptUserFrom.UnitTest)) + public StubEodPositionService() : base(nameof(DealInterestsScenarioTest)) { } - protected override void PersistEodSwapPosition(eod_swap_position position) - { - // 收集到列表,不写库。如果 id=0 模拟新增。 - if (position.id == 0) position.id = PersistedPositions.Count + 1; - PersistedPositions.Add(position); - } - - protected override void SaveAllChanges() - { - // 不做任何事(内存模式) - } - - protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) - { - return 1.0; // 本币,汇率=1 - } - // override CalcSwapInterests:用真实 SwapDealService 算(固定利率不需 mock 浮动利率) // 生产代码默认实现也是 new SwapDealService(this).GetInterests(...),这里保持一致 // 但 SwapDealService 内部 TryGetFloatRate 会连库——固定利率(FloatRateUnderlyingCode=null)不会触发 @@ -94,21 +75,16 @@ namespace YLErp.Modules.SwapModule return PersistedPositions.LastOrDefault(); } - // public 包装:调用 DealInterests(通过反射,因为参数太多不好包) + // public 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射) public void ExecuteDealInterests( List interestList, List eodPositions, DateTime settleDate, trade td, List flowEvents, decimal posiLongNational, decimal posiShortNational, decimal closeNational, decimal grossPrice, decimal orginPv) { - var method = typeof(SwapEodPositionService).GetMethod("DealInterests", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method.Invoke(this, new object[] - { - interestList, eodPositions, new List(), + DealInterests(interestList, eodPositions, new List(), settleDate, td, flowEvents, new List(), null, - posiLongNational, posiShortNational, closeNational, grossPrice, orginPv - }); + posiLongNational, posiShortNational, closeNational, grossPrice, orginPv); } } diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260105GoldenTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260105GoldenTest.cs index b70cdb0a..80280364 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260105GoldenTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260105GoldenTest.cs @@ -212,10 +212,10 @@ namespace YLErp.Modules.SwapModule #region 回放用 Stub - private sealed class ReplayStubService : SwapEodPositionService + private sealed class ReplayStubService : TestableSwapEodPositionService { private readonly string _underlyingCode; - public ReplayStubService(string underlyingCode) : base(new OptUserInfo(0, "Replay", OptUserFrom.UnitTest)) + public ReplayStubService(string underlyingCode) : base("Replay") { _underlyingCode = underlyingCode; } @@ -238,9 +238,6 @@ namespace YLErp.Modules.SwapModule return 0m; } - protected override void SaveAllChanges() { } - protected override double GetCurrencyRate(string q, string s, DateTime d, bool p, CurrencyRateType t) => 1.0; - public eod_swap_position ExecuteUpdateEodPosition( swap_position swapPosition, eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate, List unwindEvents) diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs new file mode 100644 index 00000000..bb29f270 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs @@ -0,0 +1,316 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// GLMS-20260703-0002 债券TRS 平仓利息差异 - 仅调用系统函数复现 + /// ============================================================================ + /// 客户测试环境这笔债券TRS(截图确认): + /// 成交/持仓名义本金 = 279,486,108.21 + /// 起息日(StartDate=SettleDate) = 2026-07-06,平仓日(UnwindDate) = 2026-07-20 + /// 年化天数 = 365,计息模式 = 算头不算尾("10"),计息天数 = 14 天 + /// 利率 = FR007-1.55%(浮动利率腿),重置频率 = 7 天,interest_rule = 0(当前营业日) + /// 截图 FR007:07-06=1.42%, 07-13=1.425%(重置点取当前营业日) + /// + /// 观察结果: + /// 系统计算的平仓盈亏(利息端)= -13,667.85 + /// 实际应得平仓盈亏(利息端)= -13,668.02 + /// 差异 = 0.17 元 + /// + /// 代码走查结论(SwapDealService): + /// 平仓路径:GetInterests → CalcSwapDealInterest → CalcUnwindInterest + /// → InitSwapDealInterest → CalcDailyCompoundInterest / CalcDailySimpleInterest + /// FR007 取数规则:只在"重置日"(i % interest_rest_days == 0) 取一次 FR007,非重置日沿用上一重置日。 + /// 本例 07-06~07-20 跨 2 个重置周期,取 2 次 FR007: + /// - 第一段(07-06~07-12):取 07-06 当前营业日 FR007 = 1.42% + /// - 第二段(07-13~07-19):取 07-13 当前营业日 FR007 = 1.425% + /// 差异根因:系统配置走 <复利>,导致 07-13 重置日把前 7 天累计利息并入本金, + /// 第二段计息本金降为 279,479,140.20,最终利息绝对值比单利少 0.17 元。 + /// 若按业务口径走 <单利>,则本金全程保持 279,486,108.21,利息 = -13,668.02。 + /// + /// 本测试不再做任何手工计算(手算/验算见同目录 Excel:GLMS20260703_平仓利息验算.xlsx), + /// 只做:构造输入 → 调用系统真实函数 GetInterests → 与截图已知结果断言。 + /// ============================================================================ + [TestClass] + public class GLMS20260703CloseInterestTest + { + #region 内部类:固定利率模拟服务(不触碰 DB,但按日期返回 FR007 截图值) + + private sealed class StubSwapDealService : SwapDealService + { + public StubSwapDealService(OptUserInfo optUser) : base(optUser) { } + + /// 单元测试记录:生产代码每次调用 TryGetFloatRate 的日期与结果。 + public readonly List<(DateTime RequestDate, double Rate)> FloatRateCalls = new(); + + /// + /// 按截图 eod_commodity_future_price.ValueDate 返回 FR007 ReferencePrice(小数)。 + /// 生产环境由 TryGetFloatRate 去行情/DB 取数;单元测试用截图硬编码快照替代。 + /// + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + rate = 0d; + if (underlyingCode != "FR007") return false; + + var fr007 = new Dictionary + { + [new DateTime(2026, 7, 3)] = 0.0143, + [new DateTime(2026, 7, 6)] = 0.0142, + [new DateTime(2026, 7, 7)] = 0.0143, + [new DateTime(2026, 7, 8)] = 0.0143, + [new DateTime(2026, 7, 9)] = 0.0143, + [new DateTime(2026, 7, 10)] = 0.0142, + [new DateTime(2026, 7, 13)] = 0.01425, + [new DateTime(2026, 7, 14)] = 0.0143, + [new DateTime(2026, 7, 15)] = 0.0144, + [new DateTime(2026, 7, 16)] = 0.0144, + [new DateTime(2026, 7, 17)] = 0.0144, + [new DateTime(2026, 7, 20)] = 0.0143, + }; + + if (fr007.TryGetValue(valueDate.Date, out rate)) + { + FloatRateCalls.Add((valueDate.Date, rate)); + return true; + } + + // 若请求日期不在硬编码表(如 interest_rule=-1 调到周末),返回最近有值日的 FR007 + var nearest = fr007.Keys.OrderByDescending(d => d) + .FirstOrDefault(d => d <= valueDate.Date); + if (nearest != default) + { + rate = fr007[nearest]; + FloatRateCalls.Add((valueDate.Date, rate)); + return true; + } + return false; + } + } + + #endregion + + #region 测试常量(来自客户测试环境截图 GLMS-20260703-0002) + + /// 成交/持仓名义本金 + private const decimal Notional = 279486108.21m; + + /// 年化天数 + private const int AnnualDays = 365; + + /// 固定利差 -1.55%(FR007-1.55%) + private const decimal Spread = -0.0155m; + + /// 起息日(td.StartDate = SettleDate,不是成交日) + private static readonly DateTime StartDate = new(2026, 7, 6); + + /// 成交日(td.TradeDate,仅作对照;不参与计息起点) + private static readonly DateTime TradeDate = new(2026, 7, 3); + + /// 平仓日(valueDate / unwindDate) + private static readonly DateTime CloseDate = new(2026, 7, 20); + + /// 系统实际计算的利息绝对值 + private const decimal SystemInterestAmount = 13667.85m; + + /// 实际应得利息绝对值 + private const decimal ActualInterestAmount = 13668.02m; + + #endregion + + private SwapDealService _service; + + [TestInitialize] + public void Init() + { + _service = new StubSwapDealService( + new OptUserInfo(0, nameof(GLMS20260703CloseInterestTest), OptUserFrom.UnitTest)); + } + + #region 测试数据构建器(仅构造输入,不计算利息) + + private static trade CreateTrade() + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", // 算头不算尾 + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, + TradeNumber = "GLMS-20260703-0002", + ClientId = 999998, + TradeType = "债券TRS", + TradeDate = TradeDate, + StartDate = StartDate, + ExerciseDate = CloseDate.AddDays(1), // 必须 > valueDate,否则 InitInterestDate 会多减一天 + TradeStatus = "已平仓", + ValidState = "Valid", + trade_extend = extend + }; + } + + /// + /// 构造债券TRS浮动利率本金腿。FloatRateUnderlyingCode=FR007,利率=FR007-1.55%。 + /// 仅设置输入字段;利息由系统函数 CalcDailyCompoundInterest / CalcDailySimpleInterest 计算。 + /// + private static swap_position CreateBondPosition(InterestTypeEnum interestType, int interestRule = 0) + { + var intervalModels = new List + { + new IntervalModel { Date = CloseDate, Rate = Spread, Settlement = 0 } + }; + return new swap_position + { + id = 1001, + SwapTradeId = 1, + PositionType = (int)PositionTypeFlag.Unknown, + // 债券本金腿:标的期初全价(closePrincipal = posiNotional × closePercent) + InterestDirection = (int)SwapDirectionEnum.支付, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = Spread, + InterestPrincipalFix = Notional, + PosiStartDate = StartDate, + PosiMatuirityDate = CloseDate, + IsInitial = true, + Invalid = false, + InterestType = (int)interestType, // 复利 or 单利 + IsAnnualized = true, + interest_rest_days = 7, // 7天重置 + interest_rule = interestRule, // 0=当前营业日, -1=前一营业日 + FloatRateUnderlyingCode = "FR007", // 真实浮动利率腿 + FloatRate = 0m, + PosiNotionalValue = Notional, + UnderlyingCode = "2500002.IB", + InterestSwapInterval = JsonConvert.SerializeObject(intervalModels) + }; + } + + /// + /// 驱动真实平仓利息路径(settment=false → CalcUnwindInterest → InitSwapDealInterest + /// → CalcDailyCompoundInterest / CalcDailySimpleInterest)。 + /// eodPositions 传空 → 等效"无前日日终快照",日循环从起始日重算。 + /// + private swap_flow_event CalcCloseInterest(InterestTypeEnum interestType, int interestRule = 0) + { + var td = CreateTrade(); + var position = CreateBondPosition(interestType, interestRule); + // 每次计算前清空取数记录,避免同一测试中多次调用互相污染 + ((StubSwapDealService)_service).FloatRateCalls.Clear(); + var interests = _service.GetInterests( + td, td.trade_extend, + CloseDate, CloseDate, // valueDate / unwindDate + new List(), // eodPositions(空) + new List { position }, + Notional, Notional, Notional, Notional, // posiNotional / long / short / closePosiNotional + 1m, // closePercent + (int)SwapEventTypeEnum.平仓, + false, false, 0m, Notional, // tdClose / needPrice / grossPrice / orginPv + false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count); + return interests[0]; + } + + #endregion + + #region 复现用例(仅调用系统函数 + 对照已知结果断言) + + /// + /// [GLMS20260703_REPRO_001] 系统路径:InterestType=复利 + 真实FR007 → 复现系统值 -13,667.85 + /// 与生产代码完全一致:7天重置,重置日取当前营业日 FR007,利息并入本金。 + /// + [TestMethod] + public void Reproduce_SystemValue_13667_85_WithCompoundInterest() + { + var interest = CalcCloseInterest(InterestTypeEnum.复利); + + var amount2 = Math.Round(interest.InterestAmount, 2, MidpointRounding.AwayFromZero); + var closePnl2 = Math.Round(interest.InterestClosePnL, 2, MidpointRounding.AwayFromZero); + + Console.WriteLine("系统路径(复利):"); + Console.WriteLine($" 利息金额(2位显示)={amount2}"); + Console.WriteLine($" 平仓盈亏(利息端,2位)={closePnl2}"); + + // 截图给的是"平仓盈亏(利息端)"的绝对值口径,故比较绝对值。 + Assert.AreEqual(SystemInterestAmount, Math.Abs(amount2), + $"系统复利路径应得到利息绝对值 {SystemInterestAmount},实际 {amount2}"); + Assert.AreEqual(SystemInterestAmount, Math.Abs(closePnl2), + $"支付方向平仓盈亏绝对值应为 {SystemInterestAmount},实际 {closePnl2}"); + } + + /// + /// [GLMS20260703_REPRO_002] 实际口径:InterestType=单利 + 真实FR007 → 复现实际值 -13,668.02 + /// 同一 position、同一 FR007、同一计息天数,仅把 InterestType 改为单利, + /// 利息不并入本金,全程用名义本金 279,486,108.21 计息。 + /// + [TestMethod] + public void Reproduce_ActualValue_13668_02_WithSimpleInterest() + { + var interest = CalcCloseInterest(InterestTypeEnum.单利); + + var amount2 = Math.Round(interest.InterestAmount, 2, MidpointRounding.AwayFromZero); + var closePnl2 = Math.Round(interest.InterestClosePnL, 2, MidpointRounding.AwayFromZero); + + Console.WriteLine("实际口径(单利):"); + Console.WriteLine($" 利息金额(2位显示)={amount2}"); + Console.WriteLine($" 平仓盈亏(利息端,2位)={closePnl2}"); + + Assert.AreEqual(ActualInterestAmount, Math.Abs(amount2), + $"单利路径应得到利息绝对值 {ActualInterestAmount},实际 {amount2}"); + Assert.AreEqual(ActualInterestAmount, Math.Abs(closePnl2), + $"支付方向平仓盈亏绝对值应为 {ActualInterestAmount},实际 {closePnl2}"); + } + + /// + /// [GLMS20260703_REPRO_003] 差异定位:0.17 元 = 复利 vs 单利 + /// 同一笔交易、同一 FR007 取值、同一计息天数,唯一区别是 InterestType, + /// 系统(复利)比实际(单利)少 0.17 元。 + /// + [TestMethod] + public void PrecisionGap_Is_0_17_ComplexVsSimple() + { + var compound = CalcCloseInterest(InterestTypeEnum.复利).InterestAmount; + var simple = CalcCloseInterest(InterestTypeEnum.单利).InterestAmount; + + // 两者都是负数(支付方向),取绝对值差异 + var gap = Math.Round(Math.Abs(simple) - Math.Abs(compound), 2, MidpointRounding.AwayFromZero); + + Console.WriteLine($"复利 |利息|={Math.Abs(compound):F11}"); + Console.WriteLine($"单利 |利息|={Math.Abs(simple):F11}"); + Console.WriteLine($"差异(2位)={gap}"); + + Assert.AreEqual(0.17m, gap, "单利与复利的利息绝对值差异应为 0.17 元"); + } + + /// + /// [GLMS20260703_REPRO_004] 验证真实函数确实在 07-06、07-13 两个重置点取了 FR007 + /// (不手算利息,仅检查系统函数实际发起了哪几次取数)。 + /// + [TestMethod] + public void Trace_FloatRate_Taken_Dates() + { + var fe = CalcCloseInterest(InterestTypeEnum.复利, interestRule: 0); + var calls = ((StubSwapDealService)_service).FloatRateCalls; + + Console.WriteLine("TryGetFloatRate 实际调用记录(按调用顺序):"); + foreach (var (date, rate) in calls) + { + Console.WriteLine($" 请求日期={date:yyyy-MM-dd} 返回 FR007={rate:P4}"); + } + + Assert.IsTrue(calls.Any(c => c.RequestDate == new DateTime(2026, 7, 6)), "应取 07-06 的 FR007"); + Assert.IsTrue(calls.Any(c => c.RequestDate == new DateTime(2026, 7, 13)), "应取 07-13 的 FR007"); + Assert.AreEqual(SystemInterestAmount, Math.Abs(Math.Round(fe.InterestAmount, 2, MidpointRounding.AwayFromZero)), + "interest_rule=0 复利应复现系统值 -13,667.85"); + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/GreeksBumpCalculatorTests.cs b/UnitTestProject/Modules/SwapModule/GreeksBumpCalculatorTests.cs new file mode 100644 index 00000000..ee3e91d6 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GreeksBumpCalculatorTests.cs @@ -0,0 +1,114 @@ +using YLErp.BLL.Calculation.V2; + +namespace UnitTestProject.Modules.SwapModule +{ + /// + /// GreeksBumpCalculator 纯数学契约测试(先写,锁定 ε / 差分逻辑)。 + /// 不碰 DB / QDP:用已知解析导数的函数(f(x)=x²,d/dx=2x,d²/dx²=2)验证中心差分本身正确。 + /// 业务侧集成测试(真实定价路径 + 真实 FR007)见后续 ValueCalculator 薄接入落地后再补。 + /// + [TestClass] + public class GreeksBumpCalculatorTests + { + /// + /// 一阶中心差分对 f(x)=x² 在 x=3 应精确等于 2x=6。 + /// 二次多项式的中心差分对任意 ε 都精确(截断误差为 0),因此用相对步长 0.05% 仍得 6。 + /// + [TestMethod] + public void CentralDelta_of_x2_equals_2x() + { + var calc = new GreeksBumpCalculator(); + Func f = x => x * x; // d/dx = 2x + var delta = calc.Delta(f, 3m, BumpSpec.Relative(0.0005m)); + Assert.AreEqual(6m, delta); + } + + /// + /// 二阶中心差分对 f(x)=x² 在 x=3 应精确等于 2(二阶导数恒为 2)。 + /// 验证 Gamma 的二阶差分算子正确。 + /// + [TestMethod] + public void CentralGamma_of_x2_equals_2() + { + var calc = new GreeksBumpCalculator(); + Func f = x => x * x; // d²/dx² = 2 + var gamma = calc.Gamma(f, 3m, BumpSpec.Relative(0.0005m)); + Assert.AreEqual(2m, gamma); + } + + /// + /// 1BP 变体(Dollar Greek)对 f(x)=x² 在 x=3、bump 1bp 应等于 (3.0001)² - 3²。 + /// 验证 BumpPv1Bp 直接前向 bump 的 PV 差逻辑。 + /// + [TestMethod] + public void BumpPv1Bp_of_x2_forward() + { + var calc = new GreeksBumpCalculator(); + Func f = x => x * x; + var bumped = calc.BumpPv1Bp(f, 3m); + Assert.AreEqual(3.0001m * 3.0001m - 3m * 3m, bumped); + } + + /// + /// 线性函数 f(x)=2x+1:一阶中心差分应精确等于斜率 2,二阶中心差分应精确为 0(线性无曲率)。 + /// 验证算子对"一次/零次"函数的精确性(二次之外另一种精确情形)。 + /// + [TestMethod] + public void CentralDelta_of_linear_is_slope() + { + var calc = new GreeksBumpCalculator(); + Func f = x => 2m * x + 1m; // d/dx = 2, d²/dx² = 0 + Assert.AreEqual(2m, calc.Delta(f, 5m, BumpSpec.Relative(0.0005m))); + Assert.AreEqual(0m, calc.Gamma(f, 5m, BumpSpec.Relative(0.0005m))); + } + + /// + /// 三次函数 f(x)=x³:中心差分对任意 ε 不精确(仅二次及以下精确),结果逼近解析导 3x² 但有 O(ε²) 误差。 + /// 验证 Layer B 集成测试必须带容差,不能 Assert.AreEqual 死等精确值。x=2 解析导=12。 + /// + [TestMethod] + public void CentralDelta_of_cubic_is_approx_analytic_with_tolerance() + { + var calc = new GreeksBumpCalculator(); + Func f = x => x * x * x; // d/dx = 3x² = 12 at x=2 + var delta = calc.Delta(f, 2m, BumpSpec.Relative(0.0005m)); + Assert.IsTrue(Math.Abs(delta - 12m) < 0.001m, $"中心差分三次函数应有 O(ε²) 误差,实际={delta}"); + } + + /// + /// 扭结点测试:看涨 payoff f(x)=max(x-3,0) 在行权价 x=3 处不可导。 + /// 中心差分跨扭结取到左右斜率的平均 (0+1)/2 = 0.5,说明对障碍/美式等扭结结构必须用单边差分。 + /// + [TestMethod] + public void CentralDelta_at_kink_is_average_of_one_sided() + { + var calc = new GreeksBumpCalculator(); + Func f = x => x > 3m ? x - 3m : 0m; // call payoff K=3 + var delta = calc.Delta(f, 3m, BumpSpec.Relative(0.0005m)); + Assert.AreEqual(0.5m, delta); // 中心差分给出左右斜率平均,非真实单边 Greek + } + + /// + /// 边界 x=0(相对步长会触到 Floor):f(x)=x² 在 0 处对称,Δ 应精确为 0,证明 Floor 兜底不产生噪声。 + /// + [TestMethod] + public void CentralDelta_at_zero_uses_floor_but_stays_correct() + { + var calc = new GreeksBumpCalculator(); + Func f = x => x * x; + Assert.AreEqual(0m, calc.Delta(f, 0m, BumpSpec.Relative(0.0005m))); + } + + /// + /// 利率量级小值 x=0.0001(1bp 量级)用相对步长:Resolve 返回 max(0.0001·0.0005, 1e-8)=5e-8, + /// 远大于 Floor,证明利率类小量级不会被舍入噪声吞掉。f=x² 在 0.0001 解析导=2·0.0001=0.0002。 + /// + [TestMethod] + public void CentralDelta_of_tiny_rate_like_x_is_stable() + { + var calc = new GreeksBumpCalculator(); + Func f = x => x * x; + Assert.AreEqual(0.0002m, calc.Delta(f, 0.0001m, BumpSpec.Relative(0.0005m))); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/GreeksRiskFactorTests.cs b/UnitTestProject/Modules/SwapModule/GreeksRiskFactorTests.cs new file mode 100644 index 00000000..7640d37a --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GreeksRiskFactorTests.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.BLL.Calculation.V2; +using YLErp.BLL.Calculation.V2.Parameter; + +namespace UnitTestProject.Modules.SwapModule +{ + /// + /// RiskFactor(② 风险因子抽象)+ ParameterBase.Clone 的纯单测(Layer A,无 DB / QDP)。 + /// 用假 reprice 委托验证:克隆类型保持、字典深拷、三类因子落点正确、波动率因子施于非期权参数抛异常, + /// 以及经 BuildPvFunction 喂入 GreeksBumpCalculator 后 DeltaR / Delta / Vega / BumpPv1Bp 数值正确(线性函数精确)。 + /// + [TestClass] + public class GreeksRiskFactorTests + { + // —— Clone 行为与类型保持 —— + + [TestMethod] + public void Clone_PreservesRuntimeType_And_CopiesOptionFields() + { + var src = new VanillaOptionParameter + { + Volatility = 0.2, + RiskFreeRate = 0.03, + SpotPrices = new Dictionary { { "X", 100 } } + }; + ParameterBase clone = src.Clone(); + // MemberwiseClone 必须保留运行时类型,否则 ValueCalculator 内 parameter as VanillaOptionParameter 会 cast 成 null + Assert.IsInstanceOfType(clone, typeof(VanillaOptionParameter)); + Assert.AreEqual(0.2, ((BaseOptionParameter)clone).Volatility); + Assert.AreEqual(100, clone.SpotPrices["X"]); + } + + [TestMethod] + public void Clone_DeepCopiesSpotPrices_So_Bump_Does_Not_Pollute_Original() + { + var src = new ParameterBase + { + SpotPrices = new Dictionary { { "X", 100 } } + }; + ParameterBase clone = src.Clone(); + clone.SpotPrices["X"] = 999; // 改克隆体 + Assert.AreEqual(100, src.SpotPrices["X"], "原参数的 SpotPrices 不应被克隆体的 bump 污染"); + } + + // —— 三类因子 ApplyTo 落点正确 —— + + [TestMethod] + public void RateFactor_ApplyTo_Sets_RiskFreeRate() + { + var p = new ParameterBase(); + RiskFactor.Rate("CNY-OIS-2Y").ApplyTo(p, 0.025m); + Assert.AreEqual(0.025, p.RiskFreeRate); + } + + [TestMethod] + public void PriceFactor_ApplyTo_Sets_SpotPrices_By_TargetKey() + { + var p = new ParameterBase(); + RiskFactor.Price("000300.SH").ApplyTo(p, 3500m); + Assert.AreEqual(3500, p.SpotPrices["000300.SH"]); + } + + [TestMethod] + public void VolFactor_ApplyTo_On_OptionParameter_Sets_Volatility() + { + var p = new BaseOptionParameter(); + RiskFactor.Volatility("X").ApplyTo(p, 0.18m); + Assert.AreEqual(0.18, p.Volatility); + } + + [TestMethod] + [ExpectedException(typeof(System.InvalidOperationException))] + public void VolFactor_ApplyTo_On_PlainParameter_Throws() + { + // 波动率不在 ParameterBase 基类上,只能施于期权参数 + RiskFactor.Volatility("X").ApplyTo(new ParameterBase(), 0.1m); + } + + // —— 端到端:假 reprice 验证 中心差分 / 1bp 数值正确(线性函数精确) —— + + [TestMethod] + public void RateFactor_Through_Engine_DeltaR_Equals_Slope() + { + var baseParam = new ParameterBase { RiskFreeRate = 0.03 }; + Func reprice = p => (decimal)((p.RiskFreeRate ?? 0) * 1000); // PV = 1000 * r + var factor = RiskFactor.Rate("r"); // 标准步长:绝对 1bp + Func pv = factor.BuildPvFunction(reprice, baseParam); + + var calc = new GreeksBumpCalculator(); + decimal deltaR = calc.DeltaR(pv, 0.03m, factor.Shift); // 中心差分对线性函数精确 + Assert.AreEqual(1000m, deltaR, 1e-4m); + + decimal bump1bp = calc.BumpPv1Bp(pv, 0.03m); // 前向 1bp PV 差 + Assert.AreEqual(1000m * 0.0001m, bump1bp, 1e-9m); + } + + [TestMethod] + public void PriceFactor_Through_Engine_Delta_Equals_One() + { + var baseParam = new ParameterBase + { + SpotPrices = new Dictionary { { "X", 100 } } + }; + Func reprice = p => (decimal)p.SpotPrices["X"]; // PV = S + var factor = RiskFactor.Price("X"); // 标准步长:相对 1% → ε = 1 + Func pv = factor.BuildPvFunction(reprice, baseParam); + + var calc = new GreeksBumpCalculator(); + decimal delta = calc.Delta(pv, 100m, factor.Shift); // (101 - 99) / 2 = 1 精确 + Assert.AreEqual(1m, delta, 1e-6m); + } + + [TestMethod] + public void VolFactor_Through_Engine_Vega_Equals_Slope() + { + var baseParam = new BaseOptionParameter { Volatility = 0.2 }; + Func reprice = p => (decimal)(((BaseOptionParameter)p).Volatility ?? 0) * 50; // PV = 50 * σ + var factor = RiskFactor.Volatility("X"); // 标准步长:绝对 1bp vol + Func pv = factor.BuildPvFunction(reprice, baseParam); + + var calc = new GreeksBumpCalculator(); + decimal vega = calc.Vega(pv, 0.2m, factor.Shift); // 中心差分对线性函数精确 + Assert.AreEqual(50m, vega, 1e-4m); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/MultiUnwindDividendConservationTest.cs b/UnitTestProject/Modules/SwapModule/MultiUnwindDividendConservationTest.cs index 9dcb190a..c152645f 100644 --- a/UnitTestProject/Modules/SwapModule/MultiUnwindDividendConservationTest.cs +++ b/UnitTestProject/Modules/SwapModule/MultiUnwindDividendConservationTest.cs @@ -40,11 +40,11 @@ namespace YLErp.Modules.SwapModule /// CalcBondPayment 改为按天数 × 持仓线性函数,使 TdPosiDividend 真实随 /// "天数 × 剩余持仓"变化——这是验证多日递推守恒的前提。 /// - private sealed class StubEodService : SwapEodPositionService + private sealed class StubEodService : TestableSwapEodPositionService { private readonly decimal _dailyRatePerUnit; - public StubEodService(decimal dailyRatePerUnit) : base(new OptUserInfo(0, nameof(MultiUnwindDividendConservationTest), OptUserFrom.UnitTest)) + public StubEodService(decimal dailyRatePerUnit) : base(nameof(MultiUnwindDividendConservationTest)) { _dailyRatePerUnit = dailyRatePerUnit; } @@ -66,14 +66,6 @@ namespace YLErp.Modules.SwapModule return 1.00m; } - protected override void SaveAllChanges() { } - - // 注意:UpdateEodPosition.cs:1645 直接 new EodCurrencyRateService,不走此 seam; - // 但 trade.QuoteCurrency == trade.SettlementCurrency == "CNY" 时, - // EodCurrencyRateService.GetEodCurrencyRate 会在查库前短路返回 Rate=1(cs:268-281) - protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) - => 1.0; - // 暴露 protected UpdateEodPosition(参考 GLMS20260105GoldenTest.ReplayStubService:244) public eod_swap_position ExecuteUpdateEodPosition( swap_position swapPosition, eod_swap_position eod, trade td, diff --git a/UnitTestProject/Modules/SwapModule/SwapFixedLegRealizedPnlTest.cs b/UnitTestProject/Modules/SwapModule/SwapFixedLegRealizedPnlTest.cs new file mode 100644 index 00000000..2170a4f6 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapFixedLegRealizedPnlTest.cs @@ -0,0 +1,162 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService.SetFixedLegRealizedPnl 的回归测试。 + /// ----------------------------------------------------------------- + /// 守卫提交 f4ffe710 "fix(swap): 修复期末头寸已实现盈亏计算问题"。 + /// + /// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径原本各自复制粘贴: + /// newEodPayPosition.RealizedPnl = RealizedInterest + RealizedInterestFee + /// 其中 L1296 还遗留了双分号笔误 ";;"。抽为单一纯函数后消除复制粘贴风险, + /// 并锁定"固定利息腿累计已实现盈亏 = 累计利息 + 累计利息费用"口径。 + /// + /// 单测覆盖:正/负/零/混合符号、大额、InterestFee 为零等场景。 + /// + [TestClass] + public class SwapFixedLegRealizedPnlTest + { + // ================================================================ + // 场景1:收取方向,利息与利息费用均为正 + // ================================================================ + [TestMethod] + public void 收取方向_利息与费用均为正_求和() + { + var pos = NewPosition(realizedInterest: 1000m, realizedInterestFee: 200m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(1200m, pos.RealizedPnl, 0.0001m, + "1000 + 200 = 1200"); + } + + // ================================================================ + // 场景2:支付方向,利息与利息费用均为负 + // ================================================================ + [TestMethod] + public void 支付方向_利息与费用均为负_求和() + { + var pos = NewPosition(realizedInterest: -1000m, realizedInterestFee: -200m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(-1200m, pos.RealizedPnl, 0.0001m, + "-1000 + (-200) = -1200"); + } + + // ================================================================ + // 场景3:利息费用为零 —— RealizedPnl = RealizedInterest + // 回归场景:部分路径利息费用未发生,确保不误乘/不丢值 + // ================================================================ + [TestMethod] + public void 利息费用为零_盈亏等于利息() + { + var pos = NewPosition(realizedInterest: 500m, realizedInterestFee: 0m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(500m, pos.RealizedPnl, 0.0001m, + "利息费用=0 时 RealizedPnl = RealizedInterest"); + } + + // ================================================================ + // 场景4:利息为零 —— RealizedPnl = RealizedInterestFee + // ================================================================ + [TestMethod] + public void 利息为零_盈亏等于利息费用() + { + var pos = NewPosition(realizedInterest: 0m, realizedInterestFee: 300m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(300m, pos.RealizedPnl, 0.0001m, + "利息=0 时 RealizedPnl = RealizedInterestFee"); + } + + // ================================================================ + // 场景5:两者均为零 —— RealizedPnl = 0 + // ================================================================ + [TestMethod] + public void 利息与费用均为零_盈亏为零() + { + var pos = NewPosition(realizedInterest: 0m, realizedInterestFee: 0m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(0m, pos.RealizedPnl, 0.0001m, + "两者均为 0 时 RealizedPnl = 0"); + } + + // ================================================================ + // 场景6:混合符号(利息负、利息费用正)—— 直接求和 + // 回归场景:避免有人误加 Math.Abs 或方向判断 + // ================================================================ + [TestMethod] + public void 混合符号_利息负费用正_直接求和() + { + var pos = NewPosition(realizedInterest: -800m, realizedInterestFee: 100m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(-700m, pos.RealizedPnl, 0.0001m, + "-800 + 100 = -700,不引入 Abs/方向判断"); + } + + // ================================================================ + // 场景7:大额 —— 验证 decimal 精度无溢出 + // ================================================================ + [TestMethod] + public void 大额_decimal精度无溢出() + { + var pos = NewPosition(realizedInterest: 279_486_108.21m, realizedInterestFee: 13_668.02m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(279_499_776.23m, pos.RealizedPnl, 0.0001m, + "大额 decimal 求和精度保持"); + } + + // ================================================================ + // 场景8:null 入参 —— 抛 NullReferenceException(现状锚点) + // 生产代码未加 null 检查,直接解引用 position 抛 NRE。 + // 若未来改为 ArgumentNullException,此处需同步更新。 + // ================================================================ + [TestMethod] + public void Null入参_抛NullReferenceException() + { + Assert.ThrowsException(() => + SwapEodPositionService.SetFixedLegRealizedPnl(null!)); + } + + // ================================================================ + // 场景9:覆盖原值 —— 验证是赋值而非累加 + // 回归场景:防止有人误改为 += 导致重复计算 + // ================================================================ + [TestMethod] + public void 原有RealizedPnl被覆盖_非累加() + { + var pos = NewPosition(realizedInterest: 100m, realizedInterestFee: 50m); + pos.RealizedPnl = 9999m; // 预置一个非零旧值 + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(150m, pos.RealizedPnl, 0.0001m, + "应直接覆盖为 150,而非累加旧值 9999"); + } + + // ================================================================ + // Helper:构造 eod_swap_position(只设置参与计算的 2 个字段) + // ================================================================ + private static eod_swap_position NewPosition( + decimal realizedInterest, + decimal realizedInterestFee) + { + return new eod_swap_position + { + RealizedInterest = realizedInterest, + RealizedInterestFee = realizedInterestFee + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapFrontendPnlValidateTest.cs b/UnitTestProject/Modules/SwapModule/SwapFrontendPnlValidateTest.cs new file mode 100644 index 00000000..b861410e --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapFrontendPnlValidateTest.cs @@ -0,0 +1,316 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Helpers; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapFrontendPnlValidator.BuildFrontendValidationDiffs 的回归测试。 + /// ----------------------------------------------------------------- + /// 守卫提交 41553970 "fix(swap): 修复债券结息价差盈亏计算逻辑"。 + /// + /// 背景:ValidateFrontendPnL 用 FrontendCalcReference 重算盈亏与前端值比对, + /// 原为 private void + 吞异常,无法单测。拆出 BuildFrontendValidationDiffs 纯函数: + /// - 入参:UnwindData + isIncome + /// - 返回:null(前置条件不满足)或 List<FrontendPnlDiff>(超阈值的差异项) + /// - 副作用:无(日志留在 ValidateFrontendPnL 外层) + /// + /// 本测试锁定: + /// 1) 前端值与后端重算一致 → 返回空列表 + /// 2) 前端值与后端重算不一致 → 返回对应字段差异 + /// 3) 无浮动腿 → 返回 null + /// 4) PosiGrossPrice=0 → 返回 null + /// 5) PositionQty/CloseQty 口径(41553970 修复点)正确传入 + /// + [TestClass] + public class SwapFrontendPnlValidateTest + { + // ================================================================ + // 场景1:前端值与后端重算一致 → 返回空列表 + // 用 FC_001 同款输入:债券多头,PosiGrossPrice=1.02, TradingAmountAvg=105, + // CloseQty=1000, PayDirection=1, PositionType=1, TradingFee="20" + // 后端重算:MarkClosePnl=30, FloatPnlSum=50, SwapRealizedPnL=50, SwapCloseAmount=50 + // 前端也填这些值 → 无差异 + // ================================================================ + [TestMethod] + public void 前后端一致_返回空差异列表() + { + var unwindData = BuildBaseUnwindData( + posiGrossPrice: 1.02m, tradingAmountAvg: 105m, + closeQty: 1000, positionQty: 1000, + tradingFee: "20", tradingFeePending: "0", dividendIn: "0", + payDirection: 1, positionType: 1, + swapRealizedPnL: 50m, swapCloseAmount: 50m, markClosePnl: 30m); + + var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); + + Assert.IsNotNull(diffs, "前置条件满足应返回列表而非 null"); + Assert.AreEqual(0, diffs.Count, + $"前后端一致应无差异,实际 {diffs.Count} 条:{string.Join(",", diffs.Select(d => d.Field))}"); + } + + // ================================================================ + // 场景2:SwapRealizedPnL 前端填错 → 返回该字段差异 + // 后端重算 SwapRealizedPnL=50, SwapCloseAmount=50; + // 前端 SwapRealizedPnL 故意填 60(SwapCloseAmount 保持 50 一致)→ 只 SwapRealizedPnL 有差异 + // ================================================================ + [TestMethod] + public void SwapRealizedPnL前端填错_返回该字段差异() + { + var unwindData = BuildBaseUnwindData( + posiGrossPrice: 1.02m, tradingAmountAvg: 105m, + closeQty: 1000, positionQty: 1000, + tradingFee: "20", tradingFeePending: "0", dividendIn: "0", + payDirection: 1, positionType: 1, + swapRealizedPnL: 60m, // 故意填错(正确=50) + swapCloseAmount: 50m, // 保持一致 + markClosePnl: 30m); // 保持一致 + + var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); + + Assert.IsNotNull(diffs); + CollectionAssert.AreEquivalent( + new[] { "SwapRealizedPnL" }, + diffs.Select(d => d.Field).ToArray(), + "应只捕获 SwapRealizedPnL 的差异"); + var diff = diffs.Single(d => d.Field == "SwapRealizedPnL"); + Assert.AreEqual(60m, diff.FrontendValue, "前端值=60"); + Assert.AreEqual(50m, diff.BackendValue, 0.01m, "后端重算=50"); + Assert.AreEqual(10m, diff.Delta, 0.01m, "Delta=10"); + } + + // ================================================================ + // 场景3:MarkClosePnl 前端填错 → 返回该字段差异 + // 后端重算 MarkClosePnl=30;前端故意填 25(其他保持一致)→ 只 MarkClosePnl 有差异 + // ================================================================ + [TestMethod] + public void MarkClosePnl前端填错_返回该字段差异() + { + var unwindData = BuildBaseUnwindData( + posiGrossPrice: 1.02m, tradingAmountAvg: 105m, + closeQty: 1000, positionQty: 1000, + tradingFee: "20", tradingFeePending: "0", dividendIn: "0", + payDirection: 1, positionType: 1, + swapRealizedPnL: 50m, + swapCloseAmount: 50m, + markClosePnl: 25m); // 故意填错(正确=30) + + var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); + + Assert.IsNotNull(diffs); + CollectionAssert.AreEquivalent( + new[] { "MarkClosePnl" }, + diffs.Select(d => d.Field).ToArray(), + "应只捕获 MarkClosePnl 的差异"); + var diff = diffs.Single(d => d.Field == "MarkClosePnl"); + Assert.AreEqual(25m, diff.FrontendValue); + Assert.AreEqual(30m, diff.BackendValue, 0.01m); + Assert.AreEqual(-5m, diff.Delta, 0.01m); + } + + // ================================================================ + // 场景4:无浮动腿(FlowEvents 为空)→ 返回 null + // ================================================================ + [TestMethod] + public void 无浮动腿_返回null() + { + var unwindData = new UnwindData + { + SwapTradeId = 1, + CloseQty = 1000, + PositionQty = 1000, + FlowEvents = new List() // 完全空 + }; + + var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); + + Assert.IsNull(diffs, "无浮动腿应返回 null(跳过校验)"); + } + + // ================================================================ + // 场景5:浮动腿 PosiGrossPrice=0 → 返回 null(避免误报) + // ================================================================ + [TestMethod] + public void 浮动腿PosiGrossPrice为零_返回null() + { + var unwindData = BuildBaseUnwindData( + posiGrossPrice: 0m, // 前端未传 → 0 + tradingAmountAvg: 105m, + closeQty: 1000, positionQty: 1000, + tradingFee: "20", tradingFeePending: "0", dividendIn: "0", + payDirection: 1, positionType: 1, + swapRealizedPnL: 50m, swapCloseAmount: 0m, markClosePnl: 30m); + + var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); + + Assert.IsNull(diffs, "PosiGrossPrice=0 应返回 null(避免误报)"); + } + + // ================================================================ + // 场景6:41553970 修复点 —— PositionQty 必须正确传入后端重算 + // 旧 bug:PositionQty 未传入,导致部分平仓时盈亏口径错误。 + // 验证:PositionQty != CloseQty 时,后端重算仍按真实 PositionQty 走 + // (本场景构造部分平仓:CloseQty=500, PositionQty=1000) + // 平仓页 unwind 用 CloseQty 算 MarkClosePnl: + // MarkClosePnl = 500×(1.05−1.02)×1×1 = 15 + // FloatPnlSum = 15 + 20 + 0 + 0 = 35 + // SwapRealizedPnL = SwapCloseAmount = 35 + // ================================================================ + [TestMethod] + public void 部分平仓_PositionQty正确传入后端重算() + { + var unwindData = BuildBaseUnwindData( + posiGrossPrice: 1.02m, tradingAmountAvg: 105m, + closeQty: 500, positionQty: 1000, + tradingFee: "20", tradingFeePending: "0", dividendIn: "0", + payDirection: 1, positionType: 1, + swapRealizedPnL: 35m, // 与后端重算一致 + swapCloseAmount: 35m, // 与后端重算一致 + markClosePnl: 15m); // 与后端重算一致 + + var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); + + Assert.IsNotNull(diffs); + Assert.AreEqual(0, diffs.Count, + $"部分平仓 PositionQty 正确传入应无差异,实际 {diffs.Count} 条:" + + string.Join(",", diffs.Select(d => $"{d.Field}(fe={d.FrontendValue},be={d.BackendValue})"))); + } + + // ================================================================ + // 场景7:阈值边界 —— 差异恰好等于阈值(0.01)不报,超过才报 + // 后端重算 SwapRealizedPnL=50, SwapCloseAmount=50; + // 前端 SwapRealizedPnL 填 50.01 → 差异 0.01 不> 0.01 → 不报 + // 前端 SwapRealizedPnL 填 50.02 → 差异 0.02 > 0.01 → 报 + // (SwapCloseAmount 保持 50 一致,不参与本场景断言) + // ================================================================ + [TestMethod] + public void 阈值边界_差异等于阈值不报_超过才报() + { + // 差异 = 0.01,不 > 0.01,不报 + var unwindDataEq = BuildBaseUnwindData( + posiGrossPrice: 1.02m, tradingAmountAvg: 105m, + closeQty: 1000, positionQty: 1000, + tradingFee: "20", tradingFeePending: "0", dividendIn: "0", + payDirection: 1, positionType: 1, + swapRealizedPnL: 50.01m, swapCloseAmount: 50m, markClosePnl: 30m); + var diffsEq = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindDataEq, isIncome: false); + Assert.IsNotNull(diffsEq); + Assert.IsFalse(diffsEq.Any(d => d.Field == "SwapRealizedPnL"), + "差异=0.01 不> 阈值,不应报 SwapRealizedPnL"); + + // 差异 = 0.02 > 0.01,报 + var unwindDataOver = BuildBaseUnwindData( + posiGrossPrice: 1.02m, tradingAmountAvg: 105m, + closeQty: 1000, positionQty: 1000, + tradingFee: "20", tradingFeePending: "0", dividendIn: "0", + payDirection: 1, positionType: 1, + swapRealizedPnL: 50.02m, swapCloseAmount: 50m, markClosePnl: 30m); + var diffsOver = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindDataOver, isIncome: false); + Assert.IsNotNull(diffsOver); + Assert.IsTrue(diffsOver.Any(d => d.Field == "SwapRealizedPnL"), + "差异=0.02 > 阈值,应报 SwapRealizedPnL"); + } + + // ================================================================ + // 场景8:isIncome=true 走 CalcIncome 路径 —— 确保分支选择正确 + // 结息页公式与平仓页不同,构造一致场景验证不抛异常且返回列表 + // ================================================================ + [TestMethod] + public void IsIncome为true_走CalcIncome分支_返回列表() + { + // income 页 MarkClosePnl = PositionQty × ContractSize × (ExitPrice×scale − EntryPrice) × floatRatio + // = 1000 × 1 × (105×0.01 − 1.02) × 1 = 30 + // FloatPnlSum = 30 + 20 = 50;SwapRealizedPnL = SwapCloseAmount = 50 + var unwindData = BuildBaseUnwindData( + posiGrossPrice: 1.02m, tradingAmountAvg: 105m, + closeQty: 1000, positionQty: 1000, + tradingFee: "20", tradingFeePending: "0", dividendIn: "0", + payDirection: 1, positionType: 1, + swapRealizedPnL: 50m, swapCloseAmount: 50m, markClosePnl: 30m); + + var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: true); + + Assert.IsNotNull(diffs, "isIncome=true 也应返回列表(可能为空或有差异)"); + // 不锁死具体差异,只验证分支可达、不抛异常 + } + + // ================================================================ + // 场景9:自定义阈值 —— threshold=1.0 时小差异不报 + // 后端 SwapRealizedPnL=50, SwapCloseAmount=50; + // 前端 SwapRealizedPnL=50.5(差异 0.5 < 1.0 不报),SwapCloseAmount=50 一致 + // ================================================================ + [TestMethod] + public void 自定义大阈值_小差异不报() + { + var unwindData = BuildBaseUnwindData( + posiGrossPrice: 1.02m, tradingAmountAvg: 105m, + closeQty: 1000, positionQty: 1000, + tradingFee: "20", tradingFeePending: "0", dividendIn: "0", + payDirection: 1, positionType: 1, + swapRealizedPnL: 50.5m, // 差异 0.5 + swapCloseAmount: 50m, + markClosePnl: 30m); + + var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false, threshold: 1.0m); + + Assert.IsNotNull(diffs); + Assert.IsFalse(diffs.Any(d => d.Field == "SwapRealizedPnL"), + "threshold=1.0 时差异 0.5 不应报"); + } + + // ================================================================ + // Helper:构造带一条浮动腿 + 一条利息腿的 UnwindData + // 默认用债券(UnderlyingInstrumentType 走 IsBond=true → multiplier=100) + // 字段值与 FrontendCalcCharacterizationTest.FC_001 对齐 + // ================================================================ + private static UnwindData BuildBaseUnwindData( + decimal posiGrossPrice, + decimal tradingAmountAvg, + decimal closeQty, + decimal positionQty, + string tradingFee, + string tradingFeePending, + string dividendIn, + int payDirection, + int positionType, + decimal swapRealizedPnL, + decimal swapCloseAmount, + decimal markClosePnl) + { + // 浮动腿(债券,有 UnderlyingCode) + var floatLeg = new swap_flow_event + { + UnderlyingCode = "511160.SH", + UnderlyingInstrumentType = "Bond", + PosiGrossPrice = posiGrossPrice, + TradingAmountAvg = tradingAmountAvg, + ContractSize = 1m, + PayDirection = payDirection, + PositionType = positionType, + TradingFee = decimal.Parse(tradingFee), + TradingFeePending = decimal.Parse(tradingFeePending), + DividendIn = decimal.Parse(dividendIn), + MarkClosePnl = markClosePnl, + InterestMode = (int)InterestModeEnum.标的期初全价 + }; + + // 利息腿(无 UnderlyingCode) + var interestLeg = new swap_flow_event + { + InterestMode = (int)InterestModeEnum.固定值, + InterestClosePnL = 0m + }; + + return new UnwindData + { + SwapTradeId = 1, + CloseQty = closeQty, + PositionQty = positionQty, + CloseNotionalValue = closeQty * 100m, // 债券面值 100 + SwapRealizedPnL = swapRealizedPnL, + SwapCloseAmount = swapCloseAmount, + FlowEvents = new List { floatLeg, interestLeg } + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 86c3dac1..8b69eea1 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -23,7 +23,7 @@ namespace YLErp.Modules.SwapModule /// 继承 SwapEodPositionService,override SwapPositionCompose 路径上的 seam。 /// 适配当前分支 seam 签名(GetUnderlyingPrice 带 out、GetCurrencyRate 返回 double 等)。 /// - private sealed class TestableSwapEodService : SwapEodPositionService + private sealed class TestableSwapEodService : TestableSwapEodPositionService { private readonly List _trades; private readonly List _positions; @@ -34,15 +34,15 @@ namespace YLErp.Modules.SwapModule private readonly decimal _price; private readonly decimal _vobp; - public List CreatedEodPositions { get; } = new(); - public List<(double amount, string action)> ClientCashCalls { get; } = new(); + // 输出别名(转发到基类捕获属性) + public List CreatedEodPositions => PersistedPositions; public TestableSwapEodService( List trades, List positions, List eodPositions, List eodSwaps, List extends, List flowEvents, decimal price = 100m, decimal vobp = 0m) - : base(new OptUserInfo(0, nameof(SwapPositionComposeScenarioTest), OptUserFrom.UnitTest)) + : base(nameof(SwapPositionComposeScenarioTest)) { _trades = trades; _positions = positions; _eodPositions = eodPositions; _eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents; @@ -67,13 +67,9 @@ namespace YLErp.Modules.SwapModule { vobp = _vobp; return _price; } protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m; - // 持久化/事务 seam override - protected override void PersistEodSwapPosition(eod_swap_position position) { CreatedEodPositions.Add(position); } + // 持久化/事务 seam override(PersistEodSwapPosition/SaveAllChanges/GetCurrencyRate/AddClientCash 由基类提供) protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { } - protected override void SaveAllChanges() { } protected override void ExecuteInTransaction(Action action) => action(); - protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) - { ClientCashCalls.Add((amount, action)); return ClientCashCalls.Count; } protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) { } public override void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { } protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) @@ -85,7 +81,6 @@ namespace YLErp.Modules.SwapModule decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) => new List(); - protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) => 1.0; public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) => SwapPositionCompose(settleDate, preSettleDate, null); diff --git a/UnitTestProject/Modules/SwapModule/SwapReEodDeleteManualCashRecordTest.cs b/UnitTestProject/Modules/SwapModule/SwapReEodDeleteManualCashRecordTest.cs index fd5f89de..dc3996b1 100644 --- a/UnitTestProject/Modules/SwapModule/SwapReEodDeleteManualCashRecordTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapReEodDeleteManualCashRecordTest.cs @@ -247,7 +247,7 @@ namespace YLErp.Modules.SwapModule /// [TestMethod] [TestCategory("DBRecording")] - // [Ignore] // 有写文件副作用,手动跑时取消注释 + [Ignore] // 写文件副作用 + 依赖测试库样本(trade 1903),手动跑时取消注释;不进 CI(见类头注释) public void Step1_RecordAndDiagnoseDeleteBug() { int tradeId = SampleTradeId; diff --git a/UnitTestProject/Modules/SwapModule/SwapReportInterestSignNormalizeTest.cs b/UnitTestProject/Modules/SwapModule/SwapReportInterestSignNormalizeTest.cs new file mode 100644 index 00000000..f6739ac8 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapReportInterestSignNormalizeTest.cs @@ -0,0 +1,212 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService.NormalizeInterestSignForReport 的回归测试。 + /// ----------------------------------------------------------------- + /// 守卫提交 f4ffe710 中报表分支的符号归一化逻辑(原内联于 SearchEodSwapList)。 + /// + /// 业务口径: + /// - 仅当 InterestDirection > 0 时执行(兼容历史 0 方向脏数据) + /// - 普通利息腿:收取为正、支付为负(interestRatio = Direction==收取 ? 1 : -1) + /// - 预付金腿(初始预付金/追加预付金):利息方向与保证金本金方向相反,interestRatio 取反 + /// - TdCloseInterest / RealizedInterest 统一按 Math.Abs × interestRatio 重写 + /// - RealizedPnl 重算为 RealizedInterest + RealizedInterestFee(兼容历史未同步落库) + /// + /// 抽为 public static 纯函数以支持无库单测。 + /// + [TestClass] + public class SwapReportInterestSignNormalizeTest + { + // ================================================================ + // 场景1:普通利息腿收取方向 → 利息维持正号 + // TdCloseInterest=-1000(历史脏数据符号错) → 归一化为 +1000 + // RealizedInterest=-2000 → +2000 + // RealizedPnl = 2000 + 100 = 2100 + // ================================================================ + [TestMethod] + public void 普通利息腿_收取方向_利息归一化为正() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + tdCloseInterest: -1000m, + realizedInterest: -2000m, + realizedInterestFee: 100m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(1000m, pos.TdCloseInterest, 0.0001m, "TdCloseInterest 应归一化为 +1000"); + Assert.AreEqual(2000m, pos.RealizedInterest, 0.0001m, "RealizedInterest 应归一化为 +2000"); + Assert.AreEqual(2100m, pos.RealizedPnl, 0.0001m, "RealizedPnl = 2000 + 100 = 2100"); + } + + // ================================================================ + // 场景2:普通利息腿支付方向 → 利息归一化为负 + // TdCloseInterest=1000(历史脏数据符号错) → 归一化为 -1000 + // RealizedInterest=2000 → -2000 + // RealizedPnl = -2000 + 100 = -1900 + // ================================================================ + [TestMethod] + public void 普通利息腿_支付方向_利息归一化为负() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.支付, + tdCloseInterest: 1000m, + realizedInterest: 2000m, + realizedInterestFee: 100m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(-1000m, pos.TdCloseInterest, 0.0001m, "TdCloseInterest 应归一化为 -1000"); + Assert.AreEqual(-2000m, pos.RealizedInterest, 0.0001m, "RealizedInterest 应归一化为 -2000"); + Assert.AreEqual(-1900m, pos.RealizedPnl, 0.0001m, "RealizedPnl = -2000 + 100 = -1900"); + } + + // ================================================================ + // 场景3:预付金腿收取方向 → 利息方向反向为负 + // 原因:预付金腿的利息方向与保证金本金方向相反 + // TdCloseInterest=1000 → -1000 + // RealizedInterest=2000 → -2000 + // ================================================================ + [TestMethod] + public void 预付金腿_收取方向_利息反向为负() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + tdCloseInterest: 1000m, + realizedInterest: 2000m, + realizedInterestFee: 0m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(-1000m, pos.TdCloseInterest, 0.0001m, + "预付金腿收取方向:利息反向为负"); + Assert.AreEqual(-2000m, pos.RealizedInterest, 0.0001m, + "预付金腿收取方向:累计利息反向为负"); + Assert.AreEqual(-2000m, pos.RealizedPnl, 0.0001m, + "RealizedPnl = -2000 + 0 = -2000"); + } + + // ================================================================ + // 场景4:预付金腿支付方向 → 利息方向反向为正 + // ================================================================ + [TestMethod] + public void 预付金腿_支付方向_利息反向为正() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.追加预付金, + interestDirection: (int)SwapDirectionEnum.支付, + tdCloseInterest: -1000m, + realizedInterest: -2000m, + realizedInterestFee: 50m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(1000m, pos.TdCloseInterest, 0.0001m, + "预付金腿支付方向:利息反向为正"); + Assert.AreEqual(2000m, pos.RealizedInterest, 0.0001m, + "预付金腿支付方向:累计利息反向为正"); + Assert.AreEqual(2050m, pos.RealizedPnl, 0.0001m, + "RealizedPnl = 2000 + 50 = 2050"); + } + + // ================================================================ + // 场景5:InterestDirection=0 → 不处理(兼容历史 0 方向脏数据) + // 所有字段保持原值不变 + // ================================================================ + [TestMethod] + public void 方向为零_不处理_字段保持原值() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: 0, + tdCloseInterest: -999m, + realizedInterest: -888m, + realizedInterestFee: 77m); + pos.RealizedPnl = 555m; // 预置旧值 + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(-999m, pos.TdCloseInterest, 0.0001m, "方向=0:TdCloseInterest 不变"); + Assert.AreEqual(-888m, pos.RealizedInterest, 0.0001m, "方向=0:RealizedInterest 不变"); + Assert.AreEqual(555m, pos.RealizedPnl, 0.0001m, "方向=0:RealizedPnl 不重算"); + } + + // ================================================================ + // 场景6:InterestDirection 为负 → 不处理(防御性,对应原 "> 0" 判断) + // ================================================================ + [TestMethod] + public void 方向为负_不处理_字段保持原值() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: -1, + tdCloseInterest: -999m, + realizedInterest: -888m, + realizedInterestFee: 77m); + pos.RealizedPnl = 555m; + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(-999m, pos.TdCloseInterest, 0.0001m, "方向<0:TdCloseInterest 不变"); + Assert.AreEqual(555m, pos.RealizedPnl, 0.0001m, "方向<0:RealizedPnl 不重算"); + } + + // ================================================================ + // 场景7:利息为零 → Math.Abs(0)=0,归一化后仍为 0,RealizedPnl=费用 + // ================================================================ + [TestMethod] + public void 利息为零_归一化后仍为零_盈亏等于费用() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + tdCloseInterest: 0m, + realizedInterest: 0m, + realizedInterestFee: 300m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(0m, pos.TdCloseInterest, 0.0001m); + Assert.AreEqual(0m, pos.RealizedInterest, 0.0001m); + Assert.AreEqual(300m, pos.RealizedPnl, 0.0001m, "RealizedPnl = 0 + 300 = 300"); + } + + // ================================================================ + // 场景8:null 入参 —— 抛 NullReferenceException(现状锚点) + // 生产代码未加 null 检查,InterestDirection 解引用即 NRE。 + // 若未来改为 ArgumentNullException,此处需同步更新。 + // ================================================================ + [TestMethod] + public void Null入参_抛NullReferenceException() + { + Assert.ThrowsException(() => + SwapEodPositionService.NormalizeInterestSignForReport(null!)); + } + + // ================================================================ + // Helper:构造 eod_swap_position + // ================================================================ + private static eod_swap_position NewPosition( + int interestMode, + int interestDirection, + decimal tdCloseInterest, + decimal realizedInterest, + decimal realizedInterestFee) + { + return new eod_swap_position + { + InterestMode = interestMode, + InterestDirection = interestDirection, + TdCloseInterest = tdCloseInterest, + RealizedInterest = realizedInterest, + RealizedInterestFee = realizedInterestFee + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs index 7d1dfc27..dd7726cb 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs @@ -237,5 +237,26 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); Console.WriteLine($"UW_008: A=0.3→B={service.SaveSwapDealCalls[0].data.ClosePercent}, HasPartialUnWind={td.HasPartialUnWind} ✅"); } + + [TestMethod] + public void UW_009_SwapUnwind_名义本金写入前舍入两位小数() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 1000000.006; + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m, + closeQty: 5000m, closeNotionalValue: 500000.004m, positionQty: 10000m); + unwindData.NotionalValue = 1000000.006m; + unwindData.PosiNotionalValue = 1000000.006m; + + service.SwapUnwind(unwindData); + + var savedData = service.SaveSwapDealCalls[0].data; + Assert.AreEqual(1000000.01m, savedData.NotionalValue, "期初名义本金应按两位小数写入事件"); + Assert.AreEqual(1000000.01m, savedData.PosiNotionalValue, "剩余名义本金应按两位小数写入事件"); + Assert.AreEqual(500000.00m, savedData.CloseNotionalValue, "平仓名义本金应按两位小数写入事件"); + Assert.AreEqual(500000.01, td.StockEqvNotional, 0.000001, "trade 剩余名义本金应在扣减后舍入两位小数"); + } } } diff --git a/UnitTestProject/Modules/SwapModule/SwapWeightedMarginInterestTest.cs b/UnitTestProject/Modules/SwapModule/SwapWeightedMarginInterestTest.cs new file mode 100644 index 00000000..da901be3 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapWeightedMarginInterestTest.cs @@ -0,0 +1,150 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService.CalculateWeightedMarginInterest 的回归测试。 + /// ----------------------------------------------------------------- + /// 守卫提交 a0f8dc9d "refactor(SwapModule): 简化预付金利息计算逻辑"。 + /// + /// 旧实现:按 InterestPrincipalFix 绝对值加权平均利率 × 总本金,对方向不敏感, + /// 当收取/支付双腿并存时会把支付方向的利息错误计为收益。 + /// 新实现:InterestIncomeSum 已是各腿利息金额,按方向(收取=+1,支付=-1)轧差求和。 + /// + /// 抽为 public static 纯函数以支持无库单测。本测试锁定方向轧差契约。 + /// + [TestClass] + public class SwapWeightedMarginInterestTest + { + // ================================================================ + // 场景1:空集合 → 0(Sum 空序列默认值) + // ================================================================ + [TestMethod] + public void 空集合_返回0() + { + var result = SwapEodPositionService.CalculateWeightedMarginInterest( + Enumerable.Empty()); + + Assert.AreEqual(0m, result, 0.0001m, "空集合轧差应为 0"); + } + + // ================================================================ + // 场景2:单腿收取 → InterestIncomeSum 原值 + // ================================================================ + [TestMethod] + public void 单腿收取_利息原值计入() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 1000m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(1000m, result, 0.0001m, "单腿收取:+1000"); + } + + // ================================================================ + // 场景3:单腿支付 → InterestIncomeSum 取负 + // ================================================================ + [TestMethod] + public void 单腿支付_利息取负计入() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 1000m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(-1000m, result, 0.0001m, "单腿支付:-1000"); + } + + // ================================================================ + // 场景4:双腿轧差(收取 1000 + 支付 600 → 400) + // 旧 bug:按本金加权会忽略方向,结果不是 400 + // ================================================================ + [TestMethod] + public void 双腿轧差_收取大于支付_净额为正() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 1000m), + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 600m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(400m, result, 0.0001m, "双腿轧差:1000 - 600 = 400"); + } + + // ================================================================ + // 场景5:本金为零但 InterestIncomeSum 非零 —— 回归旧 bug 关键场景 + // 旧实现:totalWeight=0 → 返回 0,丢失利息 + // 新实现:不看本金,按方向轧差 InterestIncomeSum + // ================================================================ + [TestMethod] + public void 本金为零_利息仍按方向轧差_不丢失() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, + interestIncomeSum: 500m, interestPrincipalFix: 0m), + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, + interestIncomeSum: 200m, interestPrincipalFix: 0m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(300m, result, 0.0001m, + "本金为零时旧实现返回 0 丢失利息,新实现应按方向轧差 = 500 - 200 = 300"); + } + + // ================================================================ + // 场景6:多腿混合方向 —— 收取 100+200,支付 50+80 → 170 + // ================================================================ + [TestMethod] + public void 多腿混合方向_正确轧差() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 100m), + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 50m), + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 200m), + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 80m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(170m, result, 0.0001m, "多腿轧差:(100+200) - (50+80) = 170"); + } + + // ================================================================ + // 场景7:null 入参防御 —— Sum 对 null 抛 ArgumentNullException + // 仅作行为锚点:若未来改为 null 安全,此处需同步更新 + // ================================================================ + [TestMethod] + public void Null入参_抛ArgumentNullException() + { + Assert.ThrowsException(() => + SwapEodPositionService.CalculateWeightedMarginInterest(null!)); + } + + // ================================================================ + // Helper:构造 eod_swap_position(只设置参与计算的 2 个字段 + 可选本金) + // ================================================================ + private static eod_swap_position NewMargin( + int interestDirection, + decimal interestIncomeSum, + decimal interestPrincipalFix = 0m) + { + return new eod_swap_position + { + InterestDirection = interestDirection, + InterestIncomeSum = interestIncomeSum, + InterestPrincipalFix = interestPrincipalFix + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs new file mode 100644 index 00000000..181e8cd2 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -0,0 +1,68 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Model; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService 的可测试化基类(纯内存,不连库)。 + /// ============================================================================ + /// 收敛各 ScenarioTest 中 Stub 子类的重复 override: + /// - PersistEodSwapPosition:收集到列表,不写库 + /// - SaveAllChanges:no-op + /// - GetCurrencyRate:返回 1.0(本币) + /// - 统一构造函数(注入 OptUserInfo,标记 UnitTest 来源) + /// + /// 暴露 PersistedPositions / SaveChangesCount / ClientCashCalls 等输出捕获属性, + /// 供断言使用。各测试子类按需再 override 业务 seam(FindTrade/GetUnderlyingPrice 等)。 + /// + /// 设计原则: + /// - 只收敛 8/8 Stub 都重复的高频 override,不预设业务数据注入方式 + /// (ComposePage 用属性字典、SwapPositionCompose 用构造函数 List,差异留给子类) + /// - 不提供 Execute* 包装器(签名各异且大多只出现 1-2 次,留在各子类避免基类膨胀) + /// ============================================================================ + /// + public class TestableSwapEodPositionService : SwapEodPositionService + { + /// 捕获所有持久化的 eod 持仓(按调用顺序) + public List PersistedPositions { get; } = new(); + + /// SaveAllChanges 调用次数 + public int SaveChangesCount { get; private set; } + + /// AddClientCash 调用记录(金额, 操作) + public List<(double amount, string action)> ClientCashCalls { get; } = new(); + + /// 自增 id 模拟器(新增 eod 时分配 id) + private int _nextId = 1; + + protected TestableSwapEodPositionService(string testName) + : base(new OptUserInfo(0, testName ?? nameof(TestableSwapEodPositionService), OptUserFrom.UnitTest)) + { + } + + // ===== 高频 seam override(8/8 Stub 都重复,收敛到基类)===== + + protected override void PersistEodSwapPosition(eod_swap_position position) + { + if (position.id == 0) position.id = _nextId++; + PersistedPositions.Add(position); + } + + protected override void SaveAllChanges() + { + SaveChangesCount++; + } + + protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) + { + return 1.0; // 本币,汇率=1 + } + + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) + { + ClientCashCalls.Add((amount, action)); + return _nextId++; + } + } +} diff --git a/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_互换结清后待实现归零.json b/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_互换结清后待实现归零.json index 87cf38ba..041970f6 100644 --- a/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_互换结清后待实现归零.json +++ b/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_互换结清后待实现归零.json @@ -24,7 +24,7 @@ "TdCloseInterest": 8.21917808219, "TdInterestPrincipal": 10000.0, "RealizedInterest": 8.21917808219, - "RealizedPnl": 0.0, + "RealizedPnl": 8.21917808219, "SwapPositionValue": 0.821917808219178082191780822, "InterestFeeSum": 0.0 } diff --git a/YLErpDAL/BLL/Calculation/V2/GreeksBumpCalculator.cs b/YLErpDAL/BLL/Calculation/V2/GreeksBumpCalculator.cs new file mode 100644 index 00000000..66565d3b --- /dev/null +++ b/YLErpDAL/BLL/Calculation/V2/GreeksBumpCalculator.cs @@ -0,0 +1,133 @@ +using System; + +namespace YLErp.BLL.Calculation.V2 +{ + /// + /// 风险因子种类,用于后续 ValueCalculator 薄接入层签名(CalcPvAtBumpedRiskFactor)。 + /// 当前 GreeksBumpCalculator 纯类本身不依赖它,仅作为对外契约的一部分。 + /// + public enum RiskFactorKind + { + /// 标的价 S + Price, + /// 无风险利率 r + Rate, + /// 波动率 σ + Volatility + } + + /// + /// 扰动步长规格:决定有限差分用的 ε。 + /// + /// Relative:ε = max(|x| * Value, Floor),适合指数等大量级标的,避免绝对 1bp 落入浮点舍入区。 + /// Absolute:ε = Value,等价系统现行“标的价格偏移绝对 1bp”的做法。 + /// + /// 默认 Floor 极小,仅防止 x=0 时 ε=0 导致除零。 + /// + public readonly struct BumpSpec + { + /// 步长模式 + public enum Mode + { + /// 相对步长(推荐,量级自适应) + Relative, + /// 绝对步长 + Absolute + } + + /// 步长模式 + public Mode Kind { get; } + + /// 步长数值(Relative 时为相对比例,Absolute 时为绝对量) + public decimal Value { get; } + + /// 相对步长下限(防止 |x| 过小导致 ε→0) + public decimal Floor { get; } + + /// 构造步长规格 + public BumpSpec(Mode kind, decimal value, decimal floor = 0.00000001m) + { + Kind = kind; + Value = value; + Floor = floor; + } + + /// 相对步长(value 为相对比例,如 0.0005m = 0.05%) + public static BumpSpec Relative(decimal value) => new(Mode.Relative, value); + + /// 绝对步长(value 为绝对量,如 0.0001m = 1bp) + public static BumpSpec Absolute(decimal value) => new(Mode.Absolute, value); + + /// 把规格解析成实际 ε(用 decimal,避免 double 精度漂移) + public decimal Resolve(decimal x) + { + if (Kind == Mode.Absolute) return Value; + var eps = Math.Abs(x) * Value; + return eps < Floor ? Floor : eps; + } + } + + /// + /// 有限差分希腊字母计算引擎(纯函数,不依赖 QDP / DB)。 + /// + /// 通过委托 pv(bumpedFactor) 取得“风险因子被扰动到某值时的衍生品价值”, + /// 再用中心差分估算一阶 / 二阶导。这是仓库层对 Greeks 口径的显式控制点, + /// 可绕开 QDP 内部黑盒的步长 / 差分选择,并解决指数类标的使用绝对 1bp 步长失真的口径问题。 + /// + /// + /// 用法:业务侧把“价格 / 利率 / 波动率”各自封装成一个 Func<decimal,decimal> 委托传给本类; + /// Delta/Gamma/Vega/Rho 等命名方法数学上都是同一差分算子,仅扰动的风险因子不同。 + /// + /// + public sealed class GreeksBumpCalculator + { + /// 一阶中心差分 Δ = [PV(x+ε) - PV(x-ε)] / (2ε),误差 O(ε²) + public decimal FirstOrderCentral(Func pv, decimal x, BumpSpec bump) + { + ArgumentNullException.ThrowIfNull(pv); + var eps = bump.Resolve(x); + return (pv(x + eps) - pv(x - eps)) / (2m * eps); + } + + /// 二阶中心差分 Γ = [PV(x+ε) - 2·PV(x) + PV(x-ε)] / ε²,误差 O(ε²) + public decimal SecondOrderCentral(Func pv, decimal x, BumpSpec bump) + { + ArgumentNullException.ThrowIfNull(pv); + var eps = bump.Resolve(x); + return (pv(x + eps) - 2m * pv(x) + pv(x - eps)) / (eps * eps); + } + + // —— 以下为按业务希腊字母命名的暴露,数学上都是上面两个差分算子,仅扰动因子不同 —— + + /// Delta:对标的价 S 的一阶中心差分 + public decimal Delta(Func pv, decimal s, BumpSpec bump) => FirstOrderCentral(pv, s, bump); + + /// Gamma:对标的价 S 的二阶中心差分 + public decimal Gamma(Func pv, decimal s, BumpSpec bump) => SecondOrderCentral(pv, s, bump); + + /// Vega:对波动率 σ 的一阶中心差分 + public decimal Vega(Func pv, decimal sigma, BumpSpec bump) => FirstOrderCentral(pv, sigma, bump); + + /// Vega_r:对波动率 σ 的一阶中心差分(需求命名变体,等价于 Vega) + public decimal VegaR(Func pv, decimal sigma, BumpSpec bump) => FirstOrderCentral(pv, sigma, bump); + + /// Rho:对无风险利率 r 的一阶中心差分 + public decimal Rho(Func pv, decimal r, BumpSpec bump) => FirstOrderCentral(pv, r, bump); + + /// Delta_r:对无风险利率 r 的一阶中心差分(需求命名变体,等价于 Rho) + public decimal DeltaR(Func pv, decimal r, BumpSpec bump) => FirstOrderCentral(pv, r, bump); + + /// Gamma_r:对无风险利率 r 的二阶中心差分 + public decimal GammaR(Func pv, decimal r, BumpSpec bump) => SecondOrderCentral(pv, r, bump); + + /// + /// 1BP 变体(Dollar Greek):直接前向 bump 1bp 的 PV 差 = PV(x+1bp) - PV(x),不除 ε,量纲为金额。 + /// 对应需求中的 Delta_r(1BP) / Gamma_r(1BP) / Vega_r(1BP)。 + /// + public decimal BumpPv1Bp(Func pv, decimal x, decimal oneBp = 0.0001m) + { + ArgumentNullException.ThrowIfNull(pv); + return pv(x + oneBp) - pv(x); + } + } +} diff --git a/YLErpDAL/BLL/Calculation/V2/GreeksRiskFactor.cs b/YLErpDAL/BLL/Calculation/V2/GreeksRiskFactor.cs new file mode 100644 index 00000000..8a8e03db --- /dev/null +++ b/YLErpDAL/BLL/Calculation/V2/GreeksRiskFactor.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using YLErp.BLL.Calculation.V2.Parameter; + +namespace YLErp.BLL.Calculation.V2 +{ + /// + /// 命名风险因子——业界"按因子建模、而非按资产类别建模"的核心抽象。 + /// + /// 任意衍生品都可视为 PV = Pricer(MarketData)。希腊字母 = 把某个命名风险因子 + /// 在 MarketData 上偏移一个标准步长、重定价、再差分。股票/债券指数/债券收益率期权 + /// 只是依赖不同的因子(spot / 收益率曲线 / 波动率面),天然被同一套引擎覆盖, + /// 无需为每种资产类别各写一套 Greek 代码。这是 OpenGamma/Strata、QuantLib、FRTB SBA 的同源做法。 + /// + /// + /// 每个因子自带:Id、类别()、标准扰动步长()、 + /// 作用目标键(TargetKey)。因子与 ParameterBase 的映射由 完成; + /// 与定价引擎的桥接由 ValueCalculator 薄接入层(CalcPvAtBumpedRiskFactor / PvAsFunctionOf)提供。 + /// + /// + /// 工厂方法 / / 即"步长注册表": + /// 按因子类别给定 FRTB SBA 标准偏移,统一全系统的 ε 与差分口径,解决需求 #2 中 + /// "绝对 1bp vs 相对 0.05%"的口径分歧(股指用绝对 1bp 会因量级大落入浮点舍入区,须相对步长)。 + /// + /// + public sealed class RiskFactor + { + /// 因子唯一标识 + public string Id { get; } + + /// 因子类别(决定施加到 ParameterBase 的哪个字段、用哪种标准步长) + public RiskFactorKind Category { get; } + + /// 标准扰动步长(FRTB SBA 口径,由工厂方法按类别给定) + public BumpSpec Shift { get; } + + /// + /// 作用目标键:Price / Volatility 为 UnderlyingCode;Rate 为曲线/期限标识(如 "CNY-OIS-2Y")。 + /// + public string TargetKey { get; } + + public RiskFactor(string id, RiskFactorKind category, BumpSpec shift, string targetKey) + { + Id = id ?? throw new ArgumentNullException(nameof(id)); + Category = category; + Shift = shift; + TargetKey = targetKey ?? string.Empty; + } + + // —— 步长注册表:按因子类别给定 FRTB SBA 标准偏移 —— + // 股票 / 指数 spot:相对 1%(指数量级大,绝对 1bp 会落入浮点舍入区,必须相对) + // 利率 / FX:绝对 1bp + // 波动率面:绝对 1bp vol + + /// 标的价/指数 spot 因子,默认相对 1% 步长(指数类用此可避免绝对 1bp 失真) + public static RiskFactor Price(string underlyingCode, decimal relativeStep = 0.01m) + => new($"{underlyingCode}-SPOT", RiskFactorKind.Price, BumpSpec.Relative(relativeStep), underlyingCode); + + /// 利率/贴现曲线因子,默认绝对 1bp 步长 + public static RiskFactor Rate(string curveTenorId, decimal oneBp = 0.0001m) + => new($"{curveTenorId}-RATE", RiskFactorKind.Rate, BumpSpec.Absolute(oneBp), curveTenorId); + + /// 波动率面因子,默认绝对 1bp vol 步长 + public static RiskFactor Volatility(string underlyingCode, decimal oneBp = 0.0001m) + => new($"{underlyingCode}-VOL", RiskFactorKind.Volatility, BumpSpec.Absolute(oneBp), underlyingCode); + + /// + /// 把因子扰动到 ,施加到参数副本上(不修改入参)。 + /// Rate → RiskFreeRate;Price → SpotPrices[TargetKey];Volatility → cast 期权参数设 Volatility。 + /// + public void ApplyTo(ParameterBase p, decimal value) + { + if (p == null) throw new ArgumentNullException(nameof(p)); + switch (Category) + { + case RiskFactorKind.Rate: + p.RiskFreeRate = (double)value; + break; + case RiskFactorKind.Price: + if (p.SpotPrices == null) p.SpotPrices = new Dictionary(); + p.SpotPrices[TargetKey] = (double)value; + break; + case RiskFactorKind.Volatility: + if (p is BaseOptionParameter bo) bo.Volatility = (double)value; + else throw new InvalidOperationException( + $"波动率风险因子[{Id}]只能施加于期权类参数(BaseOptionParameter),当前为 {p.GetType().Name}"); + break; + default: + throw new ArgumentOutOfRangeException(nameof(Category), Category, "未支持的风险因子类别"); + } + } + + /// + /// 构造"PV 作为本因子值的函数":克隆 baseParam → 施加扰动值 → 调 reprice 得 PV。 + /// + /// reprice 生产环境即 ValueCalculator 的真实定价(见 PvAsFunctionOf);测试可注入假函数,无需 DB / QDP。 + /// 返回的委托直接喂给 GreeksBumpCalculator 的中心差分方法。 + /// + /// + public Func BuildPvFunction(Func reprice, ParameterBase baseParam) + { + if (reprice == null) throw new ArgumentNullException(nameof(reprice)); + if (baseParam == null) throw new ArgumentNullException(nameof(baseParam)); + return bumpedValue => + { + var p = baseParam.Clone(); + ApplyTo(p, bumpedValue); + return reprice(p); + }; + } + } +} diff --git a/YLErpDAL/BLL/Calculation/V2/Parameter/ParameterBase.cs b/YLErpDAL/BLL/Calculation/V2/Parameter/ParameterBase.cs index cc312d88..73ecb89a 100644 --- a/YLErpDAL/BLL/Calculation/V2/Parameter/ParameterBase.cs +++ b/YLErpDAL/BLL/Calculation/V2/Parameter/ParameterBase.cs @@ -15,5 +15,22 @@ namespace YLErp.BLL.Calculation.V2.Parameter public bool HasNightMarket { get; set; } public bool PreciseTimeMode { get; set; } public int maturityShift { get; set; } + + /// + /// 深拷贝(保留运行时类型)。 + /// + /// 用 MemberwiseClone 保证克隆对象与 this 运行时类型一致—— + /// 例如 VanillaOptionParameter 克隆后仍是 VanillaOptionParameter, + /// 否则 ValueCalculator 内 parameter as VanillaOptionParameter 会因类型退化为基类而得到 null。 + /// 引用型字段 SpotPrices/Dividends 单独深拷,避免对克隆体 bump 时污染原参数。 + /// + /// + public virtual ParameterBase Clone() + { + var clone = (ParameterBase)MemberwiseClone(); + clone.SpotPrices = SpotPrices == null ? null : new Dictionary(SpotPrices); + clone.Dividends = Dividends == null ? null : new Dictionary(Dividends); + return clone; + } } } diff --git a/YLErpDAL/BLL/Calculation/V2/ValueCalculator.cs b/YLErpDAL/BLL/Calculation/V2/ValueCalculator.cs index b50ffa69..21aceac3 100644 --- a/YLErpDAL/BLL/Calculation/V2/ValueCalculator.cs +++ b/YLErpDAL/BLL/Calculation/V2/ValueCalculator.cs @@ -2300,6 +2300,38 @@ namespace YLErp.BLL.Calculation.V2 Console.WriteLine(ex.Message); } } + + #region Greeks 薄接入层(③ 重定价桥,加法性,不改现有定价输出) + + /// + /// 把某风险因子扰动到指定值后重定价,返回 PV(decimal)。 + /// 克隆参数后施加扰动,不污染入参;现有 CalculateTradeValue 输出一行不变。 + /// + /// + /// 这是把 GreeksBumpCalculator(有限差分引擎)接上真实定价路径的唯一接入点: + /// 业务侧拿到返回的 PV 后,配合 自带的标准步长, + /// 用 GreeksBumpCalculator.DeltaR/Delta/GammaR/VegaR 等即可算统一的希腊字母。 + /// + public static decimal CalcPvAtBumpedRiskFactor( + string userId, trade t, underlying_manager u, + RiskFactor factor, decimal bumpedValue, ParameterBase baseParam) + { + if (factor == null) throw new ArgumentNullException(nameof(factor)); + var p = baseParam.Clone(); + factor.ApplyTo(p, bumpedValue); + return (decimal)CalculateTradeValue(userId, t, u, p).Pv; + } + + /// + /// 构造"PV 作为某风险因子值的函数"委托,供 GreeksBumpCalculator 中心差分使用。 + /// 内部用真实 CalculateTradeValue 重定价;因子类别自带 FRTB SBA 标准步长(见 RiskFactor 工厂方法)。 + /// + public static Func PvAsFunctionOf( + string userId, trade t, underlying_manager u, + RiskFactor factor, ParameterBase baseParam) + => factor.BuildPvFunction(p => (decimal)CalculateTradeValue(userId, t, u, p).Pv, baseParam); + + #endregion } class OptionMarketObjectName diff --git a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs index 2d16d4dd..aeedd67e 100644 --- a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs +++ b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs @@ -1386,7 +1386,7 @@ namespace YLErp.BLL.Eod ClientId = item.client_id ?? 0, ClientName = item.client_name, TradingQty = (item.order_qty ?? 0) - (item.last_shares ?? 0), - TradingAmountAvg = BondPriceConverter.ToStorage(item.full_price ?? 0), + TradingAmountAvg = Math.Round(BondPriceConverter.ToStorage(item.full_price ?? 0), ConsGlobal.PriceRound, MidpointRounding.AwayFromZero), TradingAmountFeeAvg = BondPriceConverter.ToStorage(item.full_price ?? 0), TradingFee = 0 }; diff --git a/YLErpDAL/Helpers/BondCalcHepler.cs b/YLErpDAL/Helpers/BondCalcHepler.cs index 3247ae21..4f7acd21 100644 --- a/YLErpDAL/Helpers/BondCalcHepler.cs +++ b/YLErpDAL/Helpers/BondCalcHepler.cs @@ -1,4 +1,4 @@ -using Org.BouncyCastle.Asn1.Ocsp; +using Org.BouncyCastle.Asn1.Ocsp; using System; using System.Collections.Generic; using System.Linq; @@ -10,44 +10,115 @@ namespace YLErp.Helpers { /// /// 计算器帮助类 + /// 调用链:zszq-trs → zszq-bond-oms(/calc/cal_bond_value) → bond-calc 微服务。 + /// 契约(与 bond-oms-ui / zszq-bond-oms 保持一致): + /// 请求体 { bondId, price, priceType(DP全价/CP净价/YD收益率), targetDate?(估值日 yyyy-MM-dd,缺省代理取 T+1) } + /// 成功响应 data:{ errCode:0, errMsg:null, dirtyPrice(全价), cleanPrice(净价), ytm(收益率%) } + /// 失败响应:success=false 且 message=中文原因(债券不存在/信息不全/参数非法/服务异常) /// public class BondCalcHepler { + private const string CalcUrl = "/calc/cal_bond_value"; + /// - /// 计算器 - /// - /// - public static CalBondResult BondCalc(string underlyingCode,decimal price,string priceType="DP") + /// 计算器(债券净价/全价/收益率互算)。 + /// 兼容旧调用方(如 RealtimePnlCalc):返回 CalBondResult,失败返回 null。 + /// targetDate 可选:估值日(yyyy-MM-dd)。前端(fe)已在债券互换录入页强制要求 StartDate(开始日)必填、 + /// 为空则报错不调用本接口;故经 UI 的计算请求总会带上估值日,此处的"代理默认 T+1"仅兜底非 UI 调用方(如 RealtimePnlCalc)。 + /// + public static CalBondResult BondCalc(string underlyingCode, decimal price, string priceType = "DP", string targetDate = null) + { + string _err; + return BondCalc(underlyingCode, price, priceType, out _err, targetDate); + } + + /// + /// 计算器(带错误信息输出)。 + /// 任何失败(地址未配/无响应/success=false/业务错误码 errCode!=0/异常)都返回 null, + /// 并通过 errorMsg 带上**真实原因**,供上层(BondController)清晰反馈给用户。 + /// 关键:即使 success=true,只要内部 errCode!=0(债券不存在/信息不全/参数非法)也视为失败, + /// 避免把 0/异常价格静默回写覆盖用户手工输入。 + /// targetDate 可选:估值日(yyyy-MM-dd),透传给代理的 targetDate(底层 bond-calc 的 settlementDate); + /// 不传则代理默认取 T+1(下一工作日)。 + /// + public static CalBondResult BondCalc(string underlyingCode, decimal price, string priceType, out string errorMsg, string targetDate = null) + { + errorMsg = null; + var baseUrl = Environment.GetEnvironmentVariable("BondOmsInterface_BaseUrl"); + CalcBondRequest request = new CalcBondRequest() { - var baseUrl = Environment.GetEnvironmentVariable("BondOmsInterface_BaseUrl"); - var calculateUrl = "/calc/cal_bond_value"; - CalcBondRequest request = new CalcBondRequest() { - bondId= underlyingCode, - price = price.ToString(), - priceType = priceType, - }; - if (!string.IsNullOrEmpty(baseUrl)) + bondId = underlyingCode, + price = price.ToString(), + priceType = priceType, + targetDate = targetDate, + }; + if (string.IsNullOrEmpty(baseUrl)) { + errorMsg = "计算器服务地址未配置(BondOmsInterface_BaseUrl)"; + return null; + } + try + { + LogFactory.GetLogger("BondCalcHepler").Info( + $"债券计算器请求: url={baseUrl}{CalcUrl} bondId={underlyingCode} price={price} priceType={priceType} targetDate={(string.IsNullOrEmpty(targetDate) ? "(空→代理默认T+1)" : targetDate)}"); var httpHelper = new HttpHelper(baseUrl, null); // http 请求 Web项目接口 - var result = httpHelper.PostRequestNoAuth(calculateUrl, request).Result; - if (result != null && !result.success) + var result = httpHelper.PostRequestNoAuth(CalcUrl, request).Result; + if (result == null) { - LogFactory.GetLogger("BondCalcHepler").Info("计算器计算失败:" + result.message); + errorMsg = "计算器服务无响应"; + LogFactory.GetLogger("BondCalcHepler").Error("债券计算器无响应,地址:" + baseUrl + CalcUrl); + return null; } - else + if (!result.success) { - return result.data; + errorMsg = result.message ?? "债券计算失败"; + LogFactory.GetLogger("BondCalcHepler").Error("债券计算失败:" + errorMsg); + return null; } + if (result.data == null) + { + errorMsg = "计算器返回数据为空"; + LogFactory.GetLogger("BondCalcHepler").Error("债券计算器返回 data 为空"); + return null; + } + // 业务层错误码(债券不存在 / 债券信息不全 / 参数非法),zszq-bond-oms 在 success=true 时仍可能带 errCode!=0 + if (result.data.errCode != 0) + { + errorMsg = result.data.errMsg ?? "债券计算业务错误"; + LogFactory.GetLogger("BondCalcHepler").Error("债券计算业务错误(code=" + result.data.errCode + "):" + errorMsg); + return null; + } + // 防御性值域校验:errCode=0 但数值离谱(如净价变负 / 收益率量级爆炸 / 行权收益率哨兵 -999999)。 + // 这类"成功但不可信"的响应现有 errCode 守卫拦不住,此处兜底:宁可返回 null+提示用户核对, + // 也绝不把垃圾值回写覆盖手工输入。 + string absurdReason; + if (IsResultAbsurd(result.data, out absurdReason)) + { + errorMsg = absurdReason; + LogFactory.GetLogger("BondCalcHepler").Error( + $"债券计算返回离谱值(bondId={underlyingCode}):{absurdReason} | cleanPrice={result.data.cleanPrice} dirtyPrice={result.data.dirtyPrice} ytm={result.data.ytm}"); + return null; + } + LogFactory.GetLogger("BondCalcHepler").Info( + $"债券计算器成功: bondId={underlyingCode} targetDate={targetDate} cleanPrice={result.data.cleanPrice} dirtyPrice={result.data.dirtyPrice} ytm={result.data.ytm}"); + return result.data; + } + catch (Exception ex) + { + errorMsg = "请求债券计算器异常:" + ex.Message; + LogFactory.GetLogger("BondCalcHepler").Error("请求债券计算器时发生异常", ex); + return null; } - return null; } - + /// + /// 按结算日计算(自动结算路径用)。与 BondCalc 同款健壮处理: + /// 地址未配/无响应/success=false/业务错误码/异常 均返回 null(不回写坏数据)。 + /// public static CalBondResult BondCalcByDate(string underlyingCode, decimal price, String targetDate, string priceType = "DP") { var baseUrl = Environment.GetEnvironmentVariable("BondOmsInterface_BaseUrl"); - var calculateUrl = "/calc/cal_bond_value"; CalcBondRequest request = new CalcBondRequest() { bondId = underlyingCode, @@ -61,7 +132,21 @@ namespace YLErp.Helpers try { // http 请求 Web项目接口 - var result = httpHelper.PostRequestNoAuth(calculateUrl, request).Result; + var result = httpHelper.PostRequestNoAuth(CalcUrl, request).Result; + if (result == null || !result.success || result.data == null || result.data.errCode != 0) + { + var msg = result != null ? (result.message ?? (result.data != null ? result.data.errMsg : null)) : "计算器服务无响应"; + LogFactory.GetLogger("BondCalcHelper").Error("债券计算失败:" + msg); + return null; + } + // 防御性值域校验(同 BondCalc):拦截"成功但数值离谱"的响应,不回写坏数据 + string absurdReason; + if (IsResultAbsurd(result.data, out absurdReason)) + { + LogFactory.GetLogger("BondCalcHelper").Error( + $"债券计算返回离谱值(bondId={underlyingCode}):{absurdReason} | cleanPrice={result.data.cleanPrice} dirtyPrice={result.data.dirtyPrice} ytm={result.data.ytm}"); + return null; + } return result.data; } catch (Exception ex) @@ -71,6 +156,38 @@ namespace YLErp.Helpers } return null; } + + /// + /// 防御性值域校验:判断计算器返回值是否"离谱"(errCode=0 但净价变负 / 收益率量级爆炸 / 命中哨兵值)。 + /// 命中则上层返回 null、不回写,避免把不可信结果覆盖用户手工输入。规则: + /// - 净价/全价:占面值百分比,正常约 20~300,必须为正且不破千(≤0 或 >1000 视为离谱); + /// - 收益率(ytm,百分数口径):|ytm| > 100 视为爆炸(允许负利率债,但量级须合理); + /// - 哨兵值:任一字段命中 -999999 或 -999999×100(如 yieldToCP 不可用哨兵透传)。 + /// 与前端 swapCalc.js::getBondCalcErrorMessage 的值域闸门保持一致口径。 + /// + private static bool IsResultAbsurd(CalBondResult data, out string reason) + { + reason = null; + if (data == null) return false; + const decimal SENTINEL = -999999m; + bool Absurd(decimal v) => v == SENTINEL || v == SENTINEL * 100m; + if (Absurd(data.cleanPrice) || Absurd(data.dirtyPrice) || (data.ytm.HasValue && Absurd(data.ytm.Value))) + { + reason = "债券计算返回哨兵值(部分指标不可用),请检查估值日/价格输入或联系管理员核对债券计算服务"; + return true; + } + if (data.cleanPrice <= 0m || data.cleanPrice > 1000m || data.dirtyPrice <= 0m || data.dirtyPrice > 1000m) + { + reason = "债券计算净价/全价超出合理范围(应为正值且接近面值百分比),请检查估值日/价格输入或联系管理员核对债券计算服务"; + return true; + } + if (data.ytm.HasValue && Math.Abs(data.ytm.Value) > 100m) + { + reason = "债券计算收益率量级异常(" + data.ytm.Value + "),请检查估值日/价格输入或联系管理员核对债券计算服务"; + return true; + } + return false; + } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapConsumerService.cs b/YLErpDAL/Modules/SwapModule/SwapConsumerService.cs index 79b1a1a9..e5504ce7 100644 --- a/YLErpDAL/Modules/SwapModule/SwapConsumerService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapConsumerService.cs @@ -137,7 +137,13 @@ namespace YLErp.Modules.SwapModule swapFlow.OptTime = result.OptTime; swapFlow.SettleDate = result.SettleDate; swapFlow.TradingAmount = result.TradingAmount; - swapFlow.TradingAmountAvg = result.TradingAmountAvg; + var underlying = string.IsNullOrEmpty(result.UnderlyingCode) + ? null + : DataCacheProvider.GetUnderlyingDataSource().GetData(result.UnderlyingCode); + var storagePriceRound = underlying?.IsBond() == true + ? ConsGlobal.PriceRound + : ConsGlobal.SwapDeliveryPriceRound; + swapFlow.TradingAmountAvg = Math.Round(result.TradingAmountAvg, storagePriceRound, MidpointRounding.AwayFromZero); swapFlow.TradingAmountFeeAvg = result.TradingAmountFeeAvg; swapFlow.TradingAmountNet = result.TradingAmountNet; swapFlow.TradingAmountNetFee = result.TradingAmountNetFee; diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index eb960cc4..05182f5f 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1,4 +1,4 @@ -using MoreLinq.Extensions; +using MoreLinq.Extensions; using Newtonsoft.Json; using Qdp.Pricing.Library.Base.Utilities; using System.Linq.Expressions; @@ -36,9 +36,61 @@ namespace YLErp.Modules.SwapModule /// 原 private 改 protected virtual,使测试 stub 可整体 override,规避内部 new SwapEventService 连库。 protected virtual long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) { + NormalizeNotionalValues(unwindData); + NormalizeDeliveryPrices(unwindData.FlowEvents); return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve); } + private static void NormalizeNotionalValues(UnwindData unwindData) + { + unwindData.NotionalValue = Math.Round(unwindData.NotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + unwindData.PosiNotionalValue = Math.Round(unwindData.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + private static int GetStorageDeliveryPriceRound(swap_flow_event flowEvent) + { + if (ConsGlobal.InstrumentType.IsBond(flowEvent?.UnderlyingInstrumentType)) + { + return ConsGlobal.PriceRound; + } + if (string.IsNullOrEmpty(flowEvent?.UnderlyingCode)) + { + return ConsGlobal.SwapDeliveryPriceRound; + } + var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent?.UnderlyingCode); + return underlying?.IsBond() == true ? ConsGlobal.PriceRound : ConsGlobal.SwapDeliveryPriceRound; + } + + private static void ValidateDeliveryPrices(UnwindData unwindData) + { + if (unwindData.FlowEvents == null) + { + return; + } + foreach (var item in unwindData.FlowEvents.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode))) + { + var roundedPrice = Math.Round(item.TradingAmountAvg, GetStorageDeliveryPriceRound(item), MidpointRounding.AwayFromZero); + if (item.TradingAmountAvg != roundedPrice) + { + throw new ServiceException($"期末交割价最多保留{ConsGlobal.SwapDeliveryPriceRound}位小数"); + } + item.TradingAmountAvg = roundedPrice; + } + } + + private static void NormalizeDeliveryPrices(IEnumerable flowEvents) + { + if (flowEvents == null) + { + return; + } + foreach (var item in flowEvents.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode))) + { + item.TradingAmountAvg = Math.Round(item.TradingAmountAvg, GetStorageDeliveryPriceRound(item), MidpointRounding.AwayFromZero); + } + } + /// 保存所有变更(生产: DbContext.SaveChanges;测试: 空操作) protected virtual void SaveAllChanges() { @@ -131,12 +183,14 @@ namespace YLErp.Modules.SwapModule #region 前端盈亏只读校验(不阻断交易) + /// /// 用 FrontendCalcReference 公式重算盈亏,与前端传来的 unwindData 比对, /// 差异 > 0.01 记 Error 日志。整体 try/catch 吞异常——校验自身错误绝不阻断交易。 /// /// 目的:前端保持快速反馈(用户改输入立即算),后端不替代前端,仅做合理性兜底, /// 为将来公式统一积累"前后端差异"数据。 + /// 核心比对逻辑已抽到 SwapFrontendPnlValidator.BuildFrontendValidationDiffs 纯函数,便于单测覆盖。 /// /// 前端算好传入的结算数据 /// true=结息页(income公式),false=平仓页(unwind公式) @@ -144,51 +198,16 @@ namespace YLErp.Modules.SwapModule { try { - // 取浮动腿(有 UnderlyingCode 的),与前端 initDeal 取法一致 + var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome); + if (diffs == null) return; + + // 取浮动腿用于日志上下文(与原实现一致) var floatLeg = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); - // PosiGrossPrice 是 [NotMapped],前端可能没传;为空/0 时跳过(避免误报) - if (floatLeg == null || floatLeg.PosiGrossPrice == 0) + foreach (var d in diffs) { - return; + Logger.Error($"[互换盈亏校验分歧] tradeId={unwindData.SwapTradeId} field={d.Field} frontend={d.FrontendValue} backend={d.BackendValue} diff={d.Delta} " + + $"floatLeg=[gross={floatLeg?.PosiGrossPrice} avg={floatLeg?.TradingAmountAvg} qty={floatLeg?.Quantity} payDir={floatLeg?.PayDirection} posType={floatLeg?.PositionType}]"); } - - // 用 UnderlyingInstrumentType 推 Multiplier(债券=100,否则1) - bool isBond = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType); - int multiplier = isBond ? 100 : 1; - - // 分类利息腿/预付金腿(InterestMode 初始预付金/追加预付金→Margin,否则→Interest) - var input = new UnwindInput - { - Multiplier = multiplier, - PosiGrossPrice = floatLeg.PosiGrossPrice, // EntryDirtyPrice - TradingAmountAvg = floatLeg.TradingAmountAvg, // ExitDirtyPrice(界面×multiplier形态) - CloseQty = unwindData.CloseQty, - PositionQty = unwindData.PositionQty, - ContractSize = floatLeg.ContractSize, - CloseNotionalValue = unwindData.CloseNotionalValue, - PayDirection = floatLeg.PayDirection, - PositionType = floatLeg.PositionType, - TradingFee = floatLeg.TradingFee.ToString(), - TradingFeePending = floatLeg.TradingFeePending.ToString(), - DividendIn = floatLeg.DividendIn.ToString(), - }; - foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) - { - var target = (leg.InterestMode == (int)InterestModeEnum.初始预付金 - || leg.InterestMode == (int)InterestModeEnum.追加预付金) - ? input.MarginLegs : input.InterestLegs; - target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL }); - } - - var recalc = isIncome - ? FrontendCalcReference.CalcIncome(input) - : FrontendCalcReference.CalcUnwind(input); - - // 逐字段比对,差异 > 0.01 告警 - const decimal threshold = 0.01m; - CheckDiff(nameof(recalc.SwapRealizedPnL), unwindData.SwapRealizedPnL, recalc.SwapRealizedPnL, threshold, unwindData.SwapTradeId, floatLeg); - CheckDiff(nameof(recalc.SwapCloseAmount), unwindData.SwapCloseAmount, recalc.SwapCloseAmount, threshold, unwindData.SwapTradeId, floatLeg); - CheckDiff("MarkClosePnl", floatLeg.MarkClosePnl, recalc.MarkClosePnl, threshold, unwindData.SwapTradeId, floatLeg); } catch (Exception ex) { @@ -197,16 +216,6 @@ namespace YLErp.Modules.SwapModule } } - private void CheckDiff(string field, decimal frontendVal, decimal backendVal, decimal threshold, int tradeId, swap_flow_event floatLeg) - { - decimal diff = frontendVal - backendVal; - if (Math.Abs(diff) > threshold) - { - Logger.Error($"[互换盈亏校验分歧] tradeId={tradeId} field={field} frontend={frontendVal} backend={backendVal} diff={diff} " + - $"floatLeg=[gross={floatLeg.PosiGrossPrice} avg={floatLeg.TradingAmountAvg} qty={floatLeg.Quantity} payDir={floatLeg.PayDirection} posType={floatLeg.PositionType}]"); - } - } - #endregion /// @@ -1265,6 +1274,8 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + ValidateDeliveryPrices(unwindData); + NormalizeNotionalValues(unwindData); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 @@ -1295,7 +1306,7 @@ namespace YLErp.Modules.SwapModule td.HasPartialUnWind = 1; } td.UnWindDate = unwindData.UnwindDate; - td.StockEqvNotional -= Convert.ToDouble(unwindData.CloseNotionalValue); + td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); td.TradeAmount -= Convert.ToDouble(unwindData.CloseQty); SaveAllChanges(); cofirm = true; @@ -1321,6 +1332,10 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); + var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType) + ? ConsGlobal.PriceRound + : ConsGlobal.SwapDeliveryPriceRound; + unwindPrice = Math.Round(unwindPrice, storagePriceRound, MidpointRounding.AwayFromZero); var preDealDate = GetPreDealDate(td.id, dealDate, eventTypes); swap_flow_event floatEvent = new swap_flow_event(); UnwindData unwindData = new UnwindData(); @@ -1611,7 +1626,7 @@ namespace YLErp.Modules.SwapModule td.HasPartialUnWind = 1; } td.UnWindDate = unwindData.UnwindDate; - td.StockEqvNotional -= Convert.ToDouble(unwindData.CloseNotionalValue); + td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); td.TradeAmount -= Convert.ToDouble(unwindData.CloseQty); td.Notional = td.TradeAmount; td.OptDate = DateTime.Now; @@ -1744,6 +1759,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + ValidateDeliveryPrices(unwindData); NormalizeIncomeUnwindDate(unwindData); ValidateIncomeValueDate(unwindData, td); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -1784,12 +1800,14 @@ namespace YLErp.Modules.SwapModule throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); + NormalizeDeliveryPrices(swapEvent.unwindData.FlowEvents); if (eventType == (int)SwapEventTypeEnum.互换) { NormalizeIncomeUnwindDate(swapEvent.unwindData); ValidateIncomeValueDate(swapEvent.unwindData, td); } var flowList = FindFlowEventsByEventId(swapEvent.id); + NormalizeDeliveryPrices(flowList); string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; int clientCashId = AddClientCash(td, Convert.ToDouble(-swapEvent.unwindData.SwapRealizedPnL), action, swapEvent.unwindData.ValueDate); if (swapEvent.unwindData.SwapMarginAmount != 0) @@ -1810,7 +1828,7 @@ namespace YLErp.Modules.SwapModule td.UnWindDate = swapEvent.unwindData.UnwindDate; if (eventType != (int)SwapEventTypeEnum.互换) { - td.StockEqvNotional -= Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue); + td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); td.TradeAmount -= Convert.ToDouble(swapEvent.unwindData.CloseQty); } @@ -1832,6 +1850,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + ValidateDeliveryPrices(unwindData); if (eventType == (int)SwapEventTypeEnum.互换) { NormalizeIncomeUnwindDate(unwindData); @@ -1976,7 +1995,7 @@ namespace YLErp.Modules.SwapModule { // 平仓时才扣减持仓 position.PosiQuantity -= unwindData.CloseQty; - position.PosiNotionalValue -= unwindData.CloseNotionalValue; + position.PosiNotionalValue = Math.Round(position.PosiNotionalValue - unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); position.PosiTradingFee -= position.PosiTradingFee * unwindData.ClosePercent; position.PosiTradingFeePending -= position.PosiTradingFeePending * unwindData.ClosePercent; } @@ -1999,3 +2018,5 @@ namespace YLErp.Modules.SwapModule } } + + diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 6865b195..9875528a 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -32,11 +32,30 @@ namespace YLErp.Modules.SwapModule } + private int GetStorageDeliveryPriceRound(string underlyingInstrumentType, string underlyingCode) + { + if (ConsGlobal.InstrumentType.IsBond(underlyingInstrumentType)) + { + return ConsGlobal.PriceRound; + } + if (string.IsNullOrEmpty(underlyingCode)) + { + return ConsGlobal.SwapDeliveryPriceRound; + } + return GetUnderlyingData(underlyingCode)?.IsBond() == true + ? ConsGlobal.PriceRound + : ConsGlobal.SwapDeliveryPriceRound; + } + #region 可测试化接缝(Seams)——override 这些虚方法可在测试中替换 DB/外部调用,生产代码行为不变 /// 持久化 eod 持仓记录(生产: DbContext.Add;测试: 收集到列表) protected virtual void PersistEodSwapPosition(eod_swap_position position) { + var storagePriceRound = GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode); + position.PosiGrossPrice = Math.Round(position.PosiGrossPrice, storagePriceRound, MidpointRounding.AwayFromZero); + position.UnderlyingPrice = Math.Round(position.UnderlyingPrice, storagePriceRound, MidpointRounding.AwayFromZero); + position.PosiNotionalValue = Math.Round(position.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); if (position.id == 0) { DbContext.eod_swap_position.Add(position); @@ -156,34 +175,18 @@ namespace YLErp.Modules.SwapModule return UnderlyingCodePrice(code, settleDate, out vobp); } - /// - /// 获取用于互换浮动腿盯市的标的价格。 - /// - /// 普通债券类收益互换的新录入页面将全价按小数保存,例如页面录入 20% 后 - /// PosiGrossPrice 为 0.2;而历史交易中仍可能存在直接保存为 20 的展示态价格。 - /// 中债估值正常经 EodPriceQueryService 转换后应为小数价格,但手工维护的历史 - /// 行情可能仍以展示态进入该服务,例如 2000 经一次转换后得到 20。若将 20 - /// 与 0.2 直接相减,会把 20% 的价格差误算成 1,980,000 的浮动损益。 - /// - /// 因此仅当交易期初价已经是小数口径、且当前债券价明显仍处于展示态时,再做 - /// 一次展示态到存储态转换。期初价本身是历史展示态口径的存量交易保持原价格, - /// 避免修改日终估值链路后改变其既有损益。 - /// - private decimal GetSwapValuationPrice(string code, decimal posiGrossPrice, DateTime settleDate, out decimal vobp) + /// 获取用于互换浮动腿盯市的标的价格。 + private decimal GetSwapValuationPrice(string code, DateTime settleDate, out decimal vobp) { var price = GetUnderlyingPrice(code, settleDate, out vobp); var underlying = GetUnderlyingData(code); - var usesStoragePrice = Math.Abs(posiGrossPrice) < 2m; - var usesDisplayPrice = Math.Abs(price) >= 10m; - if (underlying?.IsBond() == true && usesStoragePrice && usesDisplayPrice) + if (underlying?.IsBond() == true) { - var normalizedPrice = BondPriceConverter.ToStorage(price); - Log.Error($"互换债券日终价格按展示态返回,已转换为存储态: UnderlyingCode={code}, ValueDate={settleDate:yyyy-MM-dd}, PosiGrossPrice={posiGrossPrice}, SourcePrice={price}, NormalizedPrice={normalizedPrice}"); - return normalizedPrice; + return Math.Round(price, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); } - return price; + return Math.Round(price, ConsGlobal.SwapDeliveryPriceRound, MidpointRounding.AwayFromZero); } /// 获取标的缓存数据(生产: DataCacheProvider;测试: 返回内存对象) @@ -395,7 +398,7 @@ namespace YLErp.Modules.SwapModule /// 自动互换集合 /// 多空组合平仓利息腿信息 /// 上一日终框架合约 - protected void DealInterests(List interestList, + protected virtual void DealInterests(List interestList, List eodPositions, List todyEodPositions, DateTime settleDate, @@ -1023,6 +1026,7 @@ namespace YLErp.Modules.SwapModule //累计已实现 newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; + SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, position.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); @@ -1156,7 +1160,7 @@ namespace YLErp.Modules.SwapModule //累计已实现 newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; - newEodPayPosition.RealizedPnl = newEodPayPosition.RealizedInterest + newEodPayPosition.RealizedInterestFee; + SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, position.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); @@ -1292,7 +1296,7 @@ namespace YLErp.Modules.SwapModule //累计已实现 newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; - newEodPayPosition.RealizedPnl = newEodPayPosition.RealizedInterest + newEodPayPosition.RealizedInterestFee; ; + SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, position.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); @@ -1414,7 +1418,7 @@ namespace YLErp.Modules.SwapModule //累计已实现 newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; - newEodPayPosition.RealizedPnl = newEodPayPosition.RealizedInterest + newEodPayPosition.RealizedInterestFee; + SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, eodPayPosition.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); @@ -1471,7 +1475,10 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.ContractSize = eventFlow.ContractSize; newEodPayPosition.CountRatio = eventFlow.CountRatio; newEodPayPosition.PosiNetPrice = netPrice; - newEodPayPosition.PosiGrossPrice = grossPrice; + newEodPayPosition.PosiGrossPrice = Math.Round( + grossPrice, + GetStorageDeliveryPriceRound(eventFlow.UnderlyingInstrumentType, eventFlow.UnderlyingCode), + MidpointRounding.AwayFromZero); newEodPayPosition.PosiNetFeePrice = netFeePrice; newEodPayPosition.PosiNetNoFeePrice = netNoFeePrice; newEodPayPosition.PosiQuantity = payQty; @@ -1556,7 +1563,7 @@ namespace YLErp.Modules.SwapModule int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; - var price = GetSwapValuationPrice(eod.UnderlyingCode, eod.PosiGrossPrice, dealDate, out decimal vobp); + var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); decimal tax = um.ValueAddedTax ?? 0; if (valueDate > td.StartDate.Value && curretEod.PosiQuantity > 0) @@ -1648,7 +1655,7 @@ namespace YLErp.Modules.SwapModule var dealDate = curretEod.ValueDate; int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; - var price = GetSwapValuationPrice(eod.UnderlyingCode, eod.PosiGrossPrice, dealDate, out decimal vobp); + var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents); var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice; decimal totalPayment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio); @@ -1770,7 +1777,10 @@ namespace YLErp.Modules.SwapModule posiQty = 0; } curretEod.PosiGrossPrice = (eod.PosiGrossPrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountAvg)) / (eod.PosiQuantity + openQty); - curretEod.PosiGrossPrice = Math.Round(curretEod.PosiGrossPrice, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + curretEod.PosiGrossPrice = Math.Round( + curretEod.PosiGrossPrice, + GetStorageDeliveryPriceRound(curretEod.UnderlyingInstrumentType, curretEod.UnderlyingCode), + MidpointRounding.AwayFromZero); curretEod.PosiNetPrice = (eod.PosiNetPrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountFeeAvg)) / (eod.PosiQuantity + openQty); curretEod.PosiNetPrice = Math.Round(curretEod.PosiNetPrice, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); curretEod.PosiNetNoFeePrice = (eod.PosiNetNoFeePrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountNetAvg)) / (eod.PosiQuantity + openQty); @@ -1834,7 +1844,7 @@ namespace YLErp.Modules.SwapModule curretEod.ContractSize = position.ContractSize; curretEod.CountRatio = position.CountRatio; curretEod.PosiTradingFee = position.PosiTradingFee; - curretEod.UnderlyingPrice = GetSwapValuationPrice(position.UnderlyingCode, position.PosiGrossPrice, dealDate, out decimal vobp); + curretEod.UnderlyingPrice = GetSwapValuationPrice(position.UnderlyingCode, dealDate, out decimal vobp); SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, position); curretEod.dv01 = Dv01Helper.CalcDv01(curretEod.UnderlyingCode, curretEod.PosiQuantity, curretEod.PosiDirection, curretEod.PositionType, vobp); //if (settleDate == td.TradeDate) @@ -1896,14 +1906,14 @@ namespace YLErp.Modules.SwapModule } if (data.IsBond()) { - return BondPrice(data, settleDate, out vobp); + return Math.Round(BondPrice(data, settleDate, out vobp), ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); } var price = data.Price ?? 0; if (EodPriceQueryService.TryGetEodPrice(settleDate, code, out var eodPrice)) { price = eodPrice.GetPrice(SettlementTypeEnum.ClosePrice); } - return Convert.ToDecimal(price); + return Math.Round(Convert.ToDecimal(price), ConsGlobal.SwapDeliveryPriceRound, MidpointRounding.AwayFromZero); } /// /// 获取债券收盘价格 @@ -1948,9 +1958,9 @@ namespace YLErp.Modules.SwapModule var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 // 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模, // 不能直接用多空腿相加,否则会把对冲方向误当成合约规模变化。 - eod_Swap.NotionalValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); - eod_Swap.NotionalValueShort = -Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)); - eod_Swap.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional); + eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + eod_Swap.NotionalValue = Math.Round(Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); eod_Swap.SwapTradeId = td.id; eod_Swap.SwapTradeNo = td.TradeNumber; eod_Swap.ClientId = td.ClientId; @@ -2025,9 +2035,9 @@ namespace YLErp.Modules.SwapModule var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList(); var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿 var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 - eod_Swap.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional); - eod_Swap.NotionalValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); - eod_Swap.NotionalValueShort = -Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)); + eod_Swap.NotionalValue = Math.Round(Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue); eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue); eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum); @@ -2086,6 +2096,28 @@ namespace YLErp.Modules.SwapModule + position.RealizedInterestFee; } + /// + /// 风险报表符号归一化:把历史两种符号口径的 TdCloseInterest/RealizedInterest + /// 统一按"绝对金额 × 业务方向"重写。普通利息腿收取为正、支付为负; + /// 预付金腿利息方向与保证金本金方向相反。随后重算 RealizedPnl。 + /// 抽为 public static 纯函数以支持无库单测(见 SwapReportInterestSignNormalizeTest)。 + /// 仅当 InterestDirection > 0 时执行(与原内联逻辑等价)。 + /// + public static void NormalizeInterestSignForReport(eod_swap_position position) + { + if (position.InterestDirection <= 0) return; + + var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; + if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) + { + interestRatio = -interestRatio; + } + position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio; + position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio; + // 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。 + position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; + } + /// /// 获取多空组合 平仓详细 /// @@ -2178,6 +2210,9 @@ namespace YLErp.Modules.SwapModule item.eodPosition.PosiNetFeePrice *= multiplier; item.eodPosition.PosiNetNoFeePrice *= multiplier; item.eodPosition.UnderlyingPrice *= multiplier; + // 历史数据的 TdCloseInterest、RealizedInterest 存在两种符号口径, + // 风险报表统一按绝对金额和业务方向还原,并重算 RealizedPnl。 + NormalizeInterestSignForReport(item.eodPosition); } return retListResult; @@ -2292,24 +2327,23 @@ namespace YLErp.Modules.SwapModule item.PeriodPaymentValuation = item.FloatingUnrealizedPnl + item.position.InterestPnL; } // eod_swap 的保证金本金来自 trade_span;缺少 span 数据时会被保存为 0。 - // 本风险页改按日终保证金腿展示,且该页面保证金本金采用原始本金的 1/10 口径。 - // 利息仍使用原始本金累积值,不能同步缩放,否则会破坏保证金利息金额。 + // 本风险页改按日终保证金腿的实际本金展示。 item.position.InitMarginGain = marginLegs .Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 && x.InterestDirection == (int)SwapDirectionEnum.收取) - .Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m; + .Sum(x => Math.Abs(x.InterestPrincipalFix)); item.position.InitMarginLoss = marginLegs .Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 && x.InterestDirection == (int)SwapDirectionEnum.支付) - .Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m; + .Sum(x => Math.Abs(x.InterestPrincipalFix)); item.position.PostionMarginGain = marginLegs .Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金 && x.InterestDirection == (int)SwapDirectionEnum.收取) - .Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m; + .Sum(x => Math.Abs(x.InterestPrincipalFix)); item.position.PostionMarginLoss = marginLegs .Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金 && x.InterestDirection == (int)SwapDirectionEnum.支付) - .Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m; + .Sum(x => Math.Abs(x.InterestPrincipalFix)); // 保证金本金方向与我方的利息现金流方向相反:原始“收取”保证金 // 表示我方占用客户资金,应向客户支付利息;支付金额按负数展示。 @@ -2611,18 +2645,25 @@ namespace YLErp.Modules.SwapModule } /// - /// 计算预付金利息。先按收取为正、支付为负转换为我方视角, - /// 再按日终本金绝对值加权平均;本金合计为零时返回零。 + /// 计算预付金利息金额。InterestIncomeSum 已是各腿利息金额, + /// 按收取为正、支付为负直接轧差求和,不做本金加权。 + /// 抽为 public static 纯函数以支持无库单测(见 SwapWeightedMarginInterestTest)。 /// - private static decimal CalculateWeightedMarginInterest(IEnumerable margins) + public static decimal CalculateWeightedMarginInterest(IEnumerable margins) { - var marginList = margins.ToList(); - var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix)); - return totalWeight == 0 - ? 0 - : marginList.Sum(x => x.InterestIncomeSum - * (x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1) - * Math.Abs(x.InterestPrincipalFix)) / totalWeight; + return margins.Sum(x => + x.InterestIncomeSum * (x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); + } + + /// + /// 固定利息腿的累计已实现盈亏 = 累计已实现利息 + 累计已实现利息费用。 + /// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径口径一致, + /// 抽为 public static 纯函数以支持无库单测(见 SwapFixedLegRealizedPnlTest), + /// 并消除复制粘贴带来的笔误风险(如 L1296 历史双分号)。 + /// + public static void SetFixedLegRealizedPnl(eod_swap_position position) + { + position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; } /// /// 将数据库中以公司/交易簿记方向保存的日终字段转换为客户视角。 diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs index 39eb73e4..2b28cca0 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs @@ -51,6 +51,21 @@ namespace YLErp.Modules.SwapModule protected virtual underlying_manager GetUnderlying(string underlyingCode) => DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode); + private int GetStorageDeliveryPriceRound(string underlyingInstrumentType, string underlyingCode) + { + if (ConsGlobal.InstrumentType.IsBond(underlyingInstrumentType)) + { + return ConsGlobal.PriceRound; + } + if (string.IsNullOrEmpty(underlyingCode)) + { + return ConsGlobal.SwapDeliveryPriceRound; + } + return GetUnderlying(underlyingCode)?.IsBond() == true + ? ConsGlobal.PriceRound + : ConsGlobal.SwapDeliveryPriceRound; + } + protected virtual DateTime GetNextBusinessDay(DateTime date) => QdpCalendarHelper.GetNonHoliday(date); @@ -61,6 +76,13 @@ namespace YLErp.Modules.SwapModule { foreach (var evt in events) { + if (!string.IsNullOrEmpty(evt.UnderlyingCode)) + { + evt.TradingAmountAvg = Math.Round( + evt.TradingAmountAvg, + GetStorageDeliveryPriceRound(evt.UnderlyingInstrumentType, evt.UnderlyingCode), + MidpointRounding.AwayFromZero); + } DbContext.swap_flow_event.Add(evt); } DbContext.SaveChanges(); @@ -321,7 +343,7 @@ namespace YLErp.Modules.SwapModule DataState = 1, EventDate = flow_merge.OccurTime, UnwindDate = QdpCalendarHelper.GetNonHoliday(flow_merge.OccurTime.AddDays(1)), - TradingAmountAvg = TradingAmountAvg, + TradingAmountAvg = Math.Round(TradingAmountAvg, GetStorageDeliveryPriceRound(underlyingInstrumentType, flow_merge.UnderlyingCode), MidpointRounding.AwayFromZero), TradingAmountFeeAvg = TradingAmountFeeAvg, TradingAmountNetFeeAvg = TradingAmountNetFeeAvg, TradingAmountNetAvg = flow_merge.TradingAmountNetAvg, @@ -375,7 +397,10 @@ namespace YLErp.Modules.SwapModule DataState = 1, EventDate = flow_merge.OccurTime, UnwindDate = QdpCalendarHelper.GetNonHoliday(flow_merge.OccurTime.AddDays(1)), - TradingAmountAvg = flow_merge.TradingAmountAvg, + TradingAmountAvg = Math.Round( + flow_merge.TradingAmountAvg, + GetStorageDeliveryPriceRound(null, flow_merge.UnderlyingCode), + MidpointRounding.AwayFromZero), TradingAmountFeeAvg = flow_merge.TradingAmountFeeAvg, TradingFeePending = flow_merge.TradingFeePending, ClientId = flow_merge.ClientId @@ -409,7 +434,10 @@ namespace YLErp.Modules.SwapModule DataState = (int)SwapFlowDateStateEnum.完成, EventDate = td.TradeDate.Value, UnwindDate = td.StartDate.Value, - TradingAmountAvg = position.PosiGrossPrice, + TradingAmountAvg = Math.Round( + position.PosiGrossPrice, + GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode), + MidpointRounding.AwayFromZero), TradingAmountFeeAvg = position.PosiNetPrice, TradingAmountNetFeeAvg = position.PosiNetFeePrice, TradingAmountNetAvg = position.PosiNetNoFeePrice, @@ -462,7 +490,7 @@ namespace YLErp.Modules.SwapModule DataState = 100, EventDate = td.TradeDate.Value, UnwindDate = QdpCalendarHelper.GetNonHoliday(td.TradeDate.Value.AddDays(1)), - TradingAmountAvg = flowMerge.TradingAmountAvg, + TradingAmountAvg = Math.Round(flowMerge.TradingAmountAvg, GetStorageDeliveryPriceRound(underlyingInstrumentType, flowMerge.UnderlyingCode), MidpointRounding.AwayFromZero), TradingAmountFeeAvg = flowMerge.TradingAmountFeeAvg, TradingAmountNetFeeAvg = flowMerge.TradingAmountNetFeeAvg, TradingAmountNetAvg = flowMerge.TradingAmountNetAvg, diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowImportService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowImportService.cs index fb65eff8..3ee3e891 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowImportService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowImportService.cs @@ -118,7 +118,8 @@ namespace YLErp.Modules.SwapModule swap_flow.ytm = reader.GetDecimalOrPercent("成交收益率",false,true) ?? 0; swap_flow.TradingAmountNet = reader.GetDecimal("成交净价") ?? 0; swap_flow.TradingAmountNetFee = TradeFeeHelper.CalcPriceWithFee(swap_flow.TradingFee, swap_flow.TradingAmountNet??0, swap_flow.TradingQty, swap_flow.BsType); - if (underlying != null && underlying.IsBond()) + var isBond = underlying != null && underlying.IsBond(); + if (isBond) { // 成交流水债券报价(×100形式)转入库小数(×0.01),统一走 BondPriceConverter swap_flow.TradingAmountAvg = BondPriceConverter.ToStorage(swap_flow.TradingAmountAvg); @@ -129,6 +130,10 @@ namespace YLErp.Modules.SwapModule if (swap_flow.TradingAmountNetFee.HasValue) swap_flow.TradingAmountNetFee = BondPriceConverter.ToStorage(swap_flow.TradingAmountNetFee.Value); } + swap_flow.TradingAmountAvg = Math.Round( + swap_flow.TradingAmountAvg, + isBond ? ConsGlobal.PriceRound : ConsGlobal.SwapDeliveryPriceRound, + MidpointRounding.AwayFromZero); if (!string.IsNullOrEmpty(clientName)) { var client = DataCacheProvider.GetClientDataSource().AsQueryable(x=>x.Name== clientName).FirstOrDefault(); diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs index 17b17901..430a5e53 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs @@ -695,7 +695,7 @@ namespace YLErp.Modules.SwapModule TradingFee = gourpItem.Sum(s => s.TradingFee), DataState = (int)SwapFlowDateStateEnum.等待完成, TradingAmountFeeAvg = gourpItem.Average(s => s.TradingAmountFeeAvg), - TradingAmountAvg = gourpItem.Average(s => s.TradingAmountAvg), + TradingAmountAvg = Math.Round(gourpItem.Average(s => s.TradingAmountAvg), ConsGlobal.PriceRound, MidpointRounding.AwayFromZero), ContractSize = swapflow.ContractSize }; UpdateDbOption(swap_flow_summary); @@ -747,12 +747,18 @@ namespace YLErp.Modules.SwapModule private void CheckValid(swap_flow req) { CheckRequired(req); + var roundedPrice = Math.Round(req.TradingAmountAvg, ConsGlobal.SwapDeliveryPriceRound, MidpointRounding.AwayFromZero); + if (req.TradingAmountAvg != roundedPrice) + { + throw new ServiceException($"成交全价最多保留{ConsGlobal.SwapDeliveryPriceRound}位小数"); + } var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(req.UnderlyingCode); if (underlying == null) { throw new ServiceException("没有找到标的信息:" + req.UnderlyingCode); } - if (underlying != null && underlying.IsBond()) + var isBond = underlying.IsBond(); + if (isBond) { // 债券报价(×100)转入库小数(×0.01),价格字段统一走 BondPriceConverter req.TradingAmountAvg = BondPriceConverter.ToStorage(req.TradingAmountAvg); @@ -764,6 +770,10 @@ namespace YLErp.Modules.SwapModule // 数量×100(万手→手),与价格维度无关,保留常量 req.TradingQty *= ConsGlobal.bondShowPriceMultiple; } + req.TradingAmountAvg = Math.Round( + req.TradingAmountAvg, + isBond ? ConsGlobal.PriceRound : ConsGlobal.SwapDeliveryPriceRound, + MidpointRounding.AwayFromZero); } diff --git a/YLErpDAL/Modules/SwapModule/SwapFrontendPnlValidator.cs b/YLErpDAL/Modules/SwapModule/SwapFrontendPnlValidator.cs new file mode 100644 index 00000000..db4a1957 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/SwapFrontendPnlValidator.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MoreLinq.Extensions; +using Newtonsoft.Json; +using Qdp.Pricing.Library.Base.Utilities; +using System.Linq.Expressions; +using YLErp.BLL; +using YLErp.BLL.Eod; +using YLErp.DBModels.Enums; +using YLErp.Helpers; +using YLErp.Modules.DataProviderModule; +using YLErp.Modules.EodModule; +using YLErp.Modules.TradeModule; +using YLErp.Modules.TradeModule.DealModule; +using YLErp.QdpModule; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 单条前后端盈亏差异(纯数据,便于单测断言)。 + /// 原内嵌于 SwapDealService,因 SwapDealService 已属超大文件(2000+ 行), + /// 将其与本校验逻辑一并抽离,降低对超大文件的改动面。 + /// + public sealed class FrontendPnlDiff + { + public string Field { get; init; } = string.Empty; + public decimal FrontendValue { get; init; } + public decimal BackendValue { get; init; } + public decimal Delta => FrontendValue - BackendValue; + } + + /// + /// 前端盈亏只读校验:用 FrontendCalcReference 公式重算盈亏,与前端传来的 unwindData 逐字段比对。 + /// 纯函数(无副作用、无 DB/日志依赖),便于无库单测(见 SwapFrontendPnlValidateTest)。 + /// 返回 null 表示前置条件不满足(无浮动腿或 PosiGrossPrice=0),调用方应跳过。 + /// + /// 从 SwapDealService.ValidateFrontendPnL 抽出,原方法仅保留调用 + 日志。 + /// + public static class SwapFrontendPnlValidator + { + /// 前端算好传入的结算数据 + /// true=结息页(income公式),false=平仓页(unwind公式) + /// 差异阈值,默认 0.01 + public static List? BuildFrontendValidationDiffs( + UnwindData unwindData, bool isIncome, decimal threshold = 0.01m) + { + // 取浮动腿(有 UnderlyingCode 的),与前端 initDeal 取法一致 + var floatLeg = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + // PosiGrossPrice 是 [NotMapped],前端可能没传;为空/0 时跳过(避免误报) + if (floatLeg == null || floatLeg.PosiGrossPrice == 0) + { + return null; + } + + // 用 UnderlyingInstrumentType 推 Multiplier(债券=100,否则1) + bool isBond = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType); + int multiplier = isBond ? 100 : 1; + + // 分类利息腿/预付金腿(InterestMode 初始预付金/追加预付金→Margin,否则→Interest) + var input = new UnwindInput + { + Multiplier = multiplier, + PosiGrossPrice = floatLeg.PosiGrossPrice, // EntryDirtyPrice + TradingAmountAvg = floatLeg.TradingAmountAvg, // ExitDirtyPrice(界面×multiplier形态) + CloseQty = unwindData.CloseQty, + PositionQty = unwindData.PositionQty, + ContractSize = floatLeg.ContractSize, + CloseNotionalValue = unwindData.CloseNotionalValue, + PayDirection = floatLeg.PayDirection, + PositionType = floatLeg.PositionType, + TradingFee = floatLeg.TradingFee.ToString(), + TradingFeePending = floatLeg.TradingFeePending.ToString(), + DividendIn = floatLeg.DividendIn.ToString(), + }; + foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) + { + var target = (leg.InterestMode == (int)InterestModeEnum.初始预付金 + || leg.InterestMode == (int)InterestModeEnum.追加预付金) + ? input.MarginLegs : input.InterestLegs; + target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL }); + } + + var recalc = isIncome + ? FrontendCalcReference.CalcIncome(input) + : FrontendCalcReference.CalcUnwind(input); + + var diffs = new List(3); + AddDiffIfOverThreshold(diffs, nameof(recalc.SwapRealizedPnL), unwindData.SwapRealizedPnL, recalc.SwapRealizedPnL, threshold); + AddDiffIfOverThreshold(diffs, nameof(recalc.SwapCloseAmount), unwindData.SwapCloseAmount, recalc.SwapCloseAmount, threshold); + AddDiffIfOverThreshold(diffs, "MarkClosePnl", floatLeg.MarkClosePnl, recalc.MarkClosePnl, threshold); + return diffs; + } + + private static void AddDiffIfOverThreshold( + List diffs, string field, decimal frontendVal, decimal backendVal, decimal threshold) + { + decimal diff = frontendVal - backendVal; + if (Math.Abs(diff) > threshold) + { + diffs.Add(new FrontendPnlDiff + { + Field = field, + FrontendValue = frontendVal, + BackendValue = backendVal + }); + } + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeAutoService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeAutoService.cs index 75af7ea2..410112e4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeAutoService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeAutoService.cs @@ -154,7 +154,7 @@ namespace YLErp.Modules.SwapModule swapFlow.DataState = (int)SwapFlowDateStateEnum.等待完成; } // 债券报价(×100)转入库小数(×0.01),统一走 BondPriceConverter - swapFlow.TradingAmountAvg = BondPriceConverter.ToStorage(item.deal_full_price ?? 0); + swapFlow.TradingAmountAvg = Math.Round(BondPriceConverter.ToStorage(item.deal_full_price ?? 0), ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); swapFlow.TradingAmountFeeAvg = BondPriceConverter.ToStorage(item.deal_full_price_include_fee ?? 0); swapFlow.TradingAmount = swapFlow.TradingQty * swapFlow.ContractSize * swapFlow.TradingAmountAvg; swapFlow.ClientId = Convert.ToInt32(item.client_id ?? 0); @@ -508,6 +508,7 @@ namespace YLErp.Modules.SwapModule swap_flow_summary.SettleDate = gourpItem.Max(s => s.SettleDate); swap_flow_summary.TradingAmount = swap_flow_summary.TradingQty * swap_flow_summary.ContractSize; swap_flow_summary.TradingAmountAvg = swap_flow_summary.TradingQty == 0 ? 0 : gourpItem.Sum(s => s.FullPrice * s.TradingQty) / swap_flow_summary.TradingQty; + swap_flow_summary.TradingAmountAvg = Math.Round(swap_flow_summary.TradingAmountAvg, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); swap_flow_summary.TradingAmountFeeAvg = swap_flow_summary.TradingQty == 0 ? swap_flow_summary.TradingAmountAvg : swap_flow_summary.TradingAmountAvg + swap_flow_summary.TradingFeePending * tradeSide / swap_flow_summary.TradingQty; swap_flow_summary.TradingAmountNetAvg = swap_flow_summary.TradingQty == 0 ? 0 : gourpItem.Sum(s => s.NetPrice * s.TradingQty) / swap_flow_summary.TradingQty; swap_flow_summary.TradingAmountNetFeeAvg = swap_flow_summary.TradingQty == 0 ? swap_flow_summary.TradingAmountNetAvg : swap_flow_summary.TradingAmountNetAvg + swap_flow_summary.TradingFeePending * tradeSide / swap_flow_summary.TradingQty; @@ -1081,7 +1082,7 @@ namespace YLErp.Modules.SwapModule var ratio = flowMergeClone.BsType == 1 ? 1 : -1; var oriRatio = flowMergeClone.BsType == 1 ? -1 : 1; flowMergeClone.TradingFeePending = flowMergeClone.TradingQty / origin.TradingQty * origin.TradingFeePending; - flowMergeClone.TradingAmountAvg = origin.TradingAmountAvg + oriRatio * origin.TradingFeePending * 2 / origin.TradingQty; + flowMergeClone.TradingAmountAvg = Math.Round(origin.TradingAmountAvg + oriRatio * origin.TradingFeePending * 2 / origin.TradingQty, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); flowMergeClone.TradingAmountNetAvg = origin.TradingAmountNetAvg + oriRatio * origin.TradingFeePending * 2 / origin.TradingQty; flowMergeClone.TradingAmountFeeAvg = flowMergeClone.TradingAmountAvg + ratio * flowMergeClone.TradingFeePending / flowMergeClone.TradingQty; diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs index 90a018be..bfc2e45d 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs @@ -62,7 +62,7 @@ namespace YLErp.Modules.SwapModule position.PosiGrossPrice = eodPayPosition.PosiGrossPrice; position.PosiNetFeePrice = eodPayPosition.PosiNetFeePrice; position.PosiNetNoFeePrice = eodPayPosition.PosiNetNoFeePrice; - position.PosiNotionalValue = eodPayPosition.PosiNotionalValue; + position.PosiNotionalValue = Math.Round(eodPayPosition.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); position.PosiQuantity = eodPayPosition.PosiQuantity; position.PosiStartDate = eodPayPosition.PosiStartDate; position.OptTime = DateTime.Now; @@ -85,7 +85,7 @@ namespace YLErp.Modules.SwapModule position.PosiGrossPrice = eodPayPosition.PosiGrossPrice; position.PosiNetFeePrice = eodPayPosition.PosiNetFeePrice; position.PosiNetNoFeePrice = eodPayPosition.PosiNetNoFeePrice; - position.PosiNotionalValue = eodPayPosition.PosiNotionalValue; + position.PosiNotionalValue = Math.Round(eodPayPosition.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); position.PosiTradingFeePending = eodPayPosition.PosiFeePending; position.PosiQuantity = eodPayPosition.PosiQuantity; position.PosiDirection = eodPayPosition.PosiDirection; diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index f95b4718..c23de7b8 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using ClosedXML.Report.Options; using Confluent.Kafka; using CsvHelper; @@ -51,6 +51,24 @@ namespace YLErp.Modules.SwapModule { } + + private static decimal ValidateDeliveryPrice(decimal price, string fieldName) + { + var roundedPrice = Math.Round(price, ConsGlobal.SwapDeliveryPriceRound, MidpointRounding.AwayFromZero); + if (price != roundedPrice) + { + throw new ServiceException($"{fieldName}最多保留{ConsGlobal.SwapDeliveryPriceRound}位小数"); + } + return roundedPrice; + } + + private static decimal? RoundSwapBondNetPriceAndYtm(decimal? value) + { + return value.HasValue + ? Math.Round(value.Value, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero) + : null; + } + #region 互换交易保存 /// /// 新版收益互换预付金校验 @@ -227,6 +245,8 @@ namespace YLErp.Modules.SwapModule private trade InnerSaveNewTrade(trade req, bool tradeNumberGenerated) { var dbTrade = req.Clone(); + // 新增场景同样从 swap_positions 取 InitYtm,无则默认 0,避免数据库 NOT NULL 约束报错 + dbTrade.InitYtm = req.swap_positions.FirstOrDefault(p => p.InitYtm != null)?.InitYtm ?? 0; DbContext.trade.Add(dbTrade); InnerSaveTrade(true, dbTrade, ""); @@ -332,7 +352,7 @@ namespace YLErp.Modules.SwapModule AssetBookName = asset.Name, Notional = Convert.ToDouble(flowMerge.TradingQtyAbs), TradeAmount = Convert.ToDouble(flowMerge.TradingQtyAbs), - StockEqvNotional = Convert.ToDouble(flowMerge.TradingAmount), + StockEqvNotional = Math.Round(Convert.ToDouble(flowMerge.TradingAmount), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero), IsAutoGenerate = true, }; if (flowMerge.SettleDate.HasValue) @@ -350,7 +370,7 @@ namespace YLErp.Modules.SwapModule td.ValidState = "Valid"; td.TradeSource = "系统交易"; td.TradeStatus = ConsTrade.确认成交; - td.InitYtm = flowMerge.InitYtm ?? 0; + td.InitYtm = RoundSwapBondNetPriceAndYtm(flowMerge.InitYtm) ?? 0; return td; } /// @@ -370,11 +390,14 @@ namespace YLErp.Modules.SwapModule CountRatio = underlying.CountRatio, ContractSize = Convert.ToDecimal(underlying.ContractSize), PosiNetPrice = flowMerge.TradingAmountFeeAvgAbs, - PosiGrossPrice = flowMerge.TradingAmountAvg, + PosiGrossPrice = Math.Round( + flowMerge.TradingAmountAvg, + underlying.IsBond() ? ConsGlobal.PriceRound : ConsGlobal.SwapDeliveryPriceRound, + MidpointRounding.AwayFromZero), PosiNetFeePrice = flowMerge.TradingAmountNetFeeAvg ?? 0, PosiNetNoFeePrice = flowMerge.TradingAmountNetAvg ?? 0, PosiQuantity = flowMerge.TradingQtyAbs, - PosiNotionalValue = flowMerge.TradingAmount, + PosiNotionalValue = Math.Round(flowMerge.TradingAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero), PosiTradingFeePending = flowMerge.TradingFeePending, PosiTradingFee = 0, PosiTradingFeeUnit = 0, @@ -386,7 +409,7 @@ namespace YLErp.Modules.SwapModule OptId = UserInfo.UserId, OptName = UserInfo.UserName, UnderlyingInstrumentType = underlying.UnderlyingInstrumentType, - InitYtm = flowMerge.InitYtm + InitYtm = RoundSwapBondNetPriceAndYtm(flowMerge.InitYtm) }; td.swap_positions.Add(floatPosition); swap_position interestPosition = new swap_position() @@ -579,7 +602,7 @@ namespace YLErp.Modules.SwapModule dbTrade.trade_extend = req.trade_extend; dbTrade.swap_positions = req.swap_positions; dbTrade.MetaDic = req.MetaDic; - dbTrade.InitYtm = req.swap_positions.FirstOrDefault(p => p.InitYtm != null)?.InitYtm; + dbTrade.InitYtm = RoundSwapBondNetPriceAndYtm(req.swap_positions.FirstOrDefault(p => p.InitYtm != null)?.InitYtm); InnerSaveTrade(false, dbTrade, changsStr, changeConfirmStatus); return dbTrade; @@ -673,11 +696,7 @@ namespace YLErp.Modules.SwapModule private bool PrepareTrade(trade req, TradeSourceEnum dataSource, underlying_manager um) { bool tradeNumberGenerated = false; - req.InitialMargin = Convert.ToDouble(req.trade_Initial_Margin.MarginValue); - if (req.trade_Initial_Margin.MarginType == 0) - { - req.InitialMargin = req.StockEqvNotional == 0 ? 0 : Convert.ToDouble(req.trade_Initial_Margin.MarginValue) * req.StockEqvNotional; - }; + PrepareInitialMargin(req); var isAddNew = req.id == 0; if (isAddNew) { @@ -720,6 +739,17 @@ namespace YLErp.Modules.SwapModule return tradeNumberGenerated; } + + private static void PrepareInitialMargin(trade req) + { + // 初始预付金依赖最终入库的名义本金,须先统一金额精度,避免两者无法勾稽。 + req.StockEqvNotional = Math.Round(req.StockEqvNotional, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + req.InitialMargin = Convert.ToDouble(req.trade_Initial_Margin.MarginValue); + if (req.trade_Initial_Margin.MarginType == 0) + { + req.InitialMargin = req.StockEqvNotional == 0 ? 0 : Convert.ToDouble(req.trade_Initial_Margin.MarginValue) * req.StockEqvNotional; + } + } //准备单个交易 private trade PrepareSingleTrade(trade req, TradeSourceEnum dataSource, bool isAddNew, underlying_manager um) { @@ -762,6 +792,7 @@ namespace YLErp.Modules.SwapModule req.SpotPrice = Convert.ToDouble(swapPosition.PosiNetPrice); } req.Strike = null; + req.StockEqvNotional = Math.Round(req.StockEqvNotional, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); req.OriginalStockEqvNotional = req.StockEqvNotional; req.StockEqvNotionalReal = req.StockEqvNotional; @@ -1349,13 +1380,19 @@ namespace YLErp.Modules.SwapModule position.UnderlyingCode = swap.UnderlyingCode; position.UnderlyingInstrumentType = swap.UnderlyingInstrumentType; position.PosiDirection = swap.PosiDirection; - position.PosiGrossPrice = swap.PosiGrossPrice; - position.PosiNetPrice = swap.PosiQuantity == 0 ? 0 : (swap.PosiGrossPrice + (position.PosiTradingFeePending / swap.PosiQuantity) * ratio); + // position.PosiGrossPrice = string.IsNullOrEmpty(swap.UnderlyingCode) + // ? Math.Round(swap.PosiGrossPrice, ConsGlobal.SwapDeliveryPriceRound, MidpointRounding.AwayFromZero) + // : ValidateDeliveryPrice(swap.PosiGrossPrice, "期初交割价"); + var storagePriceRound = ConsGlobal.InstrumentType.IsBond(swap.UnderlyingInstrumentType) + ? ConsGlobal.PriceRound + : ConsGlobal.SwapDeliveryPriceRound; + position.PosiGrossPrice = Math.Round(swap.PosiGrossPrice, storagePriceRound, MidpointRounding.AwayFromZero); + position.PosiNetPrice = swap.PosiQuantity == 0 ? 0 : (position.PosiGrossPrice + (position.PosiTradingFeePending / swap.PosiQuantity) * ratio); position.PosiNetPrice = Math.Round(position.PosiNetPrice, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - position.PosiNetNoFeePrice = swap.PosiNetNoFeePrice; + position.PosiNetNoFeePrice = RoundSwapBondNetPriceAndYtm(swap.PosiNetNoFeePrice); position.PosiNetFeePrice = swap.PosiQuantity == 0 ? 0 : (swap.PosiNetNoFeePrice + (position.PosiTradingFeePending / swap.PosiQuantity) * ratio); position.PosiNetFeePrice = Math.Round(position.PosiNetFeePrice??0, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - position.PosiNotionalValue = swap.PosiNotionalValue; + position.PosiNotionalValue = Math.Round(swap.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); position.PosiQuantity = swap.PosiQuantity; position.InterestDirection = swap.InterestDirection; position.InterestMode = swap.InterestMode; @@ -1377,10 +1414,10 @@ namespace YLErp.Modules.SwapModule position.FloatRateUnderlyingCode = swap.FloatRateUnderlyingCode; position.interest_rest_days = swap.interest_rest_days; position.interest_rule = swap.interest_rule; - position.InitYtm = swap.InitYtm; - if (swap.InitYtm != null && swap.InitYtm > 0) + position.InitYtm = RoundSwapBondNetPriceAndYtm(swap.InitYtm); + if (position.InitYtm != null && position.InitYtm > 0) { - td.InitYtm = swap.InitYtm; + td.InitYtm = position.InitYtm; } if (position.id == 0) @@ -1479,7 +1516,7 @@ namespace YLErp.Modules.SwapModule td.ProcessStatus = null; if (backToBegin) { - td.StockEqvNotional = td.OriginalStockEqvNotional ?? 0; + td.StockEqvNotional = Math.Round(td.OriginalStockEqvNotional ?? 0, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); td.UnWindDate = null; td.HasPartialUnWind = null; SingleTradeBackToBegin(td, swapPositions); @@ -1641,10 +1678,10 @@ namespace YLErp.Modules.SwapModule posi.PosiGrossPrice = eodPosi.PosiGrossPrice; posi.PosiNetFeePrice = eodPosi.PosiNetFeePrice; posi.PosiNetNoFeePrice = eodPosi.PosiNetNoFeePrice; - posi.PosiNotionalValue = eodPosi.PosiNotionalValue; + posi.PosiNotionalValue = Math.Round(eodPosi.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); if (posi.PosiDirection > 0) { - td.StockEqvNotional = Convert.ToDouble(posi.PosiNotionalValue); + td.StockEqvNotional = Math.Round(Convert.ToDouble(posi.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); td.TradeAmount = Convert.ToDouble(posi.PosiQuantity); } } diff --git a/YLErpWeb/Controllers/BondController.cs b/YLErpWeb/Controllers/BondController.cs index ae8a74a1..36ef4f85 100644 --- a/YLErpWeb/Controllers/BondController.cs +++ b/YLErpWeb/Controllers/BondController.cs @@ -7,12 +7,14 @@ namespace YLErp.Web.Controllers { [AllowAnonymous] - public JsonResult CalcBond(string underlyingCode, decimal price, string priceType) + public JsonResult CalcBond(string underlyingCode, decimal price, string priceType, string targetDate = null) { - var obj = BondCalcHepler.BondCalc(underlyingCode, price, priceType); + string errorMsg; + var obj = BondCalcHepler.BondCalc(underlyingCode, price, priceType, out errorMsg, targetDate); if (obj == null) { - return JsonError("计算价格失败"); + // 透传计算器返回的真实原因(债券不存在/信息不全/参数非法/服务异常),前端以非阻塞 toast 展示 + return JsonError(errorMsg ?? "计算价格失败"); } return JsonSuccess("", obj); diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 717ad13f..db1089ab 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -1,4 +1,4 @@ -using Autofac.Core; +using Autofac.Core; using BaseOUDAL; using CsvHelper; using DocumentFormat.OpenXml.Spreadsheet; @@ -378,7 +378,7 @@ namespace YLErp.Web.Controllers catch (Exception e) { LogFactory.GetLogger("交易保存").Error(e); - return JsonError("保存失败:" + e.Message); + return JsonError("保存失败:" + e.GetBaseException().Message, e.ToJson()); } } /// diff --git a/YLErpWeb/NLog.config b/YLErpWeb/NLog.config index 60a23827..3ab0054a 100644 --- a/YLErpWeb/NLog.config +++ b/YLErpWeb/NLog.config @@ -8,7 +8,7 @@ - + diff --git a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml index b02667f9..c86b8774 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml @@ -163,7 +163,7 @@ {{priceFormat(floatPosition.TradingAmountNetAvg > 0 ? floatPosition.TradingAmountNetAvg : floatPosition.PosiNetPrice)}} - + diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index 1011c3c8..e1ecadcb 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -200,7 +200,7 @@ {{priceFormat(floatPosition.PosiGrossPrice)}} - + diff --git a/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml b/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml index 6bc78ac4..eb805b1c 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml @@ -243,7 +243,7 @@
- +
@*
diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index b8c7d03f..661828bd 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -448,20 +448,20 @@ - + - + - + - + - + {{item.underlying!=null?item.underlying.QuoteUnitString:''}} diff --git a/YLErpWeb/fe-tests/bondCalc.test.js b/YLErpWeb/fe-tests/bondCalc.test.js new file mode 100644 index 00000000..08f6ad7b --- /dev/null +++ b/YLErpWeb/fe-tests/bondCalc.test.js @@ -0,0 +1,264 @@ +/** + * bondCalc.test.js — 债券净价/全价/收益率三字段互算的"逐字段手动锁定"守卫 + * ============================================================================ + * 核心保证(用户最担心的,也是第一天讨论的诉求): + * 用户手填过的字段 = 被锁定,回写时**绝不**覆盖它; + * 只有"未手填"的字段才从源反算得出、被回写;计算器未返回的值也不覆盖。 + * 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个。 + * + * 运行:cd YLErpWeb/fe-tests && npm i && npm test + * 纯逻辑在 swapCalc.js::applyBondCalcResult(UMD,node 可直接 require)。 + */ +const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js'); + +const CALC = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; + +describe('逐字段手动锁定:手填过的字段永不回写', () => { + test('仅手填净价(CP):净价保持,全价/收益率被反算覆盖', () => { + const state = { cleanPrice: 99.5, dirtyPrice: null, ytm: null }; // 用户手填净价 99.5 + SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: false }); + expect(state.cleanPrice).toBe(99.5); // 手填:绝不回写 + expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖 + expect(state.ytm).toBe(2.5); + }); + + test('手填净价+全价(CP&DP):两者保持,仅收益率被覆盖', () => { + const state = { cleanPrice: 99.5, dirtyPrice: 100.5, ytm: null }; + SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: false }); + expect(state.cleanPrice).toBe(99.5); // 手填:保持 + expect(state.dirtyPrice).toBe(100.5); // 手填:保持 + expect(state.ytm).toBe(2.5); // 未手填:被反算覆盖 + }); + + test('用户不认可计算结果、手填全部三个:全部保留,无一被回写(核心场景)', () => { + const state = { cleanPrice: 98, dirtyPrice: 101, ytm: 3.0 }; // 用户全手填 + SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: true }); + expect(state.cleanPrice).toBe(98); // 全部手填 → 全部保留 + expect(state.dirtyPrice).toBe(101); + expect(state.ytm).toBe(3.0); + }); + + test('手填收益率(YD):收益率保持,净价/全价被覆盖', () => { + const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 }; + SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: false, YD: true }); + expect(state.ytm).toBe(2.6); + expect(state.cleanPrice).toBe(99); + expect(state.dirtyPrice).toBe(100); + }); + + test('仅手填全价(DP):全价保持,净价/收益率被反算覆盖(最常用场景)', () => { + const state = { cleanPrice: null, dirtyPrice: 100.5, ytm: null }; // 用户手填全价 100.5 + SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: false }); + expect(state.dirtyPrice).toBe(100.5); // 手填:绝不回写 + expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖 + expect(state.ytm).toBe(2.5); + }); + + test('手填净价+收益率(CP&YD):两者保持,仅全价被覆盖', () => { + const state = { cleanPrice: 98.5, dirtyPrice: null, ytm: 3.2 }; + SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: true }); + expect(state.cleanPrice).toBe(98.5); // 手填:保持 + expect(state.ytm).toBe(3.2); // 手填:保持 + expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖 + }); + + test('手填全价+收益率(DP&YD):两者保持,仅净价被覆盖', () => { + const state = { cleanPrice: null, dirtyPrice: 101, ytm: 2.8 }; + SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: true }); + expect(state.dirtyPrice).toBe(101); // 手填:保持 + expect(state.ytm).toBe(2.8); // 手填:保持 + expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖 + }); +}); + +describe('边界:计算器未返回的值不覆盖、无变化不写', () => { + test('手填净价 且 计算器未返收益率:源与缺失值均不写', () => { + const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 }; + const partial = { cleanPrice: 99, dirtyPrice: 100, ytm: undefined }; + SwapCalc.applyBondCalcResult(state, partial, { CP: true, DP: false, YD: false }); + expect(state.cleanPrice).toBe(null); // CP 手填:即便 calc 返 99 也不写 + expect(state.dirtyPrice).toBe(100); // 未手填:被覆盖 + expect(state.ytm).toBe(2.6); // 计算器没返收益率 → 保持用户值 + }); + + test('派生值与当前值差异 { + const state = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 2.5 }; + const near = { cleanPrice: 99.5, dirtyPrice: 100.00001, ytm: 2.5 }; + SwapCalc.applyBondCalcResult(state, near, { CP: true, DP: false, YD: false }); + expect(state.cleanPrice).toBe(99.5); // 手填:不写 + expect(state.dirtyPrice).toBe(100.0); // 差异 1e-5 < EPS → 不写 + expect(state.ytm).toBe(2.5); + }); + + // 模拟 calcBondForItem:proxy 统一用【展示态】,模型字段是【存储态】,回写时 bondCalcPriceToStorage。 + test('浮动腿 item 写回:仅未手填字段被覆盖,且存储态转换正确', () => { + // 用户输入净价 99.5 → vue-number-input(percent:true) 存为存储态 0.995 + const item = { PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026, + bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + const calc = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; // 计算器返回展示态 + const proxy = { + cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 0.995 → 99.5(手填) + dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), // 1.0 → 100 + ytm: SwapCalc.bondPriceToCalc(item.InitYtm) // 0.026 → 2.6 + }; + SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); + item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); + item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); + expect(item.PosiNetNoFeePrice).toBe(0.995); // 手填(净价):存储态保持 0.995(界面仍显示 99.5) + expect(item.PosiGrossPrice).toBe(1.0); // 全价:被反算覆盖,存储态 100/100=1.0 + expect(item.InitYtm).toBe(0.025); // 收益率:被反算覆盖,存储态 2.5/100=0.025 + }); + + // 核心回归:用户输入净价 100(存储态 1.0)时,绝不能因 proxy 残留存储态而再 ÷100 变成 0.01 + test('核心回归:输入净价 100 不会被二次缩小为 1(存储态 0.01)', () => { + const item = { PosiNetNoFeePrice: 1.0, PosiGrossPrice: null, InitYtm: null, + bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + const calc = { cleanPrice: 100, dirtyPrice: 102.17928767123287, ytm: 4.860445236 }; + const proxy = { + cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 1.0 → 100 + dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), + ytm: SwapCalc.bondPriceToCalc(item.InitYtm) + }; + SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); + item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); + item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); + expect(item.PosiNetNoFeePrice).toBe(1.0); // 存储态仍是 1.0(界面显示 100) + expect(item.PosiGrossPrice).toBeCloseTo(1.0217928767123287, 9); // 102.179.../100 + expect(item.InitYtm).toBeCloseTo(0.04860445236, 9); // 4.860.../100 + }); + + // 多轮不同净价:95 / 105,确保存储态始终与界面输入一致,不会被二次缩小 + test.each([ + [95, 97.5, 5.123456], + [105, 107.3, 4.567890], + [100, 102.17928767123287, 4.860445236] + ])('多轮净价校验:输入净价 %s 时,存储态保持 %s/100 且不会被二次缩小', (inputClean, calcDirty, calcYtm) => { + const item = { PosiNetNoFeePrice: inputClean / 100, PosiGrossPrice: null, InitYtm: null, + bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + const calc = { cleanPrice: inputClean, dirtyPrice: calcDirty, ytm: calcYtm }; + const proxy = { + cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), + dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), + ytm: SwapCalc.bondPriceToCalc(item.InitYtm) + }; + SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); + item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); + item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); + expect(item.PosiNetNoFeePrice).toBe(inputClean / 100); + expect(item.PosiGrossPrice).toBeCloseTo(calcDirty / 100, 9); + expect(item.InitYtm).toBeCloseTo(calcYtm / 100, 9); + }); +}); + +describe('错误反馈:getBondCalcErrorMessage(对齐 C# BondCalcHepler 的 errCode 守卫)', () => { + test('空响应 → 提示"无响应",且绝不回写', () => { + expect(SwapCalc.getBondCalcErrorMessage(null)).toBe("债券计算器无响应,已保留手工输入"); + }); + + test('业务错误码 errCode!=0(债券不存在/信息不全/参数非法)→ 返回真实 errMsg', () => { + const resp = { errCode: 1, errMsg: "债券不存在或信息不全", dirtyPrice: 0, cleanPrice: 0, ytm: 0 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).toBe("债券不存在或信息不全"); + }); + + test('业务错误码 errCode!=0 但缺 errMsg → 返回兜底文案', () => { + const resp = { errCode: 2, dirtyPrice: 0, cleanPrice: 0, ytm: 0 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).toBe("债券计算失败,请检查标的或参数"); + }); + + test('正常成功响应(errCode=0) → 返回 null(应继续回写)', () => { + const resp = { errCode: 0, errMsg: null, dirtyPrice: 100, cleanPrice: 99, ytm: 2.5 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); + }); + + test('无 errCode 字段的正常响应 → 返回 null(应继续回写)', () => { + const resp = { dirtyPrice: 100, cleanPrice: 99, ytm: 2.5 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); + }); + + test('防御值域:成功(errCode=0)但净价为负 → 拦截提示、不回写(用户实测 180205.IB 场景)', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: -117.93, ytm: 6.37 }; + const err = SwapCalc.getBondCalcErrorMessage(resp); + expect(err).not.toBeNull(); + expect(err).toContain("净价"); + }); + + test('防御值域:成功但收益率量级爆炸(378543) → 拦截提示、不回写', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 97.82, ytm: 378543.526601942 }; + const err = SwapCalc.getBondCalcErrorMessage(resp); + expect(err).not.toBeNull(); + expect(err).toContain("收益率"); + }); + + test('防御值域:净价超过 1000(量纲错误)→ 拦截', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 9782, ytm: 6.37 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).not.toBeNull(); + }); + + test('防御值域:命中哨兵值 -999999 → 拦截', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 99, ytm: -999999 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).not.toBeNull(); + }); + + test('防御值域:合理范围内的正常值(净价97.82/收益率6.37) → 放行返回 null', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 97.82, ytm: 6.37 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); + }); + + test('防御值域:负收益率但量级合理(-2.5%) → 放行(允许负利率债券)', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 99, ytm: -2.5 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); + }); + + test('端到端:业务错误时不覆盖手工输入(仅提示)', () => { + const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6, + bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + const respObj = { errCode: 1, errMsg: "债券不存在", dirtyPrice: 0, cleanPrice: 0, ytm: 0 }; + const err = SwapCalc.getBondCalcErrorMessage(respObj); + expect(err).toBe("债券不存在"); // 有错 → 调用方会 main.message(err) 并 return + if (!err) { + const proxy = { cleanPrice: item.PosiNetNoFeePrice, dirtyPrice: item.PosiGrossPrice, ytm: item.InitYtm }; + SwapCalc.applyBondCalcResult(proxy, respObj, item.bondManual); + item.PosiNetNoFeePrice = proxy.cleanPrice; + item.PosiGrossPrice = proxy.dirtyPrice; + item.InitYtm = proxy.ytm; + } + expect(item.PosiNetNoFeePrice).toBe(99.5); + expect(item.PosiGrossPrice).toBe(100.0); + expect(item.InitYtm).toBe(2.6); + }); +}); + +describe('单位换算边界(前端↔债券计算器 存储态小数 ↔ 展示态百分比)', () => { + test('bondPriceToCalc:存储态 0.995 → 发送计算器的展示态 99.5', () => { + expect(SwapCalc.bondPriceToCalc(0.995)).toBeCloseTo(99.5, 6); + expect(SwapCalc.bondPriceToCalc(1.0)).toBe(100); // 用户敲全价100 → percent:true 收成 1.0 → 发 100 + }); + + test('bondCalcPriceToStorage:展示态 97.82 → 落库存储态 0.9782', () => { + expect(SwapCalc.bondCalcPriceToStorage(97.82)).toBeCloseTo(0.9782, 6); + expect(SwapCalc.bondCalcPriceToStorage(6.37)).toBeCloseTo(0.0637, 6); + }); + + // 复刻用户实测的 -117.93 / 378543 离谱值根因,验证修复后不再出现: + // 旧:模型 1.0(用户敲100) 漏×100 直接发 1.0 → 计算器当 1% of par → 返回 cleanPrice=-1.1793, ytm=37.8543 + // → 漏÷100 原样写回 -1.1793 → percent:true 显示 ×100 → -117.93 / 378543 + // 新:发前×100、回写÷100 → 模型 0.9782 / 1.0 / 0.0637 → 显示 97.82 / 100 / 6.37 + test('端到端:用户敲全价100 → 修复后模型与显示均为合理值(不再 -117.93/378543)', () => { + const modelGross = 1.0; // percent:true 把界面 100 收成 1.0(存储态) + const sentToCalc = SwapCalc.bondPriceToCalc(modelGross); + expect(sentToCalc).toBe(100); // 必须 ×100,不是 1.0 + const calcResp = { errCode: 0, cleanPrice: 97.82, dirtyPrice: 100, ytm: 6.37 }; + const modelNet = SwapCalc.bondCalcPriceToStorage(calcResp.cleanPrice); + const modelGrossBack = SwapCalc.bondCalcPriceToStorage(calcResp.dirtyPrice); + const modelYtm = SwapCalc.bondCalcPriceToStorage(calcResp.ytm); + expect(modelNet).toBeCloseTo(0.9782, 6); + expect(modelGrossBack).toBe(1.0); + expect(modelYtm).toBeCloseTo(0.0637, 6); + // percent:true 显示时再 ×100:97.82 / 100 / 6.37,与计算器一致,无离谱值 + expect(modelNet * 100).toBeCloseTo(97.82, 4); + expect(modelYtm * 100).toBeCloseTo(6.37, 4); + }); +}); diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js index 71c4993e..9dac27b6 100644 --- a/YLErpWeb/fe-tests/swapCalc.test.js +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -291,3 +291,140 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', expectClose(r.FloatPnlSum, 5355000, 'income FloatPnlSum包含分红'); }); }); + +// ============================================================================ +// D1 回归守卫:切换债券标的必须清空手动/源标志,避免旧债券手填状态污染新债券 +// 旧 bug:setUnderlyingCode 切债券时未重置 bondManual/bondDriverType, +// 旧债券标记过的字段在新债券上会被错误跳过/沿用旧态。 +// 修复:setUnderlyingCode 调 SwapCalc.clearBondCalcFlags(item)。 +// ============================================================================ +describe('D1 切换标的清空债券互算手动/源标志', () => { + test('clearBondCalcFlags 把 bondManual 三字段归 false、bondDriverType 归 null', () => { + const item = { + bondManual: { CP: true, DP: false, YD: true }, + bondDriverType: 'YD' + }; + const out = SwapCalc.clearBondCalcFlags(item); + expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false }); + expect(out.bondDriverType).toBeNull(); + }); + + test('clearBondCalcFlags 对全新未交互标的(无标志)也安全初始化', () => { + const item = { UnderlyingCode: '200000.IB', isBond: true }; + const out = SwapCalc.clearBondCalcFlags(item); + expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false }); + expect(out.bondDriverType).toBeNull(); + expect(out.UnderlyingCode).toBe('200000.IB'); // 其它字段不受影响 + }); + + test('模拟"债券A手填→切债券B":B 不应继承 A 的手动标志', () => { + // 债券 A:用户手填了全价,标记手动 + 设源 + const item = { UnderlyingCode: '190000.IB', isBond: true, + bondManual: { CP: false, DP: true, YD: false }, bondDriverType: 'DP' }; + // 切到债券 B(setUnderlyingCode 会调 clearBondCalcFlags) + SwapCalc.clearBondCalcFlags(item); + item.UnderlyingCode = '200000.IB'; + // 若不清空,applyBondCalcResult 会以旧的 bondManual.DP=true 跳过 B 的全价→错误 + const manual = item.bondManual; + expect(manual.CP || manual.DP || manual.YD).toBe(false); // B 上无任何手动标志 + expect(item.bondDriverType).toBeNull(); // B 无计算源,下一步反算不会误用 A 的源 + }); +}); + +// ============================================================================ +// D2 回归守卫:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志 +// 随页面重置丢失 → 用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。 +// 修复:加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,调 markExistingBondManual +// 把三格一次性锁为手动(true),重开期间计算器不再自动推导;点"重算"才清除重新计算。 +// ============================================================================ +describe('D2 重开已保存债券单 → 三字段锁定、编辑不联动另两格', () => { + test('markExistingBondManual 把三字段全锁 true、不置计算源', () => { + const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 }; + const out = SwapCalc.markExistingBondManual(item); + expect(out.bondManual).toEqual({ CP: true, DP: true, YD: true }); + expect(out.bondDriverType).toBeUndefined(); // 不置源 → calcBondForItem 早返回,重开零自动推导 + }); + + test('重开后编辑某一格(如全价):另两格因 manual=true 被 applyBondCalcResult 跳过、不被覆盖', () => { + const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 }; + SwapCalc.markExistingBondManual(item); + // 模拟用户编辑全价(DP):onBondPriceInput 会置 bondDriverType='DP' 并重算 + item.bondDriverType = 'DP'; + const proxy = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 6.37 }; + const calc = { cleanPrice: 97.82, dirtyPrice: 98.5, ytm: 2.60 }; + SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + // 三格皆 manual=true → 一个都不回写,保存值(99.5/100.0/6.37)原样保留 + expect(proxy.cleanPrice).toBe(99.5); + expect(proxy.dirtyPrice).toBe(100.0); + expect(proxy.ytm).toBe(6.37); + }); + + test('加载路径不会误锁"全新未填的债券"(三值不全)', () => { + // 全新债券标的,价格字段空 → 不应被标记为手动锁定 + const item = { isBond: true, PosiNetNoFeePrice: null, PosiGrossPrice: null, InitYtm: null }; + SwapCalc.markExistingBondManual(item); + // 全新标的本不应调用 markExistingBondManual;此处断言:即便误调也不应制造假锁定干扰后续交互 + // (实际加载逻辑用 hasV 三值齐全判定,只在已保存单上调用,此用例验证函数本身不副作用其它字段) + expect(item.UnderlyingCode).toBeUndefined(); + }); +}); + +// ============================================================================ +// D3 回归守卫:不可算债券上用户逐键手填时,v-on:input 每次按键都触发一次失败计算并弹 toast, +// 会连刷相同提示。修复:shouldShowBondErr 按"同一错误文案连续出现只提示一次"抑制噪声。 +// ============================================================================ +describe('D3 债券计算器失败提示去重', () => { + test('同一错误连续出现只提示一次', () => { + const s = {}; + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); // 首次 → 提示 + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(false); // 连刷 → 抑制 + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(false); // 再刷 → 抑制 + }); + + test('错误文案变化仍照常提示', () => { + const s = {}; + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); + expect(SwapCalc.shouldShowBondErr(s, '信息不全')).toBe(true); // 不同错误 → 提示 + expect(SwapCalc.shouldShowBondErr(s, '信息不全')).toBe(false); // 同错误 → 抑制 + }); + + test('成功(传入 null/空)清标记,下次不同错误仍能提示', () => { + const s = {}; + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); + expect(SwapCalc.shouldShowBondErr(s, null)).toBe(false); // 成功清标记,不提示 + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); // 新一次错误 → 重新提示 + }); +}); + +// ============================================================================ +// 估值日(开始日)缺失守卫:需求——债券净价/全价/收益率以【互换起始日 StartDate】估值; +// 开始日现已默认即有,故不再"悄悄回退到交易日(TradeDate)",而是真的为空时返回错误文案, +// 由 calcBondForItem 报错提示并 return(不调用计算器、不覆盖手工输入)。 +// getBondStartDateMissingMsg 为纯函数,jest 直接覆盖。 +// ============================================================================ +describe('估值日(开始日)缺失 → 报错提示、不悄悄回退交易日', () => { + test('开始日有值 → 返回 null(不报错、继续计算)', () => { + expect(SwapCalc.getBondStartDateMissingMsg('2026-07-23')).toBeNull(); + expect(SwapCalc.getBondStartDateMissingMsg('2026-01-01')).toBeNull(); + expect(SwapCalc.getBondStartDateMissingMsg('2026/07/23')).toBeNull(); + }); + + test('开始日为空/undefined/null → 返回错误文案(须提示用户)', () => { + expect(typeof SwapCalc.getBondStartDateMissingMsg('')).toBe('string'); + expect(typeof SwapCalc.getBondStartDateMissingMsg(null)).toBe('string'); + expect(typeof SwapCalc.getBondStartDateMissingMsg(undefined)).toBe('string'); + // 文案需同时引导"先填开始日"和"可手动填三数"(对应需求2) + const m = SwapCalc.getBondStartDateMissingMsg(''); + expect(m).toMatch(/开始日/); + expect(m).toMatch(/手动填写/); + }); + + test('与 D3 去重配合:开始日缺失时同一文案只弹一次', () => { + const item = {}; + const msg = SwapCalc.getBondStartDateMissingMsg(''); + // calcBondForItem 中:if (SwapCalc.shouldShowBondErr(item, msg)) main.message(msg); return; + expect(SwapCalc.shouldShowBondErr(item, msg)).toBe(true); // 首次 → 提示 + expect(SwapCalc.shouldShowBondErr(item, msg)).toBe(false); // 价格格逐键输入连刷 → 抑制 + expect(SwapCalc.shouldShowBondErr(item, null)).toBe(false); // 估值日补填后成功 → 清标记 + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/SwapflowList.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/SwapflowList.js index b92a89e3..3af1b627 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/SwapflowList.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/SwapflowList.js @@ -1,5 +1,6 @@ //window.otcformat.options.disableGrouping = true; const inputFormatTradePrice = Object.freeze({ precision: otcformat.trading.tradeSinglePrice.precision, negative: true, append: '' }); +const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '' }); const inputFormatTradeAmount = Object.freeze({ precision: otcformat.trading.notional.precision, append: '' }); const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); var clients = ylotc.clients; @@ -669,7 +670,7 @@ function getColModelGridStep4() { label: '名义本金', width: 160, align: 'center', - formatter: otcformat.trading.umprice + formatter: otcformat.trading.StockEqvNotional } , { name: 'position.PosiTradingFee', @@ -1198,6 +1199,7 @@ var vue = new Vue({ }, postSwapflow() { var thisObj = this; + thisObj.swapflow.TradingAmountAvg = _.round(Number(thisObj.swapflow.TradingAmountAvg), 9); main.post("/swaptrade2/SaveSwapflow", { req: thisObj.swapflow, step: thisObj.step }).done(function (resp) { if (resp.success) { getList(); @@ -1221,4 +1223,4 @@ var vue = new Vue({ 'vue-underlying': vueUnderlying() } }); -window.reloadData = getList(); \ No newline at end of file +window.reloadData = getList(); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index 7acad7c4..a0057f28 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -8,6 +8,7 @@ const inputFormatEqvNotional = Object.freeze({ precision: otcformat.trading.Stoc const inputFormatDividend = Object.freeze({ precision: 2, append: '', negative: true }); const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true }); +const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true }); let ValueDate = model.ValueDate; let MaxIncomeValueDate = model.MaxIncomeValueDate ? model.MaxIncomeValueDate.substr(0, 10) : ValueDate; @@ -77,6 +78,10 @@ const vue = new Vue({ getPriceScale() { return SwapCalc.getPriceScale(this.multiplier); }, + getStorageDeliveryPrice() { + const precision = inputFormatSwapDeliveryPrice.precision + (this.multiplier === 100 ? 2 : 0); + return SwapCalc.roundHalfAwayFromZero(Number(this.floatPosition.TradingAmountAvg) * this.getPriceScale(), precision); + }, initDeal() { var positions = model.FlowEvents.filter((item) => { return item.UnderlyingCode; @@ -161,7 +166,7 @@ const vue = new Vue({ { code: thisObj.floatPosition.UnderlyingCode, valuedate: thisObj.deal.ValueDate }) .done(function (res) { res.obj = res.obj * thisObj.multiplier; - thisObj.floatPosition.TradingAmountAvg = otcformat.trading.tradeSinglePrice(res.obj); + thisObj.floatPosition.TradingAmountAvg = _.round(Number(res.obj), 9); thisObj.calcFloatClosePnl(); }); }, @@ -180,11 +185,11 @@ const vue = new Vue({ 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); - let scale = thisObj.getPriceScale(); + let deliveryPrice = thisObj.getStorageDeliveryPrice(); // 债券全价是单位价格,价差盈亏应按持仓数量×合约乘数计算; // CloseNotionalValue 是期初全价折算后的名义本金,直接乘价差会重复包含期初价格。 let positionAmount = parseFloat(thisObj.floatPosition.Quantity) * parseFloat(thisObj.floatPosition.ContractSize || 1); - thisObj.floatPosition.MarkClosePnl = positionAmount * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio; + thisObj.floatPosition.MarkClosePnl = positionAmount * (deliveryPrice - thisObj.initPosiGrossPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红 // 守卫: 浮动盈亏合计必须保留 2 位小数 → 对应历史 bug 3c5f25a5(原代码缺精度保留) // 数值由 swapCalc.calcFloatPnlSum 计算, 此处 .toFixed(2) 仅保留字符串类型以兼容下游 @@ -205,13 +210,13 @@ const vue = new Vue({ thisObj.deal.SwapRealizedPnL = pnl; thisObj.deal.SwapMarginRebatePnl = 0; thisObj.deal.SwapMarginAmount = 0; - let scale = thisObj.getPriceScale(); - thisObj.floatPosition.TradingAmount = parseFloat(thisObj.floatPosition.TradingAmountAvg) * parseFloat(thisObj.deal.CloseNotionalValue) * scale; + let deliveryPrice = thisObj.getStorageDeliveryPrice(); + thisObj.floatPosition.TradingAmount = deliveryPrice * parseFloat(thisObj.deal.CloseNotionalValue); thisObj.floatPosition.CloseFee = TradingFee; if (thisObj.deal.CloseQty > 0) { - thisObj.floatPosition.TradingAmountFeeAvg = parseFloat(thisObj.floatPosition.TradingAmountAvg) * scale + (TradingFee / thisObj.deal.CloseQty) * floatRatio; + thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * floatRatio; } else { - thisObj.floatPosition.TradingAmountFeeAvg = parseFloat(thisObj.floatPosition.TradingAmountAvg) * scale; + thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice; } this.interestList.forEach(x => { //let interestRatio = x.InterestDirection == 1 ? 1 : -1; @@ -277,7 +282,7 @@ const vue = new Vue({ thisObj.floatPosition.EventDate = thisObj.deal.ValueDate; let floatPosition = _.cloneDeep(thisObj.floatPosition); floatPosition.Quantity = 0; - floatPosition.TradingAmountAvg = floatPosition.TradingAmountAvg * thisObj.getPriceScale(); + floatPosition.TradingAmountAvg = thisObj.getStorageDeliveryPrice(); reqObj.FlowEvents.push(floatPosition); var postData = { unwindData: reqObj }; var msg = "确认提交收益结算?"; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index f18f520c..58e7cfbe 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -200,7 +200,151 @@ }; } + /** + * 债券净价/全价/收益率三字段互算:回写时跳过"用户手填过的字段"(逐字段手动锁定,永不覆盖)。 + * - manualSet: { CP:bool, DP:bool, YD:bool },标记哪些字段是用户本次会话中手动输入/修改过的 + * - calc: { cleanPrice, dirtyPrice, ytm } 来自 /Bond/CalcBond 的 resp.obj + * - state: 持有三个字段的对象(直接原地写回) + * 设计(回应"计算结果不认可时如何优雅手动覆盖"): + * 用户每手填一个字段,该字段即被锁定;反算只填充"未手填"的字段,已手填的(含刚编辑的)一律不回写。 + * 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个。 + * 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。 + */ + function applyBondCalcResult(state, calc, manualSet) { + var EPS = 1e-4; + function write(field, type, value) { + if (manualSet && manualSet[type]) return; // 用户手填过的字段:绝不回写 + if (value === undefined || value === null) return; // 计算器未返回该值则不覆盖 + var cur = state[field]; + if (typeof cur === 'number' && Math.abs(cur - value) < EPS) return; // 无变化不写,避免光标跳动 + state[field] = value; + } + write('cleanPrice', 'CP', calc && calc.cleanPrice); + write('dirtyPrice', 'DP', calc && calc.dirtyPrice); + write('ytm', 'YD', calc && calc.ytm); + } + + /** + * 债券价格【存储态小数 ↔ 展示态每百元百分比】边界换算。 + * 约定(见 ConsGlobal.bondPriceMultiple=0.01 / bondShowPriceMultiple=100、BondPriceConverter): + * - 模型/DB 存【存储态小数】(如 0.995 = 99.5 元/百元面值); + * - 债券计算器(bond-calc,经 zszq-bond-oms 代理)要【展示态百分比】(如 99.5)。 + * 历史坑:calcBondForItem 曾漏掉这正反两步,把存储态 0.995 当 99.5 发给计算器、 + * 又把返回 97.82 原样落库;配合 vue-number-input 的 percent:true(显示再×100) + * 造成 -117.93 / 378543 离谱值。故发计算器前 bondPriceToCalc(×100)、回写前 bondCalcPriceToStorage(÷100)。 + * 纯函数,jest 可直接测。 + */ + function bondPriceToCalc(storagePrice) { + return Number(storagePrice) * 100; // 存储态小数 → 展示态每百元百分比 + } + function bondCalcPriceToStorage(displayPrice) { + return Number(displayPrice) / 100; // 展示态百分比 → 存储态小数 + } + /** + * 清空债券三字段互算的【手动/源】标志(纯函数,jest 可测)。 + * 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的手填状态(bondManual/bondDriverType) + * 污染新债券——否则旧债券标记过的字段在新债券上会被错误跳过 / 沿用旧态(D1 修复)。 + * 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象(首次交互已 $set 过 bondManual), + * 故重赋值能正常触发响应式更新;全新未交互过的标的清不清都无副作用。 + */ + function clearBondCalcFlags(state) { + state.bondManual = { CP: false, DP: false, YD: false }; + state.bondDriverType = null; + return state; + } + + /** + * 从 /Bond/CalcBond 响应(resp.obj,即 CalBondResult)中提取应展示给用户的错误文案; + * 无错误返回 null(调用方据此决定是否回写、是否提示)。 + * 覆盖: + * - 空响应(计算器无响应) + * - 业务错误码 errCode!=0(债券不存在 / 债券信息不全 / 参数非法) + * - 防御性值域校验(errCode=0 但数值离谱):即便计算器返回 success, + * 仍可能因【前端↔计算器的单位换算不匹配】或 + * 行权收益率哨兵(-999999) 而给出负净价 / 收益率量级爆炸(如 378543) 之类的垃圾值。 + * 现有 errCode 守卫拦不住这类"成功但离谱"的响应,故在此加值域闸门, + * 宁可不回写并提示用户,也绝不用垃圾值覆盖手工输入。 + * 与 C# BondCalcHepler 的 errCode 守卫一一对应,确保"坏结果"不会静默回写覆盖手工输入。 + * 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。 + */ + function getBondCalcErrorMessage(resp) { + if (!resp) return "债券计算器无响应,已保留手工输入"; + if (resp.errCode && resp.errCode !== 0) { + return resp.errMsg || "债券计算失败,请检查标的或参数"; + } + // 防御性值域校验:拦截 errCode=0 但数值离谱的响应(部署环境主数据量纲错误 / 哨兵值) + var SENTINEL = -999999; // bond-calc 行权收益率不可用哨兵 + var cp = resp.cleanPrice, dp = resp.dirtyPrice, yd = resp.ytm; + var absurd = function (v) { + return typeof v === 'number' && (v === SENTINEL || v === SENTINEL * 100); + }; + if (absurd(cp) || absurd(dp) || absurd(yd)) { + return "债券计算返回哨兵值(部分指标不可用),已保留手工输入,请检查估值日/价格输入或联系管理员核对债券计算服务"; + } + // 净价/全价:占面值百分比,正常约 20~300,绝不为负、也不会破千 + if ((typeof cp === 'number' && (cp <= 0 || cp > 1000)) || + (typeof dp === 'number' && (dp <= 0 || dp > 1000))) { + return "债券计算净价/全价超出合理范围(应为面值百分比且为正),已保留手工输入;" + + "请检查估值日/价格输入或联系管理员核对债券计算服务"; + } + // 到期收益率:百分数口径(如 6.37 表示 6.37%),正常约 -5~30,|收益率|>100 视为爆炸 + if (typeof yd === 'number' && Math.abs(yd) > 100) { + return "债券计算收益率量级异常(" + yd + "),已保留手工输入;" + + "请检查估值日/价格输入或联系管理员核对债券计算服务"; + } + return null; + } + + /** + * D2 修复(纯函数,jest 可测):重开(审批重开/刷新)一只【已保存且三字段齐全】的债券成交单时, + * 把三字段互算的手动标志一次性全置 true,使计算器在重开期间不再自动反算、覆盖当初保存的其他两个值。 + * - 不设置 bondDriverType:重开时没有任何字段作为"计算源",calcBondForItem 会早返回(要求 driver 非空), + * 故纯展示保存值、零自动推导;用户点"重算"才清除标志并重新推导。 + * - 用户若编辑其中某格:onBondPriceInput 会把它设为 driver 并重算,但因另两格仍是 manual=true, + * 不会被覆盖 → 满足"重开后手填覆盖跨会话 sticky、编辑不联动另两格"。 + * 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券不会被误锁。 + */ + function markExistingBondManual(state) { + state.bondManual = { CP: true, DP: true, YD: true }; + return state; + } + + /** + * D3 修复(纯函数,jest 可测):债券计算器失败提示去重。 + * 不可算的债券上用户逐键手填时,v-on:input 每次按键都触发一次失败计算并弹 toast,会连刷数条相同提示。 + * 这里按"同一错误文案连续出现只提示一次"抑制噪声;错误文案变化(如 债券不存在→信息不全)则照常提示, + * 成功(传入 null/空)时清掉标记,便于下次真出不同错误时仍能提示。 + * 纯做"是否该弹"的决策并维护 state._lastBondErr,不触碰任何计算逻辑,零风险。 + */ + function shouldShowBondErr(state, err) { + if (!err) { state._lastBondErr = null; return false; } // 成功/无错误:清标记、不提示 + if (state._lastBondErr === err) return false; // 连续相同错误 → 抑制重复 toast + state._lastBondErr = err; + return true; + } + + /** + * 估值日(开始日)缺失校验(纯函数,jest 可测)。 + * 需求:债券净价/全价/收益率以【互换起始日 StartDate】估值;开始日现已默认即有, + * 故不再"悄悄回退到交易日",而是真的为空时返回错误文案,由上层提示用户 + * (并允许其手动填写三项数值,见需求2)。 + * 返回 非空字符串=缺失需提示;返回 null=已具备估值日。 + */ + function getBondStartDateMissingMsg(startDate) { + if (startDate) return null; + return "请先填写开始日(互换起始日,作为估值日)后再计算债券净价/全价/收益率;" + + "若暂不需要计算,可手动填写净价/全价/收益率三项数值"; + } + return { + applyBondCalcResult: applyBondCalcResult, + getBondCalcErrorMessage: getBondCalcErrorMessage, + bondPriceToCalc: bondPriceToCalc, + bondCalcPriceToStorage: bondCalcPriceToStorage, + clearBondCalcFlags: clearBondCalcFlags, + markExistingBondManual: markExistingBondManual, + shouldShowBondErr: shouldShowBondErr, + getBondStartDateMissingMsg: getBondStartDateMissingMsg, roundHalfAwayFromZero: roundHalfAwayFromZero, getPriceScale: getPriceScale, deriveTradingAmountAvg: deriveTradingAmountAvg, diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index dc726ce2..91b2e26a 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -13,7 +13,10 @@ const inputFormatSwapRate = Object.freeze({ precision: otcformat.trading.premium const inputFormatTradePrice = Object.freeze({ precision: otcformat.trading.tradePrice.precision, negative: true, append: '' }); const inputFormatTradeSinglePrice = Object.freeze({ precision: otcformat.trading.tradeSinglePrice.precision, negative: true, append: '', percent: false }); const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); -const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent:true }); +const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '', percent: false }); +const inputFormatSwapBondDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '', percent: true }); +const inputFormatSwapBondNetPriceAndYtm = Object.freeze({ precision: 9, negative: true, append: '', percent: true }); +const swapBondStoragePricePrecision = inputFormatSwapBondDeliveryPrice.precision + 2; const consUnderlyingFlagBase = (function () { let unSelFlag = tradeHelper.UnderlyingSelectFlag; @@ -219,6 +222,15 @@ const vue = new Vue({ }); }, methods: { + roundStorageDeliveryPrice(item, price) { + const precision = tradeHelper.IsBond(item && item.UnderlyingInstrumentType) + ? swapBondStoragePricePrecision + : inputFormatSwapDeliveryPrice.precision; + return _.round(Number(price), precision); + }, + roundStorageBondNetPriceAndYtm(value) { + return value == null ? value : _.round(Number(value), swapBondStoragePricePrecision); + }, getPosiPriceFormatKey(item, field) { const index = item && item.index != null ? item.index : ''; const isBond = tradeHelper.IsBond(item && item.UnderlyingInstrumentType); @@ -252,6 +264,105 @@ const vue = new Vue({ changeClearingAgency() { this.trade.MetaDic["清算机构"] = this.viewState.Extend.ClearingAgency; }, + //全价(DP)列输入:先按标的单价重算名义本金(非债券标的也需要),再对债券触发三字段反算。 + // 组件 vue-number-input 只 $emit('input')(失焦/回车时由底层 numberInput.onchange 触发), + // 故此列绑 v-on:input,一个入口同时覆盖普通标的与债券两种情形。 + onDpPriceInput(item) { + this.changeSpotPrice(item); // 名义本金随全价变化(非债券标的的既有逻辑) + this.onBondPriceInput(item, 'DP'); // 债券:以全价为源反算净价/收益率(非债券内部会早返回) + }, + //债券收益互换:编辑某一浮动腿的净价/全价/收益率之一 → 该字段标记为"已手动设过"(锁定), + // 并以它为源调计算器反算"尚未手动设过"的另两个字段(已手动设过的一律不回写) + onBondPriceInput(item, type) { // type: 'CP'净价 / 'DP'全价 / 'YD'收益率 + if (!item.isBond) return; + var m = item.bondManual || { CP: false, DP: false, YD: false }; + m[type] = true; + this.$set(item, 'bondManual', m); // 锁定用户手填字段 + this.$set(item, 'bondDriverType', type); // 以该字段为本次计算源 + this.calcBondForItem(item); + }, + //放弃手动覆盖、以当前某字段为源重新自动反算(清空所有手动锁定标记) + resetBondCalc(item) { + if (!item.isBond) return; + this.$set(item, 'bondManual', { CP: false, DP: false, YD: false }); + // 优先沿用用户最后一次编辑锁定的源(bondDriverType),避免"净价残留脏值"时误以净价为源反算出连锁错误; + // 无历史源时再按 净价→全价→收益率 回退挑一个有值的字段。 + var hasVal = function (v) { return v !== null && v !== '' && !isNaN(v); }; + var driver = item.bondDriverType + || (hasVal(item.PosiNetNoFeePrice) ? 'CP' + : hasVal(item.PosiGrossPrice) ? 'DP' + : hasVal(item.InitYtm) ? 'YD' : null); + this.$set(item, 'bondDriverType', driver); + this.calcBondForItem(item); + }, + //以该浮动腿的 bondDriverType 为源调 /Bond/CalcBond;回写时跳过"已手动设过的字段"(bondManual), + //仅反向填充用户尚未手动设过的字段——故用户可逐个手填全部三个而互算不会冲掉任一个 + //错误处理(对齐 bond-oms-ui / zszq-bond-oms): + // - 业务/参数错误(success=false 或 errCode!=0,如债券不存在/信息不全/参数非法) + // 以非阻塞 toast(main.message) 反馈真实原因,绝不回写、不弹模态框、不阻止手工输入; + // - 网络/服务异常:框架 main.post 已弹"请求失败",此处不再二次提示。 + calcBondForItem(item) { + if (!item.isBond || !item.bondDriverType) return; + if (!item.UnderlyingCode) { main.message("请先选择债券标的"); return; } + var price = item.bondDriverType === 'CP' ? item.PosiNetNoFeePrice + : item.bondDriverType === 'DP' ? item.PosiGrossPrice + : item.InitYtm; + if (price === null || price === '' || isNaN(price)) return; // 编辑中(空/非数)静默,不提示 + var priceType = item.bondDriverType === 'CP' ? 'CP' : item.bondDriverType === 'DP' ? 'DP' : 'YD'; + var self = this; + // 估值日:债券互换的"期初"净价/全价/收益率应以【互换起始日 StartDate】估值。 + // 该字段在页面上本就是可选择的日期控件(vue-datepicker),用户可改;现已默认即有。 + // 不再"悄悄回退到交易日(TradeDate)"——真的为空时直接报错提示,由用户填写/手动填三数(需求2)。 + var targetDate = (this.trade && this.trade.StartDate) ? this.trade.StartDate : null; + var sdMsg = SwapCalc.getBondStartDateMissingMsg(targetDate); + if (sdMsg) { + // D3 同款去重:开始日缺失时用户在价格格逐键输入会连刷相同提示,故同一文案只弹一次 + if (SwapCalc.shouldShowBondErr(item, sdMsg)) main.message(sdMsg); + return; // 估值日缺失:不调用计算器、不覆盖任何手工输入 + } + main.post('/Bond/CalcBond', { + underlyingCode: item.UnderlyingCode, + // 模型 item.PosiGrossPrice/PosiNetNoFeePrice/InitYtm 存的是【存储态小数】(如 0.995), + // 而 bond-calc 要的是【展示态/每百元百分比】(如 99.5)。这俩靠 BondPriceConverter.ToDisplay(×100)/ToStorage(÷100) 对齐。 + // 此处传给计算器前必须 bondPriceToCalc(存储→展示);回写时再 bondCalcPriceToStorage(展示→存储), + // 否则计算器拿到 0.995≈1 当 1% of par 直接发散成离谱值。 + price: SwapCalc.bondPriceToCalc(price), + priceType: priceType, + targetDate: targetDate + }, { alertFn: main.message }).done(function (resp) { + if (!resp || !resp.obj) return; + // 业务层错误(债券不存在/信息不全/参数非法):仅提示,绝不覆盖手工输入 + var err = SwapCalc.getBondCalcErrorMessage(resp.obj); + if (err) { + // D3 修复:不可算债券上用户逐键手填时,每次按键都触发一次失败计算并弹 toast,会连刷相同提示; + // 同一错误文案连续出现只弹一次(shouldShowBondErr 维护 item._lastBondErr),抑制噪声、不影响任何计算逻辑。 + if (SwapCalc.shouldShowBondErr(item, err)) main.message(err); + return; + } + item._lastBondErr = null; // 成功则清标记,便于下次真出不同错误时仍能提示 + // 以既有三字段为代理,调用纯函数(已手动设过的字段不被覆盖),再写回。 + // 关键:proxy 内必须统一为【展示态】(per-100-face),因为 applyBondCalcResult 写入的是计算器返回的展示态。 + // 模型字段是【存储态小数】(percent:true 下 1.00 对应界面 100),所以初始化时要 bondPriceToCalc(×100); + // 若直接用存储态初始化,则用户手填字段被 applyBondCalcResult 跳过后,proxy 中仍残留存储态, + // 后续再 ÷100 就会导致该字段被二次缩小(如输入 100 变成 1)。 + var proxy = { + cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), + dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), + ytm: SwapCalc.bondPriceToCalc(item.InitYtm) + }; + var manual = item.bondManual || { CP: false, DP: false, YD: false }; + SwapCalc.applyBondCalcResult(proxy, resp.obj, manual); + // 回写前 bondCalcPriceToStorage(÷100)(展示态→存储态小数):proxy 里均为展示态; + // 模型字段存存储态(0.995),须 ÷100 落回模型,否则配合 percent:true 显示会 ×100 成离谱值。 + item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); + item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); + item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); + // 名义本金依赖全价(PosiGrossPrice):以净价/收益率为源反算出的全价被回写后, + // 直接赋值不会触发组件 input 事件,需在此显式重算,保持名义本金与全价一致。 + if (self.calcNotional) self.calcNotional(); + if (self.$forceUpdate) self.$forceUpdate(); + }); + }, //变更收费基本单位 changeOpenFeeType() { var thisObj = this; @@ -277,7 +388,8 @@ const vue = new Vue({ //计算数量 if (this.paySwapList.length > 0) { var item = this.paySwapList[0]; - var notional = item.PosiGrossPrice * item.ContractSize; + var deliveryPrice = this.roundStorageDeliveryPrice(item, item.PosiGrossPrice); + var notional = deliveryPrice * item.ContractSize; item.PosiQuantity = notional == 0 ? 0 : _.round(this.trade.StockEqvNotional / notional, page.otcFormatConfig.StockEqvNotional.precision); this.calcNotional(); } @@ -358,7 +470,8 @@ const vue = new Vue({ } var national = payItem.PosiQuantity * payItem.ContractSize; // 守卫: 名义本金必须 round 到 2 位 → 对应历史 bug f873239a(缺 _.round); 外置到 swapCalc.calcStockEqvNotional - var stockEqvNotional = SwapCalc.calcStockEqvNotional(payItem.PosiGrossPrice, national);//名义本金=期初价格*数量*乘数 + var deliveryPrice = this.roundStorageDeliveryPrice(payItem, payItem.PosiGrossPrice); + var stockEqvNotional = SwapCalc.calcStockEqvNotional(deliveryPrice, national);//名义本金=期初价格*数量*乘数 this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional); payItem.PosiNotionalValue = this.trade.StockEqvNotional; } @@ -502,6 +615,9 @@ const vue = new Vue({ errorcount++; return false; } + x.PosiGrossPrice = thisObj.roundStorageDeliveryPrice(x, x.PosiGrossPrice); + x.PosiNetNoFeePrice = thisObj.roundStorageBondNetPriceAndYtm(x.PosiNetNoFeePrice); + x.InitYtm = x.InitYtm == null ? null : thisObj.roundStorageBondNetPriceAndYtm(x.InitYtm); thisObj.trade.swap_positions.push(x); }); } else { @@ -589,6 +705,10 @@ const vue = new Vue({ item.underlying.UnderlyingInstrumentType = data.InstrumentType; item.underlying.QuoteUnitString = data.QuoteUnitString; this.trade.UnderlyingCode = underlyingCode; + // 债券标的:标记该浮动腿可启用净价/全价/收益率互算 + this.$set(item, 'isBond', tradeHelper.IsBond(data.InstrumentType)); + // 切换标的时清空债券三字段互算的手动/源标志(D1 修复:避免旧债券手填状态污染新债券) + SwapCalc.clearBondCalcFlags(item); //this.initMarginRate(); }, setFloatRateUnderlyingCode(data, item) { @@ -627,8 +747,13 @@ const vue = new Vue({ var thisObj = this; main.post("/pricing/AjaxGetUnderlyingPrice", { underlyingCode: underlyingCode, tradeDate: StartDate }) .done(function (resp) { - item.PosiNetNoFeePrice = otcformat.trading.umprice(resp.obj.netPrice); - item.PosiGrossPrice = otcformat.trading.umprice(resp.obj.price); + // 行情接口(AjaxGetUnderlyingPrice)对债券返回的 price/netPrice 本就是【存储态小数】(1.0 代表 100 元), + // 与 PosiGrossPrice/PosiNetNoFeePrice 模型字段同量纲(见 PricingController: EodPrice 直接返回、 + // 非 EodPrice 分支 ×bondPriceMultiple=0.01)。故此处仅做精度格式化,**不可**再 bondCalcPriceToStorage(÷100), + // 否则默认价 1.0 被除成 0.01,界面 percent:true 再 ×100 显示为 1("被自动除以100"bug)。 + // 计算器(/Bond/CalcBond)返回的才是展示态,其 ÷100 落库逻辑在 calcBondForItem 内处理。 + item.PosiNetNoFeePrice = thisObj.roundStorageBondNetPriceAndYtm(resp.obj.netPrice); + item.PosiGrossPrice = thisObj.roundStorageDeliveryPrice(item, resp.obj.price); thisObj.calcNotional(); }); }, @@ -1314,6 +1439,15 @@ const vue = new Vue({ thisObj.paySwapList.forEach((val, num, arr) => { arr[num].index = num; this.StockEqvNotional = val.ContractSize * val.PosiQuantity * val.PosiGrossPrice; + // D2 修复:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志随页面重置而丢失; + // 若不锁,用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。 + // 故在加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,直接把三格标为手动锁定, + // 计算器在重开期间不自动推导;用户点"重算"才清除并重新计算。 + var hasV = function (v) { return v !== null && v !== undefined && v !== '' && !isNaN(Number(v)); }; + var isBond = val.isBond || tradeHelper.IsBond(val.UnderlyingInstrumentType); + if (isBond && hasV(val.PosiNetNoFeePrice) && hasV(val.PosiGrossPrice) && hasV(val.InitYtm)) { + thisObj.$set(arr[num], 'bondManual', { CP: true, DP: true, YD: true }); + } }); } diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index c7fd23ff..3838b7ea 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -7,6 +7,7 @@ const inputFormatTradeAmount = Object.freeze({ precision: otcformat.trading.noti const inputFormatEqvNotional = Object.freeze({ precision: otcformat.trading.StockEqvNotional.precision, append: '', negative: true }); const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true }); +const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true }); let ValueDate = model.ValueDate; const vue = new Vue({ el: '#vueDiv', @@ -40,6 +41,10 @@ const vue = new Vue({ getPriceScale() { return this.multiplier == 100 ? 0.01 : 1; }, + getStorageDeliveryPrice() { + const precision = inputFormatSwapDeliveryPrice.precision + (this.multiplier === 100 ? 2 : 0); + return SwapCalc.roundHalfAwayFromZero(Number(this.floatPosition.TradingAmountAvg) * this.getPriceScale(), precision); + }, initDeal() { var positions = model.FlowEvents.filter((item) => { return item.UnderlyingCode; @@ -84,7 +89,7 @@ const vue = new Vue({ this.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(this.deal.SwapCloseAmount); //this.floatPosition.PosiNetPrice = otcformat.trading.tradeSinglePrice(this.floatPosition.PosiNetPrice); //this.floatPosition.PosiGrossPrice = otcformat.trading.tradeSinglePrice(this.floatPosition.PosiGrossPrice); - this.floatPosition.TradingAmountAvg = otcformat.trading.tradeSinglePrice(this.floatPosition.TradingAmountAvg); + this.floatPosition.TradingAmountAvg = _.round(Number(this.floatPosition.TradingAmountAvg), 9); this.floatPosition.TradingFee = otcformat.trading.StockEqvNotional(this.floatPosition.TradingFee); this.floatPosition.TradingFeePending = otcformat.trading.StockEqvNotional(this.floatPosition.TradingFeePending); this.floatPosition.DividendIn = parseFloat(this.floatPosition.DividendIn).toFixed(2); @@ -212,7 +217,7 @@ const vue = new Vue({ { code: thisObj.floatPosition.UnderlyingCode, valuedate: thisObj.deal.ValueDate }) .done(function (res) { res.obj = res.obj * thisObj.multiplier; - thisObj.floatPosition.TradingAmountAvg = otcformat.trading.tradeSinglePrice(res.obj); + thisObj.floatPosition.TradingAmountAvg = _.round(Number(res.obj), 9); thisObj.calcFloatClosePnl(); }); }, @@ -222,8 +227,8 @@ const vue = new Vue({ 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 scale = thisObj.getPriceScale(); - thisObj.floatPosition.MarkClosePnl = Math.round(thisObj.deal.CloseQty * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiNetPrice) * floatRatio * longRatio * 10000) / 10000; + let deliveryPrice = thisObj.getStorageDeliveryPrice(); + thisObj.floatPosition.MarkClosePnl = Math.round(thisObj.deal.CloseQty * (deliveryPrice - thisObj.initPosiNetPrice) * floatRatio * longRatio * 10000) / 10000; thisObj.floatPosition.MarkClosePnl = Number(thisObj.floatPosition.MarkClosePnl.toFixed(2));//MarkClosePnl 纯盯市不要计算交易费用和分红 thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl); thisObj.floatPosition.FloatPnlSum = (parseFloat(thisObj.floatPosition.MarkClosePnl) + TradingFee + TradingFeePending + parseFloat(thisObj.floatPosition.DividendIn)).toFixed(2); @@ -253,13 +258,13 @@ const vue = new Vue({ thisObj.deal.SwapRealizedPnL = pnl; thisObj.deal.SwapMarginRebatePnl = 0; thisObj.deal.SwapMarginAmount = 0; - let scale = thisObj.getPriceScale(); - thisObj.floatPosition.TradingAmount = parseFloat(thisObj.floatPosition.TradingAmountAvg) * parseFloat(thisObj.deal.CloseQty) * scale; + let deliveryPrice = thisObj.getStorageDeliveryPrice(); + thisObj.floatPosition.TradingAmount = deliveryPrice * parseFloat(thisObj.deal.CloseQty); thisObj.floatPosition.CloseFee = TradingFee; if (thisObj.deal.CloseQty == 0) { thisObj.floatPosition.TradingAmountFeeAvg = 0; } else { - thisObj.floatPosition.TradingAmountFeeAvg = parseFloat(thisObj.floatPosition.TradingAmountAvg) * scale + (TradingFee / thisObj.deal.CloseQty) * ratio; + thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * ratio; } this.interestList.forEach(x => { /*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/ @@ -360,7 +365,7 @@ const vue = new Vue({ thisObj.floatPosition.EventDate = thisObj.deal.ValueDate; let floatPosition = _.cloneDeep(thisObj.floatPosition); floatPosition.Quantity = reqObj.CloseQty; - floatPosition.TradingAmountAvg = floatPosition.TradingAmountAvg * thisObj.getPriceScale(); + floatPosition.TradingAmountAvg = thisObj.getStorageDeliveryPrice(); reqObj.FlowEvents.push(floatPosition); var postData = { unwindData: reqObj }; var msg = "确认提交平仓?"; diff --git a/YLErpWeb/wwwroot/Scripts/app/tradeHelper.js b/YLErpWeb/wwwroot/Scripts/app/tradeHelper.js index 75701fcb..fa58d6ff 100644 --- a/YLErpWeb/wwwroot/Scripts/app/tradeHelper.js +++ b/YLErpWeb/wwwroot/Scripts/app/tradeHelper.js @@ -276,6 +276,7 @@ tradeHelper.IsBond = function (instType) { switch (instType) { case "Bonds": + case "Bond": case "TBonds": case "CreditBonds": case "OtherBonds": diff --git a/YLErpWeb/wwwroot/Statics/views/TradeDetailsListMailV2.cshtml b/YLErpWeb/wwwroot/Statics/views/TradeDetailsListMailV2.cshtml index 36b18756..44cc8c9f 100644 --- a/YLErpWeb/wwwroot/Statics/views/TradeDetailsListMailV2.cshtml +++ b/YLErpWeb/wwwroot/Statics/views/TradeDetailsListMailV2.cshtml @@ -897,7 +897,7 @@ @tr.MetaDic["互换_收取方初始预付金"] @tr.MetaDic["互换_收取方交易费用"] @tr.MetaDic["互换_收取方多空方向"] - @tr.OriginalStockEqvNotional + @tr.TdDetail.OriginalStockEqvNotional.OtcFormat(OtcFormatFlag.StockEqvNotional) @tr.MetaDic["年化天数"] @tr.MetaDic["互换_互换日期"]