From 3728d45cc40d8863b93068239d46312a299dca85 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:09:51 +0800 Subject: [PATCH 1/4] =?UTF-8?q?test(web):=20swapPricePrecisionHelper?= =?UTF-8?q?=E8=A1=A519=E4=BE=8B=E7=89=B9=E5=BE=81=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E5=AD=97=E7=AC=A6=E4=B8=B2=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E5=8D=81=E8=BF=9B=E5=88=B6=E6=A0=B8=E5=BF=83=E2=80=94=E2=80=94?= =?UTF-8?q?shiftDecimal/roundDecimal/multiplyDecimal/getRule/roundForSubmi?= =?UTF-8?q?t/format=E5=85=A8golden=E5=80=BC=E6=96=AD=E8=A8=80(0.1=C3=970.2?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE0.02=E3=80=811.005=E2=86=921.01=E7=BB=95?= =?UTF-8?q?=E5=BC=80=E6=B5=AE=E7=82=B9=E9=99=B7=E9=98=B1=E3=80=81roundDeci?= =?UTF-8?q?mal=E7=BB=9D=E5=AF=B9=E5=80=BC=E5=9B=9B=E8=88=8D=E4=BA=94?= =?UTF-8?q?=E5=85=A5=E4=B8=8EExitYtm=E7=BA=A6=E5=AE=9A=E5=90=8C=E5=90=91?= =?UTF-8?q?=E3=80=81=E4=B9=98=E6=B3=95=E4=BF=9D=E7=95=99=E5=B0=8F=E6=95=B0?= =?UTF-8?q?=E6=A0=87=E5=BA=A61.5=C3=972=3D3.0=E3=80=81Bond=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=8B=BC=E9=94=99=E2=86=92null=E5=9B=9E=E8=90=BDumpri?= =?UTF-8?q?ce=E8=80=8CStock=E5=9B=9E=E8=90=BD=E5=93=81=E7=A7=8D=E7=BA=A7?= =?UTF-8?q?=E7=9A=84=E4=B8=8D=E5=AF=B9=E7=A7=B0=E5=86=BB=E7=BB=93)?= =?UTF-8?q?=EF=BC=9Brequire=E7=9B=B4=E8=BF=9E=E5=8A=A0=E8=BD=BD=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3readFileSync=E6=B2=99=E7=AE=B1(=E5=8F=AF=E8=A2=AB?= =?UTF-8?q?=E6=8F=92=E6=A1=A9)=EF=BC=9B=E8=A6=86=E7=9B=96=E7=8E=87?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E7=BA=B3=E5=85=A5=E8=AF=A5=E6=96=87=E4=BB=B6?= =?UTF-8?q?=EF=BC=9B=E5=AE=9E=E9=94=A4=E6=B3=A8=E9=87=8A:=E6=BA=90?= =?UTF-8?q?=E7=A0=81=E5=9C=A8rootDir=E5=A4=96=E8=87=B4babel(=E6=97=A0trans?= =?UTF-8?q?former)=E4=B8=8Ev8(=E9=80=83=E9=80=B8=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E5=BD=92=E5=9B=A0,=E5=90=ABroots/=E7=BB=9D?= =?UTF-8?q?=E5=AF=B9=E8=B7=AF=E5=BE=84=E5=8F=98=E4=BD=93=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?jest29.7)=E4=B8=A4=E6=8F=90=E4=BE=9B=E5=99=A8=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=8E=87=E6=81=920=E4=B8=94=E9=97=A8=E6=A7=9B=E4=B8=8D?= =?UTF-8?q?=E8=A7=A6=E5=8F=91,=E7=9C=9F=E4=BF=AE=E9=9C=80=E9=87=8D?= =?UTF-8?q?=E6=9E=84rootDir=E5=8F=A6=E8=A1=8C=E7=AB=8B=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/fe-tests/jest.config.js | 4 + .../fe-tests/swapPricePrecisionHelper.test.js | 178 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js diff --git a/YLErpWeb/fe-tests/jest.config.js b/YLErpWeb/fe-tests/jest.config.js index b06bd582..f6877998 100644 --- a/YLErpWeb/fe-tests/jest.config.js +++ b/YLErpWeb/fe-tests/jest.config.js @@ -23,9 +23,13 @@ module.exports = { moduleDirectories: ['node_modules', '../wwwroot/Scripts'], // 覆盖率配置(--coverage 时生效) + // 已知问题:源码在 rootDir(fe-tests) 之外,babel 提供器因 transform:{} 无插桩器、 + // v8 提供器对 rootDir 逃逸路径无法归因(jest 29.7 实测,含 roots/绝对路径变体), + // 覆盖率表恒为 0 且门槛不触发。清单仍保留以固化意图,真实修复需重构 rootDir。 collectCoverageFrom: [ '../wwwroot/Scripts/app/swaptrade/swapCalc.js', '../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js', + '../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js', '../wwwroot/Scripts/fast/fastVue.base.js', // 逐步加入更多文件 ], diff --git a/YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js b/YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js new file mode 100644 index 00000000..d30ae970 --- /dev/null +++ b/YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js @@ -0,0 +1,178 @@ +/** + * swapPricePrecisionHelper.test.js — 字符串精确十进制核心的特征测试 + * ============================================================================ + * 目的:swapPricePrecisionHelper.js 的组件事件(swapPriceInput.component.test.js)与 + * 配置接线(swapPrecisionConfig.test.js)已有测试,但【字符串精确十进制核心】 + * (shiftDecimal / roundDecimal / multiplyDecimal / getRule / format / + * roundForSubmit) 一直只有间接覆盖。精度即资损,这里用 golden 值冻结现状: + * 1. 全部走字符串数位运算,绕开 IEEE754 浮点陷阱(0.1*0.2、1.005.toFixed); + * 2. roundDecimal 是【绝对值上四舍五入】= AwayFromZero,与 unwindBondCalc + * 的 ExitYtm 约定同向; + * 3. roundDecimal 不补零(1.2 → '1.2'),补零是 formatCommon/formatFixed 的职责; + * 4. roundDecimal 对非法输入/负精度【原样返回不归一】,调用方别拿它当校验器; + * 5. getRule 的覆盖优先级:先查 defaults 有无该品种(没有直接 null),再看 + * main.swapPricePrecision 覆盖,覆盖非法则回落 defaults。 + * + * 做法:直接 require 源文件(jest v8 覆盖率据此插桩,readFileSync+new Function + * 的沙箱加载会让覆盖率统计看不见);配置覆盖用 global.main.swapPricePrecision + * 注入——模块内 global 绑定到 globalThis,与本测试文件的 global 同源, + * beforeEach/afterEach 清理防串扰。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest swapPricePrecisionHelper + */ +const helper = require('../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js'); + +function withConfig(config, extras) { + if (config !== undefined) global.main = { swapPricePrecision: config }; + if (extras && extras.otcformat) global.otcformat = extras.otcformat; +} + +beforeEach(() => { delete global.main; delete global.otcformat; }); +afterEach(() => { delete global.main; delete global.otcformat; }); + +describe('shiftDecimal:纯数位平移,不经浮点', () => { + + test('右移补零 / 左移进小数', () => { + expect(helper.shiftDecimal('1.5', 1)).toBe('15'); + expect(helper.shiftDecimal('1.5', -1)).toBe('0.15'); + expect(helper.shiftDecimal('12.34', -3)).toBe('0.01234'); + expect(helper.shiftDecimal('0.001', -2)).toBe('0.00001'); + expect(helper.shiftDecimal('5', 2)).toBe('500'); + }); + + test('保留符号;places=0 或非整数只做归一化', () => { + expect(helper.shiftDecimal('-1.5', 1)).toBe('-15'); + expect(helper.shiftDecimal('1.50', 0)).toBe('1.50'); // 不动尾巴零 + expect(helper.shiftDecimal('1.50', 1.5)).toBe('1.50'); + }); + + test('归一化先行:首尾零/符号/科学计数法', () => { + expect(helper.shiftDecimal('007.50', 0)).toBe('7.50'); + expect(helper.shiftDecimal('+7', 0)).toBe('7'); + expect(helper.shiftDecimal('-0.000', 0)).toBe('0.000'); // 负零坍缩为无符号 + expect(helper.shiftDecimal('1e-7', 0)).toBe('0.0000001'); + expect(helper.shiftDecimal('1.23e5', 0)).toBe('123000'); + }); + + test('非法输入返回 null,空白串返回空串', () => { + expect(helper.shiftDecimal('abc', 2)).toBeNull(); + expect(helper.shiftDecimal('1.2.3', 2)).toBeNull(); + expect(helper.shiftDecimal('', 2)).toBe(''); + }); +}); + +describe('roundDecimal:绝对值四舍五入(AwayFromZero),字符串路径无浮点陷阱', () => { + + test('经典浮点陷阱对照:1.005 与 0.1×0.2 场景字符串路径给正确答案', () => { + expect(Number(1.005).toFixed(2)).toBe('1.00'); // 浮点路径错(冻结对照) + expect(helper.roundDecimal('1.005', 2)).toBe('1.01'); + expect(helper.roundDecimal('2.345', 2)).toBe('2.35'); + expect(helper.roundDecimal('2.344', 2)).toBe('2.34'); + }); + + test('负数远离零(与 ExitYtm 约定同向)', () => { + expect(helper.roundDecimal('-2.345', 2)).toBe('-2.35'); + expect(helper.roundDecimal('-2.5', 0)).toBe('-3'); + expect(helper.roundDecimal('-0.00005', 4)).toBe('-0.0001'); + }); + + test('进位链:跨数量级与跨整数位', () => { + expect(helper.roundDecimal('9.99', 1)).toBe('10.0'); + expect(helper.roundDecimal('9.99', 0)).toBe('10'); + expect(helper.roundDecimal('99.999', 2)).toBe('100.00'); + expect(helper.roundDecimal('0.00005', 4)).toBe('0.0001'); + }); + + test('不补零也不主动去零:位数不足原样返回,已有位数保留尾巴零', () => { + expect(helper.roundDecimal('1.2', 4)).toBe('1.2'); // 不足4位不补零(补零是 formatCommon 的职责) + expect(helper.roundDecimal('3.10000', 4)).toBe('3.1000'); // 已到4位:只取整不去零(去零是 format 的职责) + }); + + test('防御姿态:负精度/非法输入原样返回,不做校验', () => { + expect(helper.roundDecimal('2.345', -1)).toBe('2.345'); + expect(helper.roundDecimal('2.345', 1.5)).toBe('2.345'); + expect(helper.roundDecimal('abc', 2)).toBe('abc'); + }); +}); + +describe('multiplyDecimal:字符串精确乘法', () => { + + test('0.1×0.2 精确为 0.02(浮点给 0.020000000000000004)', () => { + expect(helper.multiplyDecimal('0.1', '0.2')).toBe('0.02'); + expect(helper.multiplyDecimal('0.1', '0.1')).toBe('0.01'); + expect(helper.multiplyDecimal('1.1', '1.1')).toBe('1.21'); + }); + + test('符号组合;保留小数标度(1.5×2=3.0 而非 3)', () => { + expect(helper.multiplyDecimal('1.5', '2')).toBe('3.0'); + expect(helper.multiplyDecimal('-1.5', '2')).toBe('-3.0'); + expect(helper.multiplyDecimal('-1.5', '-2')).toBe('3.0'); + expect(helper.multiplyDecimal('-0.03', '-0.02')).toBe('0.0006'); + }); + + test('大整数精确(超出 Number.MAX_SAFE_INTEGER)', () => { + expect(helper.multiplyDecimal('123456789', '987654321')).toBe('121932631112635269'); + }); + + test('非法输入 null;空串按 0 处理', () => { + expect(helper.multiplyDecimal('abc', '1')).toBeNull(); + expect(helper.multiplyDecimal('', '1')).toBe('0'); + }); +}); + +describe('getRule / getInputFormat / roundForSubmit:字段规则解析与覆盖优先级', () => { + test('defaults 字段级规则:债券三字段 vs 股票兜底', () => { + expect(helper.getRule('Bond', 'yield')).toEqual({ integerDigits: 2, precision: 4 }); + expect(helper.getRule('Bond', 'grossPrice')).toEqual({ integerDigits: 6, precision: 9 }); + // 债券顶层没有 integerDigits/precision:字段拼错 → null(后续走 umprice 兜底), + // 而 Stock 顶层有 → 字段拼错回落品种级规则。这个不对称冻结于此。 + expect(helper.getRule('Bond', 'unknownField')).toBeNull(); + expect(helper.getRule('Stock', 'anything')).toEqual({ integerDigits: 7, precision: 2 }); + expect(helper.getRule('NoSuchType', 'yield')).toBeNull(); + }); + + test('main.swapPricePrecision 覆盖优先于 defaults,非法覆盖回落 defaults', () => { + withConfig({ Bond: { yield: { integerDigits: 3, precision: 5 } } }); + expect(helper.getRule('Bond', 'yield')).toEqual({ integerDigits: 3, precision: 5 }); + + withConfig({ Bond: { yield: { integerDigits: 0 } } }); + const bad = helper; + expect(bad.getRule('Bond', 'yield')).toEqual({ integerDigits: 2, precision: 4 }); + + // defaults 里没有的品种,配置了也不认:覆盖只允许白名单内微调 + withConfig({ BrandNewType: { yield: { integerDigits: 3, precision: 5 } } }); + const unknown = helper; + expect(unknown.getRule('BrandNewType', 'yield')).toBeNull(); + }); + + test('getInputFormat 合并 options', () => { + expect(helper.getInputFormat('Bond', 'yield', { negative: true })) + .toEqual({ negative: true, integerDigits: 2, precision: 4 }); + expect(helper.getInputFormat('NoSuchType', 'yield', { negative: true })) + .toEqual({ negative: true }); + }); + + test('roundForSubmit:按规则精度+offset 取整;空值/无规则原样返回', () => { + expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield')).toBe('3.1416'); + expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield', 2)).toBe('3.141593'); + expect(helper.roundForSubmit('', 'Bond', 'yield')).toBe(''); + expect(helper.roundForSubmit('1.2', 'NoSuchType', 'yield')).toBe('1.2'); + expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield', 2.5)).toBe('3.1416'); // 非整数offset忽略 + }); +}); + +describe('format:字段级展示态(取整+去尾巴零)', () => { + test('按字段规则取整并去尾零', () => { + expect(helper.format('3.14159', 'Bond', 'yield')).toBe('3.1416'); + expect(helper.format('3.10000', 'Bond', 'yield')).toBe('3.1'); + expect(helper.format('99.5', 'Stock', 'whatever')).toBe('99.5'); + expect(helper.format('', 'Bond', 'yield')).toBe(''); + }); + + test('无规则时回落 otcformat.trading.umprice', () => { + withConfig(undefined, { + otcformat: { trading: { umprice: function (v) { return 'UM:' + v; } } } + }); + expect(helper.format('1.23', 'NoSuchType', 'yield')).toBe('UM:1.23'); + }); +}); From 8b66875098d21ef5728305d904c52b766c2827f2 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:09:57 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(swap):=20EQD-6977=20=E7=BD=9A=E6=81=AF?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E8=A1=A5share<1=E6=81=92=E7=AD=89=E5=BC=8F?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E2=80=94=E2=80=9450%=E5=B9=B3=E4=BB=93+?= =?UTF-8?q?=E6=97=A0preEod=E5=9C=BA=E6=99=AF=E9=92=89merger=E5=A4=8D?= =?UTF-8?q?=E5=88=BBGetInterests=E6=9C=AC=E9=87=91=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E7=9A=84=E6=8E=A5=E7=BC=9D(=E6=A0=87=E7=9A=84=E6=9C=9F?= =?UTF-8?q?=E5=88=9D=E5=85=A8=E4=BB=B7CalcNotional=3DposiNotional=C3=97clo?= =?UTF-8?q?sePercent=20vs=20=E9=87=8D=E6=94=BE=E5=9F=BA=E6=95=B0=E6=8C=89c?= =?UTF-8?q?losePosiNotionalValue=E7=BC=A9=E6=94=BE)=EF=BC=9B=E7=8E=B0?= =?UTF-8?q?=E6=9C=89=E7=94=A8=E4=BE=8B=E5=85=A8closePercent=3D1m=EF=BC=8C?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E5=8F=A3=E5=BE=84=E8=8B=A5=E6=9C=89=E5=81=8F?= =?UTF-8?q?=E5=B7=AE=E5=9C=A8share=3D1=E4=B8=8B=E4=B8=8D=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E3=80=81share<1=E6=97=B6=E2=91=A0=E6=B7=B7=E5=85=A5=E6=9C=AC?= =?UTF-8?q?=E9=87=91=E5=B7=AE=EF=BC=9B=E5=8F=98=E5=BC=82=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=EF=BC=9ACalcNotional=E4=B8=B4=E6=97=B6=E5=BF=BD=E7=95=A5closeP?= =?UTF-8?q?ercent=E5=94=AF=E6=AD=A4=E7=94=A8=E4=BE=8B=E7=BA=A2(1=E8=B4=A57?= =?UTF-8?q?=E8=BF=87)=E8=AF=81=E6=98=8E=E9=89=B4=E5=88=AB=E5=8A=9B?= =?UTF-8?q?=EF=BC=9BRunFee=E5=8A=A0closePercent=E5=8F=82=E6=95=B0=E9=BB=98?= =?UTF-8?q?=E8=AE=A41m=E5=AD=98=E9=87=8F=E8=B0=83=E7=94=A8=E9=9B=B6?= =?UTF-8?q?=E5=BD=B1=E5=93=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyBoundaryMatrixTest.cs | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs index 6ebb8bf0..bf3dd846 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs @@ -11,7 +11,9 @@ namespace UnitTestProject.Modules.SwapModule.Penalty /// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天); /// ③ 到期日恰为重置日(末段 [到期,到期] 1 天); /// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同); - /// ⑤ 起息日当天平仓(无 preEod)。 + /// ⑤ 起息日当天平仓(无 preEod); + /// ⑥ 部分平仓 share<1 + 无 preEod 兜底——钉 merger 复刻 GetInterests 本金口径的接缝 + /// (现有用例全部 closePercent=1m,重放基数与复刻本金的口径偏差在 share=1 下不可见)。 /// /// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率, /// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。 @@ -67,7 +69,7 @@ namespace UnitTestProject.Modules.SwapModule.Penalty private static decimal RunFee(trade td, swap_position p, decimal settledAmount, eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread, - decimal interestPrincipal = 0m, bool maturityCalcLast = true) + decimal interestPrincipal = 0m, bool maturityCalcLast = true, decimal closePercent = 1m) { var e = new swap_flow_event { @@ -78,7 +80,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty PenaltyInterestFeeMerger.Merge( td, new List { p }, new List { e }, unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast, - posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, + posiNotionalValue: Notional, closePosiNotionalValue: Notional * closePercent, + closePercent: closePercent, getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread); return e.InterestFee; } @@ -192,6 +195,30 @@ namespace UnitTestProject.Modules.SwapModule.Penalty "无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)"); } + [TestMethod] + public void 部分平仓无preEod兜底_share对齐本金口径_恒等式成立() + { + // 接缝守卫:merger 的 closePrincipal 走 CalcNotional 复刻 GetInterests 口径 + // (标的期初全价 = posiNotional×closePercent),而重放基数由调用方以 + // closePosiNotionalValue 缩放——两处口径若有偏差,share=1 时不可见、 + // share<1 时 ① 里会混入本金差。本用例以 50% 平仓钉死该对齐。 + var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30); + var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结),14 天重置 + var share = 0.5m; + var closedNotional = Notional * share; + // 被平份额的实结与重放基数:复利对 notional 线性,直接按半额本金重放 + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14); + var replayFinalBasis = closedNotional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, notional: closedNotional, period: 14); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed, + preEod: null, unwind: unwind, settled: false, spread: hist[^1], + interestPrincipal: replayFinalBasis, maturityCalcLast: false, closePercent: share); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "部分平仓+无preEod:兜底①按被平份额缩放精确,全期(被平份额)=实结+罚息(口径漂移时此式必挂)"); + } + [TestMethod] public void 起息日当天平仓_无preEod_恒等式成立() { From 6e76f1c10d1a7823696a8ba176ea2354ab7b6c57 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:10:04 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(swap):=20EQD-6977=20=E6=97=A0preEod?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E2=91=A0=3D0=E4=B8=94=E8=B4=A6=E9=BE=84?= =?UTF-8?q?=E8=B6=85=E9=87=8D=E7=BD=AE=E5=91=A8=E6=9C=9F=E8=A1=A5=E9=80=80?= =?UTF-8?q?=E5=8C=96=E5=91=8A=E8=AD=A6trace=E2=80=94=E2=80=94interestWindo?= =?UTF-8?q?wEmpty(=E5=BD=93=E6=97=A5=E5=B7=B2=E7=BB=93=E6=81=AF)=E6=97=A9?= =?UTF-8?q?=E9=80=80=E4=B8=8D=E9=87=8D=E6=94=BE=E8=A6=86=E7=9B=96=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E5=80=BC=E6=97=B6=E2=91=A0=E9=9D=99=E9=BB=980(?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E6=97=A5=E5=88=86=E6=94=AF=E6=9C=AC=E6=9C=89?= =?UTF-8?q?=E5=90=8C=E7=B1=BB=E5=91=8A=E8=AD=A6,=E6=AE=B5=E4=B8=AD?= =?UTF-8?q?=E5=88=86=E6=94=AF=E6=BC=8F)=E5=A4=8D=E5=88=A9=E6=AF=8F?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E5=B9=B6=E6=9C=AC=E7=90=86=E5=BA=94=E2=91=A0?= =?UTF-8?q?>0=EF=BC=9Btrace=E5=90=AB=E4=BA=8B=E4=BB=B6=E5=9F=BA=E6=95=B0/?= =?UTF-8?q?=E5=B9=B3=E4=BB=93=E6=9C=AC=E9=87=91=E4=B8=A4=E4=BE=A7=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E5=80=BC+=E8=B4=A6=E9=BE=84=E5=A4=A9=E6=95=B0+?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=91=A8=E6=9C=9F+=E7=96=91=E4=BC=BC?= =?UTF-8?q?=E6=88=90=E5=9B=A0=E6=8C=87=E5=90=91(interestWindowEmpty?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=E6=9C=AA=E9=87=8D=E6=94=BE/=E6=97=A5?= =?UTF-8?q?=E7=BB=88=E5=BD=92=E6=A1=A3=E7=BC=BA=E5=A4=B1)=E8=B6=B3?= =?UTF-8?q?=E5=A4=9F=E4=BA=8B=E5=90=8E=E5=AE=9A=E4=BD=8D=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=EF=BC=9B=E6=89=BF=E6=8E=A5=E5=9D=97=E6=8A=BD=E4=B8=BAResolveCo?= =?UTF-8?q?mpoundCarry=E7=A7=81=E6=9C=89=E7=BA=AF=E6=96=B9=E6=B3=95(Merge?= =?UTF-8?q?=E7=98=A6=E8=BA=AB,=E4=B8=89=E5=88=86=E6=94=AF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=A5=91=E7=BA=A6=E6=B3=A8=E9=87=8A=E9=9A=8F=E8=A1=8C?= =?UTF-8?q?,=E8=A1=8C=E4=B8=BA=E9=9B=B6=E5=8F=98=E5=8C=96)=EF=BC=9BTDD?= =?UTF-8?q?=E5=85=88=E8=A1=8C3=E4=BE=8Btrace=E9=92=89=E5=AD=90:=E9=80=80?= =?UTF-8?q?=E5=8C=96=E5=BF=85=E5=91=8A=E8=AD=A6(=E5=85=88=E7=BA=A2)/?= =?UTF-8?q?=E5=85=9C=E5=BA=95>0=E4=B8=8D=E5=91=8A=E8=AD=A6/=E7=9C=9F?= =?UTF-8?q?=E9=A6=96=E6=97=A5=E4=B8=8D=E5=91=8A=E8=AD=A6=EF=BC=9B=E7=BD=9A?= =?UTF-8?q?=E6=81=AF=E5=9F=9F27=E4=BE=8B+GetInterests=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E8=AF=AD=E4=B9=899=E4=BE=8B=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyInterestFeeMergerTest.cs | 45 +++++++++- .../Penalty/PenaltyInterestFeeMerger.cs | 86 ++++++++++++------- 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs index 2186bfdc..5df336c4 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs @@ -50,7 +50,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty private static void RunMerge( swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod, - Func? getSpread = null, Func? tryGetFixing = null) + Func? getSpread = null, Func? tryGetFixing = null, + AccrualTrace? trace = null) { getSpread ??= _ => Rate; tryGetFixing ??= (d, code) => Rate; @@ -61,7 +62,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, getSpread: getSpread, getPreEod: _ => preEod, - tryGetFixing: tryGetFixing); + tryGetFixing: tryGetFixing, + trace: trace); } /// 复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。 @@ -152,6 +154,45 @@ namespace UnitTestProject.Modules.SwapModule.Penalty Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息"); } + [TestMethod] + public void 无preEod复利段中兜底为零且账龄超重置周期_留退化告警trace() + { + // 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底①=0。 + // 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应①>0,①=0 属退化—— + // 典型成因=interestWindowEmpty(当日已结息)早退未重放覆盖种子、或日终归档缺失。 + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional; // GetInterests 种子值:interestWindowEmpty 早退路径不会用重放基数覆盖它 + var trace = new AccrualTrace(); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); + + StringAssert.Contains(trace.ToString(), "无preEod兜底①=0", + "①=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因"); + } + + [TestMethod] + public void 无preEod兜底为正_不留退化告警() + { + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → ①=100000 正常路径 + var trace = new AccrualTrace(); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); + + Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "①>0 是正常兜底路径,不得告警"); + } + + [TestMethod] + public void 无preEod真首日兜底为零_不留退化告警() + { + var p = Leg(InterestTypeEnum.复利); + p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,①=0 是设计内约定(类头注) + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional; + var trace = new AccrualTrace(); + RunMerge(p, e, preEod: null, trace: trace); + + Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "真首日 ①=0 合法,不得告警"); + } + [TestMethod] public void 冻结利率解析失败_跳过该腿不阻断() { diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index a6b2256b..91ab256f 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -93,39 +93,9 @@ public static class PenaltyInterestFeeMerger } // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 - // ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): - // 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; - // 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) - // 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; - // 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 - // preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 - decimal capitalized = 0m, carryIn = 0m; - if (isCompound) - { - var periodDays = position.interest_rest_days ?? 1; - var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); - if (unwindOnResetDay) - { - capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; - if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) - trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); - } - else if (preEod != null) - { - capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal); - } - else - { - capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); - } - // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) - if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) - { - trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); - capitalized = Math.Max(0m, normalEvent.InterestAmount); - } - carryIn = normalEvent.InterestAmount - capitalized; - } + var (capitalized, carryIn) = isCompound + ? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace) + : (0m, 0m); var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound); // 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate @@ -149,4 +119,54 @@ public static class PenaltyInterestFeeMerger $"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}"); } } + + /// + /// 复利承接量:① 实际滚动基数中已并入部分 + ② 最近重置日后实际已计利息(= 实结 − ①)。 + /// + /// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): + /// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + /// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) + /// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; + /// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 + /// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 + /// + private static (decimal Capitalized, decimal CarryIn) ResolveCompoundCarry( + swap_position position, swap_flow_event normalEvent, eod_swap_position? preEod, + decimal closePrincipal, decimal share, DateTime unwindDate, AccrualTrace? trace) + { + var periodDays = position.interest_rest_days ?? 1; + var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); + + var capitalized = 0m; + if (unwindOnResetDay) + { + capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; + if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) + trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); + } + else if (preEod != null) + { + capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal); + } + else + { + capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); + // 兜底①=0 但账龄已过重置周期:复利每周期并本,理应 ①>0——多为 interestWindowEmpty + // (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。 + var ageDays = (unwindDate - position.PosiStartDate).Days; + if (capitalized == 0m && ageDays >= periodDays) + trace?.Note( + $"PENALTY|p{position.id} 注意 无preEod兜底①=0但账龄{ageDays}天≥重置周期{periodDays}天:" + + $"事件基数{normalEvent.InterestPrincipal:F2}=平仓本金{closePrincipal:F2}(疑似interestWindowEmpty种子未重放/日终归档缺失," + + $"请核对swap_flow_event.InterestPrincipal重放回写与eod_swap_position归档)"); + } + + // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) + if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) + { + trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); + capitalized = Math.Max(0m, normalEvent.InterestAmount); + } + return (capitalized, normalEvent.InterestAmount - capitalized); + } } From e8d0a764422d0b8e39707169f3719e80a8a972f7 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:17:20 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(web):=20=E5=B9=B3=E4=BB=93?= =?UTF-8?q?=E9=A1=B5=E6=94=B6=E4=BB=98=E6=96=B9=E5=90=91=E7=AC=A6=E5=8F=B7?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=8C=96unwindLegSign.js+4=E4=BE=8B=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=E2=80=94=E2=80=94=E8=A3=B8=E4=B8=89=E5=85=83(x=3D=3D1?= =?UTF-8?q?=3F=C2=B11:=E2=88=931)=E6=8A=BD=E4=B8=BA=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=B9=B6=E5=86=BB=E7=BB=93=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E4=BA=8B=E5=AE=9E:=E5=88=A9=E6=81=AF=E7=9B=88=E4=BA=8F?= =?UTF-8?q?=E4=B8=8E=E4=BF=9D=E8=AF=81=E9=87=91=E8=BF=94=E8=BF=98=E6=9C=AC?= =?UTF-8?q?=E9=87=91=E5=AF=B9=E5=90=8C=E4=B8=80InterestDirection=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE=E5=BF=85=E9=A1=BB=E5=8F=8D=E5=8F=B7(=E5=88=A9?= =?UTF-8?q?=E6=81=AF=3D=E4=B9=B0=E6=96=B9=E6=8C=81=E6=9C=89=E6=A0=87?= =?UTF-8?q?=E7=9A=84=E5=90=91=E4=BA=A4=E6=98=93=E5=95=86=E8=9E=8D=E8=B5=84?= =?UTF-8?q?=E7=9A=84=E6=88=90=E6=9C=AC,=E4=BF=9D=E8=AF=81=E9=87=91?= =?UTF-8?q?=E8=BF=94=E8=BF=98=3D=E9=80=80=E5=AE=A2=E6=88=B7=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E4=BA=A4=E7=9A=84=E9=92=B1,=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E9=9D=9E=E7=AC=94=E8=AF=AF),=E4=B8=8D?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E6=96=AD=E8=A8=80=E4=B8=A4=E7=AC=A6=E5=8F=B7?= =?UTF-8?q?=E4=B9=8B=E5=92=8C=E6=81=920,=E9=A1=BA=E6=89=8B=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=BF=85=E6=8C=82=EF=BC=9BinterestPnlSign=E5=AF=B9?= =?UTF-8?q?=E5=88=A9=E6=81=AF/=E4=BF=9D=E8=AF=81=E9=87=91=E4=B8=A4?= =?UTF-8?q?=E8=85=BF=E9=80=9A=E7=94=A8(changeInterestAmount=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=AF=B9=E4=B8=A4=E7=B1=BB=E8=A1=8C=E9=83=BD=E7=BB=91?= =?UTF-8?q?=E5=AE=9A,=E5=B7=B2=E6=A0=B8=E5=AE=9E)=EF=BC=9B=E5=8F=A6?= =?UTF-8?q?=E5=88=A0=E6=AD=BB=E4=BB=A3=E7=A0=81:this.ratio/this.shortRatio?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=B1=9E=E6=80=A7+=E8=B5=8B=E5=80=BC?= =?UTF-8?q?=E5=85=A8=E5=BA=93=E6=97=A0=E8=AF=BB=E5=8F=96(=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E4=BA=A6=E6=97=A0=E7=BB=91=E5=AE=9A)=E5=8F=8A?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=8E=89=E7=9A=84=E5=8E=86=E5=8F=B2=E4=B8=89?= =?UTF-8?q?=E5=85=83=EF=BC=9BSwapUnwind.cshtml=E8=A1=A5script=E6=A0=87?= =?UTF-8?q?=E7=AD=BE,=E4=B8=A4=E4=B8=AA=E6=89=A7=E8=A1=8C=E5=9E=8B?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=B2=99=E7=AE=B1=E5=96=82=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=9B=E8=A1=8C=E4=B8=BA=E7=AD=89=E4=BB=B7?= =?UTF-8?q?(Number=3D=3D=3D1=E4=B8=8E=3D=3D1=E5=AF=B9=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E5=85=A5=E5=8F=82=E7=AD=89=E4=BB=B7=E5=B7=B2=E9=AA=8C=E8=AF=81?= =?UTF-8?q?),=E5=85=A8=E5=A5=97315=E4=BE=8B=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml | 1 + .../closePercentInterestRefresh.test.js | 2 + YLErpWeb/fe-tests/unwindLegSign.test.js | 47 +++++++++++++++++++ YLErpWeb/fe-tests/unwindSwapTrade.test.js | 2 + .../Scripts/app/swaptrade/unwindLegSign.js | 45 ++++++++++++++++++ .../Scripts/app/swaptrade/unwindSwapTrade.js | 18 +++---- 6 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 YLErpWeb/fe-tests/unwindLegSign.test.js create mode 100644 YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index c1fdaf8d..353c255e 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -27,6 +27,7 @@ + }
diff --git a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js index 2fb4a7eb..5d31102f 100644 --- a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js +++ b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js @@ -155,6 +155,8 @@ function loadVueApp(model, mockPost) { roundExitYtm(v) { return v; }, applyManualEdit(s) { return s; } }, + // 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖) + UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'), _: { round(value, precision) { return Number(Number(value || 0).toFixed(precision || 0)); diff --git a/YLErpWeb/fe-tests/unwindLegSign.test.js b/YLErpWeb/fe-tests/unwindLegSign.test.js new file mode 100644 index 00000000..3258511c --- /dev/null +++ b/YLErpWeb/fe-tests/unwindLegSign.test.js @@ -0,0 +1,47 @@ +/** + * unwindLegSign.test.js — 平仓页收付方向符号映射(隐式约定显式化) + * ============================================================================ + * 冻结的领域事实(2026-08-21 业务确认): + * 利息腿(融资成本)与保证金腿(返息/返还本金)方向【必须相反】—— + * 利息是买方持有标的向交易商融资的成本(买方付出去的钱); + * 保证金是客户自己交的抵押金,返息/返还是把客户自己的钱退回来。 + * 两者对同一 InterestDirection 枚举符号互为镜像,这是业务事实而非笔误, + * 任何人"顺手统一"这两个符号都会翻转保证金返还方向(资损级 bug)。 + * + * 另冻结:PayDirection==1 → +1、PositionType==1(多头) → +1 的浮动端/多空符号。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest unwindLegSign + */ +const UnwindLegSign = require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'); + +describe('收付方向符号:各枚举映射', () => { + test('利息盈亏(利息腿/保证金腿通用):收取(1)→+1,支付(其他)→-1', () => { + expect(UnwindLegSign.interestPnlSign(1)).toBe(1); + expect(UnwindLegSign.interestPnlSign(0)).toBe(-1); + expect(UnwindLegSign.interestPnlSign(null)).toBe(-1); + }); + + test('保证金腿返还本金:与利息腿同枚举反号(返的是客户自己的钱)', () => { + expect(UnwindLegSign.marginRebatePrincipalSign(1)).toBe(-1); + expect(UnwindLegSign.marginRebatePrincipalSign(0)).toBe(1); + expect(UnwindLegSign.marginRebatePrincipalSign(null)).toBe(1); + }); + + test('浮动端收付:PayDirection==1(收取)→+1;多空:PositionType==1(多头)→+1', () => { + expect(UnwindLegSign.payDirectionSign(1)).toBe(1); + expect(UnwindLegSign.payDirectionSign(0)).toBe(-1); + expect(UnwindLegSign.positionTypeSign(1)).toBe(1); + expect(UnwindLegSign.positionTypeSign(0)).toBe(-1); + }); +}); + +describe('不变量:利息腿与保证金腿符号互为镜像(业务事实,禁止统一)', () => { + test('同一 InterestDirection 下两符号之和恒为 0', () => { + [1, 0, null, undefined, 2].forEach(dir => { + expect( + UnwindLegSign.interestPnlSign(dir) + + UnwindLegSign.marginRebatePrincipalSign(dir) + ).toBe(0); + }); + }); +}); diff --git a/YLErpWeb/fe-tests/unwindSwapTrade.test.js b/YLErpWeb/fe-tests/unwindSwapTrade.test.js index f0774f96..488a0c3b 100644 --- a/YLErpWeb/fe-tests/unwindSwapTrade.test.js +++ b/YLErpWeb/fe-tests/unwindSwapTrade.test.js @@ -66,6 +66,8 @@ function loadUnwindHelpers() { roundHalfAwayFromZero(value) { return value; }, calcCloseQtyByOriginalPercent() { return 0; } }, + // 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖) + UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'), _: { round(value, precision) { return Number(Number(value || 0).toFixed(precision || 0)); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js new file mode 100644 index 00000000..a07d62cd --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js @@ -0,0 +1,45 @@ +/** + * unwindLegSign.js — 平仓页收付方向符号(纯函数) + * ============================================================================ + * 把散落在 unwindSwapTrade.js 各计算式里的裸三元(xxx==1 ? ±1 : ∓1)显式化为命名函数。 + * 【为什么必须显式化】利息盈亏与保证金返还本金对同一 InterestDirection 枚举符号相反—— + * 这是业务事实而非笔误,任何"顺手统一"都会翻转保证金返还方向(资损级 bug): + * 利息盈亏(利息腿/保证金腿通用,changeInterestAmount 对两类行都生效): + * 收益互换中买方为持有标的向交易商融资,利息=融资成本(买方付出去的钱), + * InterestDirection==1(收取) → 盈亏记 +1; + * 保证金返还本金:保证金是客户自己交的抵押金,返息/返还本金是把客户自己的钱退回来, + * 本金流方向与利息(成本)相反 → 同枚举符号取镜像 -1。 + * + * 其余两个符号:PayDirection==1(浮动端收取) → +1;PositionType==1(多头) → +1。 + * + * 加载:浏览器 script 标签(先于 unwindSwapTrade.js);Node:module.exports 供 jest。 + */ +var UnwindLegSign = (function () { + 'use strict'; + + // Number()===1 与页面原 ==1 对 '1'/true/数字等实际入参等价,且对 null/undefined 同样落到 -1 分支 + function interestPnlSign(interestDirection) { + return Number(interestDirection) === 1 ? 1 : -1; + } + + function marginRebatePrincipalSign(interestDirection) { + return Number(interestDirection) === 1 ? -1 : 1; + } + + function payDirectionSign(payDirection) { + return Number(payDirection) === 1 ? 1 : -1; + } + + function positionTypeSign(positionType) { + return Number(positionType) === 1 ? 1 : -1; + } + + return Object.freeze({ + interestPnlSign: interestPnlSign, + marginRebatePrincipalSign: marginRebatePrincipalSign, + payDirectionSign: payDirectionSign, + positionTypeSign: positionTypeSign + }); +}()); + +if (typeof module === 'object' && module.exports) module.exports = UnwindLegSign; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 764e9623..40b5da7c 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -69,8 +69,6 @@ const vue = new Vue({ // 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。 // oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。 oriClosePercent: 1, - ratio: 1, - shortRatio: 1, }, computed: { maxUnwindDate() { @@ -125,8 +123,6 @@ const vue = new Vue({ this.marginList = model.FlowEvents.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; }); - this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1; - this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1; this.TradeStartDate = model.TradeStartDate; // 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1 this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue) @@ -424,8 +420,8 @@ const vue = new Vue({ }, calcFloatClosePnl() {//计算浮动端平仓盈亏 var thisObj = this; - let floatRatio = thisObj.floatPosition.PayDirection == 1 ? 1 : -1; - let longRatio = thisObj.floatPosition.PositionType == 1 ? 1 : -1; + let floatRatio = UnwindLegSign.payDirectionSign(thisObj.floatPosition.PayDirection); + let longRatio = UnwindLegSign.positionTypeSign(thisObj.floatPosition.PositionType); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending); let deliveryPrice = thisObj.getStorageDeliveryPrice(); @@ -440,7 +436,7 @@ const vue = new Vue({ this.calcFloatClosePnl(); }, changeInterestAmount(item) {//修改利息金额 - let interestRatio = item.InterestDirection == 1 ? 1 : -1; + let interestRatio = UnwindLegSign.interestPnlSign(item.InterestDirection); item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee) * interestRatio); this.calcCloseAmount(); }, @@ -450,8 +446,8 @@ const vue = new Vue({ // this.calcCloseAmount(); //}, calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付 - let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1; - let ratio = this.floatPosition.PositionType == 1 ? 1 : -1; + let floatRatio = UnwindLegSign.payDirectionSign(this.floatPosition.PayDirection); + let ratio = UnwindLegSign.positionTypeSign(this.floatPosition.PositionType); let thisObj = this; let pnl = parseFloat(this.floatPosition.FloatPnlSum); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); @@ -468,14 +464,14 @@ const vue = new Vue({ thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * ratio; } this.interestList.forEach(x => { - /*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/ let interestAmount = parseFloat(x.InterestClosePnL); thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; }); this.marginList.forEach(x => { let interestAmount = parseFloat(x.InterestClosePnL); - let interestRatio = x.InterestDirection == 1 ? -1 : 1; + // 保证金返还本金符号与利息盈亏同枚举反号(见 unwindLegSign.js 头注——业务事实勿统一) + let interestRatio = UnwindLegSign.marginRebatePrincipalSign(x.InterestDirection); thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;