Merge branch 'glms/feature/1.4.2' of http://git.yiliantech.com/gitlab/otc-dev/zszq-trs into glms/feature/1.4.2

This commit is contained in:
hjhan
2026-08-21 08:07:39 +08:00
11 changed files with 412 additions and 49 deletions
@@ -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<swap_position> { p }, new List<swap_flow_event> { 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_恒等式成立()
{
@@ -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<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null)
Func<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null,
AccrualTrace? trace = null)
{
getSpread ??= _ => Rate;
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);
}
/// <summary>复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。</summary>
@@ -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 _跳过该腿不阻断()
{
@@ -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}");
}
}
/// <summary>
/// 复利承接量:① 实际滚动基数中已并入部分 + ② 最近重置日后实际已计利息(= 实结 − ①)。
///
/// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约):
/// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差;
/// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest)
/// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值;
/// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取
/// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。
/// </summary>
private static (decimal Capitalized, decimal CarryIn) ResolveCompoundCarry(
swap_position position, swap_flow_event normalEvent, eod_swap_position? preEod,
decimal closePrincipal, decimal share, DateTime unwindDate, AccrualTrace? trace)
{
var periodDays = position.interest_rest_days ?? 1;
var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays);
var capitalized = 0m;
if (unwindOnResetDay)
{
capitalized = (preEod?.InterestIncomeSum ?? 0m) * share;
if (preEod == null && (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);
}
}
@@ -27,6 +27,7 @@
<script src="~/Scripts/app/swaptrade/swapPricePrecisionHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapCalc.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindBondCalc.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindLegSign.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindSwapTrade.js?v=@HtmlUtil.JsVersion"></script>
}
<div class="pb-3" id="vueDiv">
@@ -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));
+4
View File
@@ -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',
// 逐步加入更多文件
],
@@ -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');
});
});
+47
View File
@@ -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);
});
});
});
@@ -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));
@@ -0,0 +1,45 @@
/**
* unwindLegSign.js — 平仓页收付方向符号(纯函数)
* ============================================================================
* 把散落在 unwindSwapTrade.js 各计算式里的裸三元(xxx==1 ? ±1 : ∓1)显式化为命名函数。
* 【为什么必须显式化】利息盈亏与保证金返还本金对同一 InterestDirection 枚举符号相反——
* 这是业务事实而非笔误,任何"顺手统一"都会翻转保证金返还方向(资损级 bug):
* 利息盈亏(利息腿/保证金腿通用,changeInterestAmount 对两类行都生效):
* 收益互换中买方为持有标的向交易商融资,利息=融资成本(买方付出去的钱),
* InterestDirection==1(收取) → 盈亏记 +1
* 保证金返还本金:保证金是客户自己交的抵押金,返息/返还本金是把客户自己的钱退回来,
* 本金流方向与利息(成本)相反 → 同枚举符号取镜像 -1。
*
* 其余两个符号:PayDirection==1(浮动端收取) → +1PositionType==1(多头) → +1。
*
* 加载:浏览器 script 标签(先于 unwindSwapTrade.js);Nodemodule.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;
@@ -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;