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:
lisong
2026-08-07 09:29:34 +08:00
42 changed files with 2329 additions and 384 deletions
@@ -0,0 +1,116 @@
using YLErp.Modules.EodModule.SettlementModule;
namespace YLErp.Modules.EodModule
{
/// <summary>
/// 验证 EodCheckSettlePrice 的持仓分支按 ClientIds 收敛:
/// 给定收盘客户时,不应再把“仅属于其他客户”的上一交易日持仓标的纳入结算价缺失检查。
///
/// 采用确定性夹具:插入两条合成持仓(客户A持标的A、客户B持标的B),直接调用抽出的
/// static 查询方法断言过滤语义,finally 中清理,避免依赖测试库现有数据形状。
/// 若 underlying_manager 无足够的对冲类型标的,则 Assert.Inconclusive 跳过。
/// </summary>
[TestClass]
public class EodCheckSettlePriceClientScopeTest : UnitTestBase
{
[TestMethod]
public void PositionUnderlyingQuery_ExcludesOtherClients_WhenClientIdsGiven()
{
using var db = DbContextFactory.GetYLDbContext();
// 选两个存在的、非期货的标的(避开 GetFutureTypes,保证通过方法内部的期货到期过滤);
// 持仓 TradeType 固定为"股票"(属于 TradeTypesForHedge),才能进入结算价检查。
var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes();
var underlyings = db.underlying_manager
.Where(u => u.UnderlyingCode != null && !futureTypes.Contains(u.UnderlyingInstrumentType))
.Take(5)
.ToList();
if (underlyings.Count < 2)
{
Assert.Inconclusive("underlying_manager 无足够的非期货标的,跳过");
return;
}
var uA = underlyings[0];
var uB = underlyings[1];
// 复用一条现有持仓的 BookId/TradeId,确保外键合法(若存在)
var sample = db.eod_trade_position.FirstOrDefault(p => p.BookId != 0);
int bookId = sample?.BookId ?? 1;
int tradeId = sample?.TradeId ?? 0;
// 合成日期与客户,避免与测试库真实数据冲突
var preSettleDate = new DateTime(2026, 5, 1);
var settleDate = new DateTime(2026, 5, 2);
int clientA = 900001;
int clientB = 900002;
var rows = new List<eod_trade_position>
{
new eod_trade_position
{
ValueDate = preSettleDate,
ClientId = clientA,
UnderlyingCode = uA.UnderlyingCode,
UnderlyingId = uA.id,
TradeType = "股票",
BookId = bookId,
TradeId = tradeId,
Amount = 1,
HedgeUniqueCode = "UT_CLIENTSCOPE_A"
},
new eod_trade_position
{
ValueDate = preSettleDate,
ClientId = clientB,
UnderlyingCode = uB.UnderlyingCode,
UnderlyingId = uB.id,
TradeType = "股票",
BookId = bookId,
TradeId = tradeId,
Amount = 1,
HedgeUniqueCode = "UT_CLIENTSCOPE_B"
}
};
foreach (var r in rows)
{
r.OptId = 0;
r.OptName = "UT_CLIENTSCOPE";
r.OptDate = DateTime.Now;
}
try
{
db.eod_trade_position.AddRange(rows);
db.SaveChanges();
var fullSet = EodCheckSettlePrice.QueryPositionUnderlyingCodes(db, preSettleDate, settleDate, null)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var filteredA = EodCheckSettlePrice.QueryPositionUnderlyingCodes(db, preSettleDate, settleDate, new List<int> { clientA })
.ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.IsTrue(fullSet.Contains(uA.UnderlyingCode), "全客户结果应包含客户A的标的");
Assert.IsTrue(fullSet.Contains(uB.UnderlyingCode), "全客户结果应包含客户B的标的");
Assert.IsTrue(filteredA.Contains(uA.UnderlyingCode), "按客户A收敛后仍应包含客户A的标的");
// 关键断言:修复点——按客户A收敛后不应再包含“仅属客户B”的标的
Assert.IsFalse(filteredA.Contains(uB.UnderlyingCode),
"修复验证失败:按客户A收敛后仍包含仅属客户B的持仓标的(ClientId 过滤未生效)");
}
finally
{
// 清理合成数据,使测试库状态不变
foreach (var r in rows)
{
var exist = db.eod_trade_position.FirstOrDefault(x =>
x.ValueDate == preSettleDate && x.ClientId == r.ClientId &&
x.UnderlyingCode == r.UnderlyingCode && x.HedgeUniqueCode == r.HedgeUniqueCode);
if (exist != null)
{
db.eod_trade_position.Remove(exist);
}
}
db.SaveChanges();
}
}
}
}
@@ -713,7 +713,7 @@ namespace YLErp.Modules.SwapModule
"部分平仓计算必须带入自动互换遗留的待实现尾差");
Assert.AreEqual(position.id, service.LastInterestCalculationEodPosition.PositionId,
"部分平仓计息必须按腿标识匹配上一日日终");
AssertDecimal(0.006383561644m, firstCloseResult.InterestIncomeSum,
AssertDecimal(-0.010438356164m, firstCloseResult.InterestIncomeSum,
"部分平仓后待实现应延续历史尾差");
AssertDecimal(0.02m, firstCloseResult.RealizedInterest,
"部分平仓后累计已实现应包含此前自动互换和本次平仓");
@@ -744,7 +744,7 @@ namespace YLErp.Modules.SwapModule
const decimal rate = 0.0299m;
const decimal pendingInterest = 0.820379534246m;
const decimal settledInterest = 0.82m;
const decimal expectedPendingInterest = 0.820569301369m;
const decimal expectedPendingInterest = 0.410474008219m;
var service = new StubEodPositionService();
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
@@ -782,6 +782,31 @@ namespace YLErp.Modules.SwapModule
"The previous EOD identity must not be reset to a new position");
}
[TestMethod]
public void DI_MANUAL_PREPAY_PARTIAL_CLOSE_UsesHistoryPlusRemainingDailyInterest()
{
var service = new StubEodPositionService();
var td = CreateTrade();
var position = CreateInterestPosition();
position.InterestMode = (int)InterestModeEnum.;
position.InterestPrincipalFix = Principal;
var previousEod = CreatePreEod(StartDate.AddDays(2), 100m);
previousEod.InterestMode = position.InterestMode;
previousEod.TdInterestPrincipal = Principal;
var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.;
closeFlow.InterestPrincipal = 500m;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, StartDate.AddDays(3), null,
500m, 0m, new List<swap_flow_event> { closeFlow }, 500m, false);
var expected = previousEod.InterestIncomeSum
+ result.TdInterestIncome - result.TdCloseInterest;
AssertDecimal(expected, result.InterestIncomeSum,
"预付金部分平仓待实现收益应为历史待实现+平仓后当日新增-平仓实现");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_005_AutoSettlementKeepsRemainingPrincipal()
{
@@ -0,0 +1,219 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 【绿灯验收】国联民生-期间结算事件验证-分红前部分平仓过的交易,平仓与分红结束并收盘后,
/// 继续平仓 / 互换时"浮动端平仓盈亏·分红(DividendIn)"应为 0(修复方案C:后端读 EOD 单一可信源)。
/// ============================================================================
/// 参考交易:GLMS-20260105-0004(测试库 id=1921,标的 230004.IB
/// 场景时间线(库内真实数据):
/// 2026-01-05 开仓,期初持仓 50,000,000(支付方向 Direction=2,多头 PositionType=1
/// 2026-02-28 部分平仓 40%20,000,000),剩余 30,000,000;该日早于 3/2 登记日,
/// 已平仓的 40% 不享有 3/2 付息 → 平仓事件 DividendIn=0(正确)
/// 2026-03-02 债券期间付息登记日;当日自动互换(EventType=4),按【剩余持仓 30,000,000】
/// 实现分红 DividendIn=-54,240=30,000,000 × 0.001808);EOD PosiDividendSum=0(全实现)
/// 2026-03-03 收盘后对该交易继续平仓/互换
///
/// 修复前(Bug):前端 getDivindIn 用期初全额持仓(50M)×totalInterest 算出 -90,400
/// 扣除 consumedDividend(-54,240) 得 remainDividend=-36,160 展示 → 错误。
/// -36,160 恰=已平仓40%(20M)×单位付息(0.001808)×支付方向(-1),即把"登记日前已平仓、
/// 不享有该笔分红"的部分重复计入。
///
/// 修复后(方案C):后端 InitUnwind/InitIncome 经 GetPreEodDividendSum 读上一收盘日
/// eod_swap_position.PosiDividendSum。3/2 互换后该值=0 → DividendIn=0(正确)。
/// 前端 getDivindIn 不再自算,直接用后端值。
/// ============================================================================
/// 本测试连真实测试库,调用真实 SwapDealService.GetPreEodDividendSum 验证修复后 DividendIn=0。
/// 连不上库时 Inconclusive 跳过(CI 无 DB 环境不挡)。
/// </summary>
[TestClass]
public class GLMS20260105PartialCloseDividendBugTest
{
private const string TradeNumber = "GLMS-20260105-0004";
// 3/3 继续平仓/互换的业务日期;上一收盘日为 3/2(EOD 已生成,PosiDividendSum=0
private static readonly DateTime DealDate0303 = new(2026, 3, 3);
/// <summary>
/// 连真实库的 SwapDealService 子类,仅用于暴露 protected GetPreEodDividendSum 供单测调用。
/// 不 override 任何 seam → DbContext 走真实 YLContext(与 DbContextFactory.GetYLDbContext() 同库)。
/// </summary>
private sealed class RealDbSwapDealService : SwapDealService
{
public RealDbSwapDealService() : base(OptUserInfo.UnitTestUser) { }
public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
=> GetPreEodDividendSum(tradeId, positionId, dealDate);
}
[TestMethod]
[TestCategory("DBRecording")]
public void Green_3_3_Unwind_DividendIn_FromEOD_Equals_Zero()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; }
try
{
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; }
// 浮动腿持仓:非初始、UnderlyingCode 非空(当前剩余 30,000,000
var positions = db.swap_position.Where(x => x.SwapTradeId == td.id && !x.Invalid).ToList();
var floatLeg = positions.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial);
Assert.IsNotNull(floatLeg, "未找到浮动腿持仓(非初始、UnderlyingCode 非空)");
// ---- 调用真实 GetPreEodDividendSum(方案C 修复核心)----
var service = new RealDbSwapDealService();
decimal dividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, DealDate0303);
// ---- 绿灯断言:3/2 互换后 EOD PosiDividendSum=0 → DividendIn=0 ----
Assert.AreEqual(0m, dividendIn, 0.01m,
$"[绿灯·方案C验收] 3/3平仓/互换 DividendIn 期望=0(3/2互换后剩余持仓30M的分红已全部实现," +
$"EOD PosiDividendSum=0)。实际={dividendIn}。修复前该值为 -36,160(期初持仓×totalInterest 重算误计入已平仓40%)。");
// ---- 健全性:上一 EOD 确为 3/2,且 PosiDividendSum=0 ----
var lastEod = db.eod_swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && x.ValueDate < DealDate0303 && x.PositionId == floatLeg.PositionId)
.OrderByDescending(x => x.ValueDate).FirstOrDefault();
Assert.IsNotNull(lastEod, "应存在 3/2 的 EOD 持仓记录");
Assert.AreEqual(new DateTime(2026, 3, 2), lastEod.ValueDate, "上一收盘日应为 3/2");
Assert.AreEqual(0m, lastEod.PosiDividendSum, 0.01m,
$"3/2 EOD PosiDividendSum 应=0(当日 TdPosiDividend={lastEod.TdPosiDividend} 全额由互换 TdCloseDividend={lastEod.TdCloseDividend} 实现)");
Assert.AreEqual(30_000_000m, lastEod.PosiQuantity, "3/2 剩余持仓应为 30,000,0002/28已平仓40%");
}
finally { db?.Dispose(); }
}
/// <summary>
/// 互换页(InitIncome)与平仓页(InitUnwind)读同一 EOD:同 position、同 dealDate
/// GetPreEodDividendSum 返回值必然一致。本测试明确覆盖"点击收益互换同理"路径
/// (用户反馈:平仓页 -36,160,点击收益互换同样错误)。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Green_3_3_Income_DividendIn_FromEOD_Equals_Zero()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; }
try
{
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; }
var floatLeg = db.swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial && x.UnderlyingCode != null && x.UnderlyingCode != "")
.FirstOrDefault();
Assert.IsNotNull(floatLeg, "未找到浮动腿持仓");
// InitIncome 与 InitUnwind 调用 GetPreEodDividendSum 的入参一致 → 结果一致
var service = new RealDbSwapDealService();
decimal incomeDividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, DealDate0303);
Assert.AreEqual(0m, incomeDividendIn, 0.01m,
$"[互换页·方案C验收] 3/3收益互换 DividendIn 期望=0,实际={incomeDividendIn}。" +
$"InitIncome 与 InitUnwind 共用 GetPreEodDividendSum,应返回同一 EOD 值。");
}
finally { db?.Dispose(); }
}
/// <summary>
/// 防御性:dealDate 早于任何 EOD(如交易首日尚未收盘)时,GetPreEodDividendSum 应返回 0
/// 不抛异常。覆盖"无 EOD 记录"边界——fallback 到 dealDate.AddDays(-1) 后查无数据 → 0。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Green_NoEodBeforeDealDate_Returns_Zero_WithoutThrowing()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; }
try
{
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; }
var floatLeg = db.swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial && x.UnderlyingCode != null && x.UnderlyingCode != "")
.FirstOrDefault();
Assert.IsNotNull(floatLeg, "未找到浮动腿持仓");
var service = new RealDbSwapDealService();
// dealDate = 交易开始日前一天,确保无任何 EOD 满足 ValueDate < dealDate
var preStartDate = td.StartDate.Value.AddDays(-1);
decimal dividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, preStartDate);
Assert.AreEqual(0m, dividendIn, 0.01m,
$"[防御] dealDate({preStartDate:yyyy-MM-dd})早于所有 EOD 时,GetPreEodDividendSum 应返回 0,实际={dividendIn}。" +
$"与历史 InitUnwind/InitIncome 中 DividendIn=0 行为一致,不抛异常。");
}
finally { db?.Dispose(); }
}
// ============================================================================
// 非0场景验收(关键防回归)
// ----------------------------------------------------------------------------
// GLMS-20260105-0004 的 PosiDividendSum 恰好=03/2 已全额互换),无法暴露
// "DividendPending 硬编码 0" 的回归。本测试用一笔 PosiDividendSum≠0 的活交易
// GLMS-20260706-0004(标的 230004.IB,持仓 1亿,待实现分红 -150,000,多日稳定)
// 验证:GetPreEodDividendSum 返回非0的 -150,000 → 修复后 DividendIn/DividendPending
// 均应为此值(而非历史硬0回归)。
// 若本测试因硬0回归而失败(实际=0),即说明有人把 DividendPending 改回了硬0
// 或 GetPreEodDividendSum 传导链路被破坏。
// ============================================================================
/// <summary>
/// 非0场景:PosiDividendSum≠0 时,GetPreEodDividendSum 返回真实非0值,
/// 该值应同时成为 DividendIn 与 DividendPending(全量口径)。
/// 这是针对"硬0被0掩盖"盲区的核心防回归测试。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Green_NonZeroPosiDividendSum_Returns_RealValue_NotHardZero()
{
const string nonZeroTrade = "GLMS-20260706-0004"; // PosiDividendSum=-150,000,确认成交活交易
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; }
try
{
var td = db.trade.FirstOrDefault(t => t.TradeNumber == nonZeroTrade);
if (td == null) { Assert.Inconclusive($"测试库不存在交易 {nonZeroTrade}"); return; }
var floatLeg = db.swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial && x.UnderlyingCode != null && x.UnderlyingCode != "")
.FirstOrDefault();
Assert.IsNotNull(floatLeg, $"未找到 {nonZeroTrade} 的浮动腿持仓");
// 取该交易最近一次 EOD 的 PosiDividendSum 作为期望值(多日稳定 -150,000)
var lastEod = db.eod_swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && x.PositionId == floatLeg.PositionId && x.PosiQuantity > 0)
.OrderByDescending(x => x.ValueDate).FirstOrDefault();
Assert.IsNotNull(lastEod, $"未找到 {nonZeroTrade} 的有效 EOD");
Assert.AreNotEqual(0m, lastEod.PosiDividendSum, 0.01m,
$"前置:{nonZeroTrade} 的 PosiDividendSum 应≠0(本测试专为非0场景设计),实际={lastEod.PosiDividendSum}。" +
$"若该交易已互换/平仓致归0,请换另一笔 PosiDividendSum≠0 的活交易。");
// dealDate 取 lastEod 次日,确保 GetPreEodDividendSum 读到这笔非0 EOD
var dealDate = lastEod.ValueDate.AddDays(1);
var service = new RealDbSwapDealService();
decimal dividend = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, dealDate);
// ---- 核心:返回值=真实非0的 PosiDividendSum,不是硬0 ----
Assert.AreEqual(lastEod.PosiDividendSum, dividend, 0.01m,
$"[非0场景·方案C验收] {nonZeroTrade} GetPreEodDividendSum 应返回 EOD PosiDividendSum={lastEod.PosiDividendSum}" +
$"实际={dividend}。该值将同时成为 DividendPending(待结算分红收益,全量口径)。" +
$"若实际=0,说明 DividendPending 硬0回归未修复,或传导链路被破坏(参见 SwapDealService.GetPreEodDividendSum 注释的口径论证)。");
Console.WriteLine($"[非0场景验证通过] {nonZeroTrade}: PosiDividendSum={dividend}(非0)→ DividendIn/DividendPending 均为此值,非硬0。");
}
finally { db?.Dispose(); }
}
}
}
@@ -111,27 +111,25 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
public void PartialCloseTradingFeeAndPendingFeeUseTheSameRoundedOriginalFeeAllocation()
public void ManuallyAdjustedPendingFeeDoesNotOverrideBaseRateCloseFee()
{
var oriPosition = new swap_position
{
PosiFeeType = 0,
PosiTradingFeeUnit = 1.1234m,
PosiTradingFeePending = 113.46m
PosiFeeType = 1,
PosiTradingFeeUnit = 0.123456m,
PosiTradingFeePending = 1235.56m
};
var unwindData = new UnwindData
{
NotionalValue = 10098m,
CloseNotionalValue = 4039.2m,
NotionalQty = 10000m,
CloseQty = 4000m
CloseQty = 10000m
};
var tradingFee = InvokeCalcInitTradingFee(oriPosition, unwindData);
var pendingFee = InvokeCalcInitTradingFeePending(oriPosition, new swap_position(), unwindData);
Assert.AreEqual(45.38m, tradingFee);
Assert.AreEqual(45.38m, pendingFee);
Assert.AreEqual(1234.56m, tradingFee);
Assert.AreEqual(1235.56m, pendingFee);
}
[TestMethod]
@@ -63,5 +63,35 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(ClientCashInCashOut._预付金返息, service.ClientCashCalls[1].action);
Console.WriteLine($"SI_002: 互换={service.ClientCashCalls[0].amount}, 预付金返息={service.ClientCashCalls[1].amount} ✅");
}
[TestMethod]
public void SI_003_SwapIncome_含预付金腿_不返还预付金本金()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m);
unwindData.FlowEvents.Add(new swap_flow_event
{
UnderlyingCode = "UT-FLOAT",
MarkClosePnl = 100m,
PayDirection = 1
});
unwindData.FlowEvents.Add(new swap_flow_event
{
InterestMode = (int)InterestModeEnum.,
InterestDirection = 1,
InterestPrincipal = 10000m,
InterestClosePnL = 2m
});
service.SwapIncome(unwindData);
Assert.AreEqual(0m, unwindData.SwapMarginAmount, "手动互换不应返还预付金本金");
Assert.IsFalse(service.ClientCashCalls.Any(x => x.action == ClientCashInCashOut._应付预付金),
"手动互换不应生成应付预付金流水");
Assert.IsTrue(service.ClientCashCalls.Any(x => x.action == ClientCashInCashOut._预付金返息),
"手动互换仍应结算预付金返息");
}
}
}
@@ -36,6 +36,7 @@ namespace YLErp.Modules.SwapModule
// 输出别名(转发到基类捕获属性)
public List<eod_swap_position> CreatedEodPositions => PersistedPositions;
public List<swap_position> LastInterestCalculationPositions { get; private set; }
public TestableSwapEodService(
List<trade> trades, List<swap_position> positions,
@@ -55,6 +56,7 @@ namespace YLErp.Modules.SwapModule
protected override List<trade_extend> FindTradeExtends(List<int> tradeIds) => _extends;
protected override List<eod_swap> FindEodSwapsByDate(DateTime valueDate) => _eodSwaps;
protected override List<swap_flow_event> FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents;
protected override List<swap_flow_event> FindCompletedFlowEvents(List<int> tradeIds) => _flowEvents;
protected override List<eod_swap_position> FindEodSwapPositions(int swapTradeId, DateTime preSettleDate)
=> _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList();
protected override List<swap_position> FindSwapPositions(int swapTradeId)
@@ -80,7 +82,17 @@ namespace YLErp.Modules.SwapModule
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null) => new List<swap_flow_event>();
List<swap_flow_event> closeList = null)
{
LastInterestCalculationPositions = positions;
return positions.Select(position => new swap_flow_event
{
PositionId = position.id,
InterestPrincipal = 1000m,
InterestRate = 0.01m,
FloatRate = 0.01m
}).ToList();
}
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
=> SwapPositionCompose(settleDate, preSettleDate, null);
@@ -250,5 +262,173 @@ namespace YLErp.Modules.SwapModule
Assert.IsTrue(ex.Message.Contains("未收盘"), $"异常消息应含'未收盘',实际:{ex.Message}");
Console.WriteLine($"SPC_004: 抛异常'{ex.Message}' ✅");
}
[TestMethod]
public void SPC_005_部分平仓后_预付金日终按实时剩余本金计息()
{
const long initialPrepayId = 2;
var td = CreateTrade();
var initialPrepay = new swap_position
{
id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false,
PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value,
InterestSwapInterval = "[]"
};
var realPrepay = new swap_position
{
id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 700m, IsInitial = false, Invalid = false
};
var prepayEod = new eod_swap_position
{
id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId,
ValueDate = PreSettleDate, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 700m, TdInterestPrincipal = 700m
};
var service = new TestableSwapEodService(
new List<trade> { td },
new List<swap_position> { CreateFloatPosition(1, 1000), initialPrepay, realPrepay },
new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod },
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } },
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event>
{
CreateCloseFlowEvent(1, 300),
new swap_flow_event
{
SwapTradeId = SwapTradeId, PositionId = initialPrepayId,
EventType = (int)SwapEventTypeEnum.,
EventDate = SettleDate,
DataState = (int)SwapFlowDateStateEnum.
}
});
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
var calculatedPrepay = service.LastInterestCalculationPositions
.Single(x => x.id == initialPrepayId);
Assert.AreEqual(700m, calculatedPrepay.InterestPrincipalFix);
Assert.AreEqual(initialPrepayId, calculatedPrepay.id);
}
[TestMethod]
public void SPC_006_平仓日_预付金日终不得重复扣减实时剩余本金()
{
const long initialPrepayId = 2;
var td = CreateTrade();
var initialPrepay = new swap_position
{
id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false,
IsAnnualized = true,
PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value,
InterestSwapInterval = "[]"
};
var realPrepay = new swap_position
{
id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 700m, IsInitial = false, Invalid = false
};
var prepayEod = new eod_swap_position
{
id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId,
ValueDate = PreSettleDate, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 1000m, TdInterestPrincipal = 1000m
};
var closeFlow = CreateCloseFlowEvent(1, 300);
closeFlow.InterestRate = 0.01m;
var prepayCloseFlow = new swap_flow_event
{
SwapTradeId = SwapTradeId, PositionId = initialPrepayId,
EventType = (int)SwapEventTypeEnum.,
EventDate = SettleDate, DataState = (int)SwapFlowDateStateEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipal = 300m
};
var service = new TestableSwapEodService(
new List<trade> { td },
new List<swap_position> { CreateFloatPosition(1, 1000), initialPrepay, realPrepay },
new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod },
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } },
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event> { closeFlow, prepayCloseFlow });
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
var persistedPrepay = service.CreatedEodPositions
.Single(x => x.PositionId == initialPrepayId);
Assert.AreEqual(700m, persistedPrepay.InterestPrincipalFix,
"实时腿已经扣减到700,日终不得再次按平仓比例扣减");
Assert.AreEqual(700m, persistedPrepay.TdInterestPrincipal,
"平仓日预付金计息本金应立即切换为实时剩余本金");
Assert.AreEqual(700m * 0.01m / 365m, persistedPrepay.TdInterestIncome,
"平仓日新增利息应按实时剩余本金计算");
}
[TestMethod]
public void SPC_007_HistoricalReplayUsesAsOfPrincipal()
{
const long originalPositionId = 2;
var original = new swap_position
{
id = originalPositionId, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 10000m
};
var realtime = new swap_position
{
PositionId = originalPositionId,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 7000m
};
var close = new swap_flow_event
{
PositionId = originalPositionId,
PositionType = 0,
EventType = (int)SwapEventTypeEnum.,
EventDate = new DateTime(2026, 7, 9),
InterestMode = (int)InterestModeEnum.,
InterestPrincipal = 3000m
};
var floatClose = new swap_flow_event
{
PositionId = 1,
PositionType = 1,
EventType = (int)SwapEventTypeEnum.,
EventDate = new DateTime(2026, 7, 9),
TradingAmount = 3000000m
};
var originalWithFloat = new List<swap_position>
{
original,
new swap_position { id = 1, PosiDirection = 1, PosiNotionalValue = 10000000m }
};
var beforeClose = SwapDealService.ResolveInterestLegPositionsAsOf(
originalWithFloat, new List<swap_position> { realtime },
new[] { close, floatClose }, new DateTime(2026, 7, 8))
.Single(x => x.id == originalPositionId);
var onCloseDate = SwapDealService.ResolveInterestLegPositionsAsOf(
originalWithFloat, new List<swap_position> { realtime },
new[] { close, floatClose }, new DateTime(2026, 7, 9))
.Single(x => x.id == originalPositionId);
Assert.AreEqual(10000m, beforeClose.InterestPrincipalFix);
Assert.AreEqual(7000m, onCloseDate.InterestPrincipalFix);
}
}
}
@@ -166,6 +166,35 @@ namespace YLErp.Modules.SwapModule
Console.WriteLine($"UW_005: 反序列化SwapRealizedPnL=8000, 资金流水={service.ClientCashCalls[0].amount}, TradeStatus={td.TradeStatus} ✅");
}
[TestMethod]
public void UW_005A_ApproveSwapTrade_互换审核_不返还预付金本金()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 100m, swapMarginAmount: -10000m);
var swapEvent = new swap_event
{
id = 2,
SwapTradeId = SwapDealTestFactory.SwapTradeId,
EventType = (int)SwapEventTypeEnum.,
Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var service = new TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event>
{
[(int)SwapEventTypeEnum.互换] = swapEvent
});
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.);
Assert.AreEqual(1, service.ClientCashCalls.Count, "互换审批只应生成互换结算流水");
Assert.AreEqual(ClientCashInCashOut._互换, service.ClientCashCalls[0].action);
Assert.IsFalse(service.ClientCashCalls.Any(x => x.action == ClientCashInCashOut._应付预付金),
"互换审批不应生成应付预付金流水");
}
// ================================================================
// 场景6ApplySwapTrade 提交审核 —— 前置校验与保存事件
// ================================================================
@@ -197,6 +226,8 @@ namespace YLErp.Modules.SwapModule
public void UW_007_SwapUnwind_占期初A转占剩余B_全平判定正确()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 600000;
td.TradeAmount = 600000;
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum., closePercent: 0.6m,
@@ -259,6 +290,33 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(500000.01, td.StockEqvNotional, 0.000001, "trade 剩余名义本金应在扣减后舍入两位小数");
}
[TestMethod]
public void UW_013_SwapUnwind_合法零点零一剩余不应判定全平()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 1000000.01;
td.TradeAmount = 10000.01;
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m,
closeMethod: (int)CloseMethodEnum.,
closePercent: 1000000m / 1000000.01m,
closeQty: 10000m,
closeNotionalValue: 1000000m,
positionQty: 10000.01m);
unwindData.NotionalValue = 1000000.01m;
unwindData.PosiNotionalValue = 1000000.01m;
service.SwapUnwind(unwindData);
Assert.AreEqual("确认成交", td.TradeStatus,
"剩余名义本金和数量均为0.01时仍应保持部分平仓状态");
Assert.AreEqual(1, td.HasPartialUnWind,
"合法的0.01尾差不应被清零");
Assert.AreEqual(0.01, td.StockEqvNotional, 0.000001);
Assert.AreEqual(0.01, td.TradeAmount, 0.000001);
}
[TestMethod]
public void UW_010_SwapUnwind_现金与两位利息事件保持一致()
{
@@ -289,5 +347,183 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(10m, unwindData.SwapRealizedPnL);
Assert.AreEqual(-10d, service.ClientCashCalls[0].amount, 0.001d);
}
[TestMethod]
public void UW_011_trade2308_full_close_uses_remaining_values_and_zeroes_tail()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 4906156.15;
td.TradeAmount = 5000000;
td.Notional = 5000000;
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m,
closeMethod: (int)CloseMethodEnum.,
closePercent: 0.5m,
closeQty: 5000000.01m,
closeNotionalValue: 4906156.15m,
positionQty: 5000000m);
unwindData.NotionalValue = 9812312.31m;
unwindData.PosiNotionalValue = 4906156.15m;
service.SwapUnwind(unwindData);
var saved = service.SaveSwapDealCalls[0].data;
Assert.AreEqual((int)CloseMethodEnum., saved.CloseMethod,
"CloseMethod 保留本次部分平仓意图,终态由扣减后的持仓事实决定");
Assert.AreEqual(5000000m, saved.CloseQty);
Assert.AreEqual(4906156.15m, saved.CloseNotionalValue);
Assert.AreEqual(0d, td.StockEqvNotional, 0.000001);
Assert.AreEqual(0d, td.TradeAmount, 0.000001);
Assert.AreEqual(0d, td.Notional, 0.000001);
Assert.AreEqual("已平仓", td.TradeStatus);
}
[TestMethod]
public void UW_014_full_close_quantity_normalization_recalculates_pnl_and_cash()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 4906156.15;
td.TradeAmount = 5000000;
td.Notional = 5000000;
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 50000000.10m,
closeMethod: (int)CloseMethodEnum.,
closePercent: 0.5m,
closeQty: 5000000.01m,
closeNotionalValue: 4906156.15m,
positionQty: 5000000m);
unwindData.NotionalValue = 9812312.31m;
unwindData.PosiNotionalValue = 4906156.15m;
unwindData.SwapCloseAmount = 50000000.10m;
var floatEvent = new swap_flow_event
{
UnderlyingCode = "UT-FLOAT",
PositionType = (int)PositionTypeFlag.Long,
EventType = (int)SwapEventTypeEnum.,
PayDirection = 1,
PosiGrossPrice = 1m,
TradingAmountAvg = 11m,
MarkClosePnl = 50000000.10m
};
unwindData.FlowEvents.Add(floatEvent);
service.SwapUnwind(unwindData);
Assert.AreEqual(5000000m, unwindData.CloseQty);
Assert.AreEqual(50000000m, floatEvent.MarkClosePnl);
Assert.AreEqual(50000000m, unwindData.SwapRealizedPnL);
Assert.AreEqual(50000000m, unwindData.SwapCloseAmount);
Assert.AreEqual(-50000000d, service.ClientCashCalls.Single().amount, 0.001d);
}
[TestMethod]
public void UW_012_approve_restores_A_to_B_and_normalizes_flow_for_full_close()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 4906156.15;
td.TradeAmount = 5000000;
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m,
closeMethod: (int)CloseMethodEnum.,
closePercent: 4906156.15m / 9812312.31m,
closeQty: 5000000.01m,
closeNotionalValue: 4906156.15m,
positionQty: 5000000m);
unwindData.NotionalValue = 9812312.31m;
unwindData.PosiNotionalValue = 4906156.15m;
var floatEvent = new swap_flow_event
{
EventId = 1,
UnderlyingCode = "261031.IB",
PositionType = (int)PositionTypeFlag.Long,
Quantity = 5000000.01m,
PositionQty = -0.01m
};
var swapEvent = new swap_event
{
id = 1,
SwapTradeId = SwapDealTestFactory.SwapTradeId,
EventType = (int)SwapEventTypeEnum.,
Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var service = new TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event>
{
[(int)SwapEventTypeEnum.平仓] = swapEvent
},
flowEventsByEventId: new Dictionary<long, List<swap_flow_event>>
{
[1] = new List<swap_flow_event> { floatEvent }
});
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.);
Assert.AreEqual(1m, swapEvent.unwindData.ClosePercent);
Assert.AreEqual((int)CloseMethodEnum., swapEvent.unwindData.CloseMethod,
"审批不应把部分平仓事件改写为全平意图");
Assert.AreEqual(5000000m, swapEvent.unwindData.CloseQty);
Assert.AreEqual(4906156.15m, swapEvent.unwindData.CloseNotionalValue);
Assert.AreEqual(5000000m, floatEvent.Quantity);
Assert.AreEqual(0m, floatEvent.PositionQty);
Assert.AreEqual("已平仓", td.TradeStatus);
Assert.AreEqual(0d, td.StockEqvNotional, 0.000001);
Assert.AreEqual(0d, td.TradeAmount, 0.000001);
}
[TestMethod]
public void UW_015_approve_full_close_recalculates_normalized_pnl_before_cash()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 4906156.15;
td.TradeAmount = 5000000;
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 50000000.10m,
closeMethod: (int)CloseMethodEnum.,
closePercent: 4906156.15m / 9812312.31m,
closeQty: 5000000.01m,
closeNotionalValue: 4906156.15m,
positionQty: 5000000m);
unwindData.NotionalValue = 9812312.31m;
unwindData.PosiNotionalValue = 4906156.15m;
unwindData.SwapCloseAmount = 50000000.10m;
var floatEvent = new swap_flow_event
{
EventId = 1,
UnderlyingCode = "261031.IB",
PositionType = (int)PositionTypeFlag.Long,
PayDirection = 1,
PosiGrossPrice = 1m,
TradingAmountAvg = 11m,
MarkClosePnl = 50000000.10m,
Quantity = 5000000.01m,
PositionQty = -0.01m
};
var swapEvent = new swap_event
{
id = 1,
SwapTradeId = SwapDealTestFactory.SwapTradeId,
EventType = (int)SwapEventTypeEnum.,
Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var service = new TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event>
{
[(int)SwapEventTypeEnum.平仓] = swapEvent
},
flowEventsByEventId: new Dictionary<long, List<swap_flow_event>>
{
[1] = new List<swap_flow_event> { floatEvent }
});
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.);
Assert.AreEqual(5000000m, swapEvent.unwindData.CloseQty);
Assert.AreEqual(50000000m, swapEvent.unwindData.SwapRealizedPnL);
Assert.AreEqual(-50000000d, service.ClientCashCalls.Single().amount, 0.001d);
}
}
}
+4
View File
@@ -57,6 +57,10 @@ namespace YLErp.Model
public string AccountCapitalTopicGroupId { get; set; }
public int AutoOffsetReset { get; set; }
/// <summary>
/// TRS合约数据推送topic(对外,如onebp等)
/// </summary>
public string ContractTopic { get; set; } = "onederi.trs.onebp.contract.v1";
}
}
@@ -188,7 +188,7 @@ namespace YLErp.Modules.EodModule
private Dictionary<string,long> GetTFeatureBondInnerCode(List<string> underlyingCodes,DateTime valueDate,DbConnection conn)
{
var sql = "SELECT contractcode,deliverableinnercode,spread FROM fut_cgbderiv WHERE TradingDay = @ValueDate AND pricetype = 3 AND (contractcode,irr) IN (SELECT contractcode,MAX(irr) FROM fut_cgbderiv WHERE TradingDay = @ValueDate AND contractcode IN (@UmCodes) AND pricetype = 3 GROUP BY contractcode);";
var sql = "SELECT contractcode,deliverableinnercode,spread FROM fut_cgbderiv WHERE tradingday = @ValueDate AND pricetype = 3 AND (contractcode,irr) IN (SELECT contractcode,MAX(irr) FROM fut_cgbderiv WHERE tradingday = @ValueDate AND contractcode IN (@UmCodes) AND pricetype = 3 GROUP BY contractcode);";
var datas = conn.Query<TFeatureBondInnerCodeQueryDto>(sql, new
{
ValueDate = valueDate,
@@ -1,5 +1,5 @@
using System.Linq;
using System.Linq.Dynamic.Core;
using YLErp.BLL;
using YLErp.Configuration.Enums;
using YLErp.Modules.DataProviderModule;
namespace YLErp.Modules.EodModule.SettlementModule
@@ -7,7 +7,7 @@ namespace YLErp.Modules.EodModule.SettlementModule
/// <summary>
/// 检查当日结算价,是否全部进系统。
/// </summary>
class EodCheckSettlePrice : EodSettleServiceBaseV2
public class EodCheckSettlePrice : EodSettleServiceBaseV2
{
public const string Step = "检查标的结算价格缺失";
@@ -58,7 +58,7 @@ namespace YLErp.Modules.EodModule.SettlementModule
allQuery = DbContext.trade.Where(tradePredicate).Select(n => n.UnderlyingCode).Distinct();
if (PS.Config.ErpElement.ForwardTradePriceModel == Configuration.Enums.ForwardTradePriceModel.STANDARD)
if (PS.Config.ErpElement.ForwardTradePriceModel == ForwardTradePriceModel.STANDARD)
{
allQuery = allQuery.Union(DbContext.trade.Where(tradePredicate).Where(x => x.BasisUnderlyingCode != null && x.BasisUnderlyingCode != "").Select(n => n.BasisUnderlyingCode).Distinct());
}
@@ -88,14 +88,8 @@ namespace YLErp.Modules.EodModule.SettlementModule
allQuery = allQuery == null ? exTradeQuery.Distinct() : allQuery.Union(exTradeQuery.Distinct());
var preSettleDate = _context.PreSettleDate;
//最后一个交易日持仓信息
var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes();
var positionQuery = from t in DbContext.eod_trade_position
join um in DbContext.underlying_manager on t.UnderlyingCode equals um.UnderlyingCode
where t.ValueDate == preSettleDate && t.Amount != 0
&& ConsTrade.TradeTypesForHedge.Contains(t.TradeType)
&& (!futureTypes.Contains(um.UnderlyingInstrumentType) || um.MaturityDate >= settleDate)
select t.UnderlyingCode;
// 持仓分支传入 clienIds:使上一交易日持仓标的按当前收盘客户作用域收敛(防御点详见 QueryPositionUnderlyingCodes)。
var positionQuery = QueryPositionUnderlyingCodes(DbContext, preSettleDate, settleDate, clienIds);
allQuery = allQuery.Union(positionQuery.Distinct());
}
@@ -130,6 +124,41 @@ namespace YLErp.Modules.EodModule.SettlementModule
_context.RaiseError(Step, "标的代码:" + codes);
}
}
/// <summary>
/// 取上一交易日持仓中需结算价的标的(持仓分支)。
/// 抽成 static 以便单测直接覆盖 ClientId 收敛语义。
///
/// ── 防御行为(EQD-6967)─────────────────────────────────────────────
/// 收盘缺失价检查在“按客户作用域结算(ClientIds 非 null)”时,本应只校验
/// 当前收盘客户自己的标的,而不应把“其他客户”或“系统级(ClientId=0)”的
/// 上一交易日持仓标的误报为缺失价。历史上持仓分支完全未引用 ClientId,
/// 导致客户 A 收盘时被其他客户/系统级持仓的标的噪声干扰(见缺陷现象)。
///
/// 本方法通过 clienIds 过滤做收敛,与 OTC 分支、客户产品分支已有的
/// ClientId 过滤语义保持一致:
/// · clienIds == null → 系统级全量结算,短路为 true(不收窄,零回归);
/// · clienIds != null → 仅返回属于指定客户的持仓标的(Contains(t.ClientId)),
/// 过滤掉其他客户及系统级(ClientId=0)持仓噪声。
/// 注意:场外交易结算模式(IsSettleExchangeTrades=false)下持仓分支整块跳过,
/// 此时本方法不会被调用,属操作层面的规避而非修复。
/// ───────────────────────────────────────────────────────────────────
/// </summary>
public static IQueryable<string> QueryPositionUnderlyingCodes(YLContext db, DateTime preSettleDate, DateTime settleDate, IEnumerable<int> clienIds)
{
var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes();
return from t in db.eod_trade_position
join um in db.underlying_manager on t.UnderlyingCode equals um.UnderlyingCode
where t.ValueDate == preSettleDate && t.Amount != 0
&& ConsTrade.TradeTypesForHedge.Contains(t.TradeType)
&& (!futureTypes.Contains(um.UnderlyingInstrumentType) || um.MaturityDate >= settleDate)
// 【防御点·EQD-6967】按客户作用域收敛持仓标的:
// clienIds==null → 系统级全量结算,不收窄(零回归);
// clienIds!=null → 仅保留指定客户持仓,过滤掉其他客户/系统级(ClientId=0)持仓噪声。
&& (clienIds == null || clienIds.Contains(t.ClientId))
select t.UnderlyingCode;
}
/// <summary>
/// 获取互换标的
/// </summary>
+270 -29
View File
@@ -47,6 +47,65 @@ namespace YLErp.Modules.SwapModule
unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
private static bool NormalizeFullCloseRequest(UnwindData unwindData)
{
if (unwindData.CloseMethod != (int)CloseMethodEnum.
&& unwindData.ClosePercent < 1
&& !(unwindData.PositionQty > 0 && unwindData.CloseQty >= unwindData.PositionQty)
&& !(unwindData.PosiNotionalValue > 0 && unwindData.CloseNotionalValue >= unwindData.PosiNotionalValue))
{
return false;
}
var closeQty = unwindData.CloseQty;
var closeNotionalValue = unwindData.CloseNotionalValue;
unwindData.ClosePercent = 1;
if (unwindData.PositionQty > 0) unwindData.CloseQty = unwindData.PositionQty;
if (unwindData.PosiNotionalValue > 0) unwindData.CloseNotionalValue = unwindData.PosiNotionalValue;
return closeQty != unwindData.CloseQty || closeNotionalValue != unwindData.CloseNotionalValue;
}
private static void RecalculateNormalizedUnwindAmounts(UnwindData unwindData)
{
var floatLeg = unwindData.FlowEvents.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode));
if (floatLeg == null || floatLeg.PosiGrossPrice == 0) return;
var input = new UnwindInput
{
Multiplier = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType) ? 100 : 1,
PosiGrossPrice = floatLeg.PosiGrossPrice,
TradingAmountAvg = floatLeg.TradingAmountAvg,
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 result = FrontendCalcReference.CalcUnwind(input);
floatLeg.MarkClosePnl = result.MarkClosePnl;
unwindData.SwapCloseAmount = result.SwapCloseAmount;
unwindData.SwapRealizedPnL = result.SwapRealizedPnL;
unwindData.SwapMarginRebatePnl = result.SwapMarginRebatePnl;
}
private static bool IsFullCloseAfterDeduction(UnwindData unwindData, double remainingNotional, double remainingQuantity)
{
return unwindData.ClosePercent == 1 || (remainingNotional == 0 && remainingQuantity == 0);
}
// 待实现利息会进入 decimal(30,12) 日终快照
private const int InterestCalculationPrecision = 12;
@@ -85,6 +144,10 @@ namespace YLErp.Modules.SwapModule
if (unwindData.FlowEvents.Any(x => !string.IsNullOrEmpty(x.UnderlyingCode)))
{
CalcCloseAmount(unwindData);
if (eventType == (int)SwapEventTypeEnum.)
{
unwindData.SwapMarginAmount = 0;
}
return;
}
@@ -287,7 +350,15 @@ namespace YLErp.Modules.SwapModule
floatEvent.PositionId = position.PositionId;
floatEvent.EventType = (int)SwapEventTypeEnum.;
floatEvent.EventReason = "交易";
floatEvent.DividendIn = 0;
// 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
// 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
// 同一 EOD 值取一次喂两栏:
// DividendIn = "浮动端平仓盈亏·分红"(本次动作要落袋的,落库后被前端按需展示)
// DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径,
// 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归)
decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
floatEvent.DividendIn = preEodDividendSum;
floatEvent.DividendPending = preEodDividendSum;
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
@@ -324,19 +395,12 @@ namespace YLErp.Modules.SwapModule
return 0;
}
if (oriPosition.PosiTradingFeeUnit == 0)
if (oriPosition.PosiFeeType == 1)
{
return 0;
return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
var closeBase = oriPosition.PosiFeeType == 1 ? unwindData.CloseQty : unwindData.CloseNotionalValue;
var originalBase = oriPosition.PosiFeeType == 1 ? unwindData.NotionalQty : unwindData.NotionalValue;
if (originalBase <= 0)
{
return 0;
}
return Math.Round(oriPosition.PosiTradingFeePending * closeBase / originalBase, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
private static decimal CalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData)
@@ -346,7 +410,14 @@ namespace YLErp.Modules.SwapModule
return position?.PosiTradingFeePending ?? 0;
}
return CalcInitTradingFee(oriPosition, unwindData);
var closeBase = oriPosition.PosiFeeType == 1 ? unwindData.CloseQty : unwindData.CloseNotionalValue;
var originalBase = oriPosition.PosiFeeType == 1 ? unwindData.NotionalQty : unwindData.NotionalValue;
if (originalBase <= 0)
{
return position?.PosiTradingFeePending ?? 0;
}
return Math.Round(oriPosition.PosiTradingFeePending * closeBase / originalBase, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 校验上日是否收盘
@@ -498,7 +569,9 @@ namespace YLErp.Modules.SwapModule
if (position != null)
{
floatEvent.PositionId = position.PositionId;
floatEvent.DividendIn = 0;
// 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
// 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
floatEvent.DividendIn = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
@@ -606,6 +679,65 @@ namespace YLErp.Modules.SwapModule
return p;
}).ToList();
}
/// <summary>
/// 计算预付金腿当前真实持仓 (当前持仓+未来持仓)
/// </summary>
/// <param name="origPositions"></param>
/// <param name="realPositions"></param>
/// <param name="completedFlowEvents"></param>
/// <param name="settleDate"></param>
/// <returns></returns>
public static List<swap_position> ResolveInterestLegPositionsAsOf(
List<swap_position> origPositions, List<swap_position> realPositions,
IEnumerable<swap_flow_event> completedFlowEvents, DateTime settleDate)
{
realPositions ??= new List<swap_position>();
var futureFlows = (completedFlowEvents ?? Enumerable.Empty<swap_flow_event>())
.Where(x => x.EventType == (int)SwapEventTypeEnum. && x.EventDate > settleDate)
.ToList();
var originalNotional = origPositions.Where(x => x.PosiDirection > 0)
.Sum(x => x.PosiNotionalValue);
var futureCloseNotional = futureFlows.Where(x => x.PositionType > 0)
.Sum(x => x.TradingAmount);
var hasNotionalFlows = futureCloseNotional > 0 && originalNotional > 0;
var futureClosePrincipal = futureFlows
.Where(x => x.InterestMode == (int)InterestModeEnum.
|| x.InterestMode == (int)InterestModeEnum.)
.GroupBy(x => x.PositionId)
.ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal));
var priorClosePositionIds = new HashSet<long>((completedFlowEvents ?? Enumerable.Empty<swap_flow_event>())
.Where(x => x.EventType == (int)SwapEventTypeEnum. && x.EventDate <= settleDate)
.Select(x => x.PositionId));
return origPositions.Where(x => x.PosiDirection == 0).Select(p =>
{
if (p.InterestMode == (int)InterestModeEnum.
|| p.InterestMode == (int)InterestModeEnum.)
{
var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id);
if (realLeg != null)
{
if (!priorClosePositionIds.Contains(p.id))
{
return p;
}
var futurePrincipal = hasNotionalFlows
? p.InterestPrincipalFix * futureCloseNotional / originalNotional
: futureClosePrincipal.TryGetValue(p.id, out var flowPrincipal) ? flowPrincipal : 0m;
var asOfPrincipal = realLeg.InterestPrincipalFix + futurePrincipal;
asOfPrincipal = Math.Min(p.InterestPrincipalFix, Math.Max(0m, asOfPrincipal));
if (asOfPrincipal != p.InterestPrincipalFix)
{
var clone = p.Clone();
clone.InterestPrincipalFix = asOfPrincipal;
return clone;
}
}
}
return p;
}).ToList();
}
public static decimal ResolveUnwindPreviousNotional(
eod_swap lastEod,
@@ -834,6 +966,47 @@ namespace YLErp.Modules.SwapModule
return notionalValue > 0 ? posiNotionalValue / notionalValue : 1;
}
/// <summary>
/// 读取"上一收盘日"浮动腿的待实现分红(eod_swap_position.PosiDividendSum),
/// 用于平仓/互换预览页展示"浮动端平仓盈亏·分红(DividendIn)" 与 "待结算分红收益(DividendPending)"。
/// <para>方案C:替代前端 totalInterest × 期初持仓 的重算——后者会把登记日前已平仓、
/// 不享有该笔分红的部分重复计入(GLMS-20260105-0004 误显 -36,160)。
/// EOD 的 PosiDividendSum 已按"实际持仓递推 + 当日实现扣除"算出待实现分红,
/// 是单一可信源。</para>
/// <para>复用 GetUnwindInterests(cs:624-626) 的"上一 EOD 日期"推导:取 eod_swap 中
/// ValueDate &lt; dealDate 的最大日期,无则 dealDate.AddDays(-1);再经
/// SwapEodPositionService.GetPreEodPositions 取该日持仓,匹配 PositionId。</para>
/// <para>抽为 protected virtual:与 GetMaxIncomeValueDate 一致,便于测试替身覆写、
/// 也兼容无 EOD 的边界(返回 0,与历史 DividendIn=0 行为一致)。</para>
/// </summary>
/// <returns>上一收盘日该浮动腿的待实现分红;无 EOD 记录返回 0</returns>
/// <remarks>
/// 【口径论证·勿改】为什么 DividendPending 也用本方法的全量值(非分摊、非硬0):
/// <para>1. 字段语义直接对应:EOD PosiDividendSum 的 DisplayName="浮动端平仓盈亏·分红未实现"
/// (EodSwapPosition.cs:186),递推式 PosiDividendSum=前日+当日新计-当日实现
/// (SwapEodPositionService.cs:1825),即"扣过当日实现后、还挂在账上未来才结的存量"。
/// 前端列"待结算分红收益"(SwapflowList.js:561) 字面就是同一回事 → 直接取 PosiDividendSum。</para>
/// <para>2. 是"存量"非"流量"DividendPending 描述的是"账上还欠多少"(与本次平仓比例无关的总额),
/// 而 DividendIn 才是"本次动作落袋多少"。两者口径本就不同,各自正确。若把 DividendPending 改成
/// 按本次平仓比例分摊,会把"存量"误当"流量",与列名"待结算"矛盾。</para>
/// <para>3. 历史教训:方案C 初版曾把前端 DividendPending 硬编码 0commit e3c473ba),因测试交易
/// PosiDividendSum 恰好=03/2 已全额互换)而测试通过、掩盖问题。但对 PosiDividendSum≠0 的部分
/// 平仓交易,硬0 会落库(SwapFlowEventService.cs:588 冲账取负写入 swap_flow_event.DividendPending
/// 并在事件列表"待结算分红收益"列显示错误的 0 —— 这是确定的回归。故本方法返回值同时喂两栏,
/// 前端不得再覆盖。例外:互换页 DividendPending 保持 0(互换语义=全量结清,结清后待结算归0)。</para>
/// </remarks>
protected virtual decimal GetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
{
var lastEod = DbContext.eod_swap
.Where(x => x.ValueDate < dealDate && x.SwapTradeId == tradeId)
.OrderByDescending(o => o.ValueDate).FirstOrDefault();
var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate;
var preEod = new SwapEodPositionService(this)
.GetPreEodPositions(tradeId, preEodDate)
.FirstOrDefault(x => x.PositionId == positionId);
return preEod == null ? 0m : preEod.PosiDividendSum;
}
/// <summary>
/// 获取固定利率
/// </summary>
@@ -1335,10 +1508,14 @@ namespace YLErp.Modules.SwapModule
NormalizeNotionalValues(unwindData);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_平仓");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易
// 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。
// 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
if (NormalizeFullCloseRequest(unwindData))
{
RecalculateNormalizedUnwindAmounts(unwindData);
}
ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易
bool cofirm = false;
ExecuteInTransaction(() =>
{
@@ -1354,18 +1531,24 @@ namespace YLErp.Modules.SwapModule
DealFloatPosition(unwindData);
var flowList = new List<swap_flow_event>(unwindData.FlowEvents);
var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum., clientCashId, "系统操作_平仓");
if (unwindData.CloseMethod == (int)CloseMethodEnum. || unwindData.ClosePercent == 1)
var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(unwindData.CloseQty);
var isFullClose = IsFullCloseAfterDeduction(unwindData, remainingStockEqvNotional, remainingTradeAmount);
if (isFullClose)
{
td.TradeStatus = "已平仓";
td.StockEqvNotional = 0;
td.TradeAmount = 0;
CallSaveSwapTradeClientCash(td, unwindData.ValueDate);
}
else
{
td.HasPartialUnWind = 1;
td.StockEqvNotional = remainingStockEqvNotional;
td.TradeAmount = remainingTradeAmount;
}
td.Notional = td.TradeAmount;
td.UnWindDate = unwindData.UnwindDate;
td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
td.TradeAmount -= Convert.ToDouble(unwindData.CloseQty);
SaveAllChanges();
cofirm = true;
});
@@ -1860,20 +2043,65 @@ namespace YLErp.Modules.SwapModule
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
NormalizeNotionalValues(swapEvent.unwindData);
// Stored events keep display ratio A; approval calculations consume remaining ratio B.
swapEvent.unwindData.ClosePercent = ToRemainingClosePercent(
swapEvent.unwindData.ClosePercent,
swapEvent.unwindData.NotionalValue,
swapEvent.unwindData.PosiNotionalValue);
var flowList = FindFlowEventsByEventId(swapEvent.id);
swapEvent.unwindData.FlowEvents = flowList;
if (eventType == (int)SwapEventTypeEnum.)
{
if (NormalizeFullCloseRequest(swapEvent.unwindData))
{
RecalculateNormalizedUnwindAmounts(swapEvent.unwindData);
}
}
if (eventType == (int)SwapEventTypeEnum.)
{
NormalizeIncomeUnwindDate(swapEvent.unwindData);
ValidateIncomeValueDate(swapEvent.unwindData, td);
}
var flowList = FindFlowEventsByEventId(swapEvent.id);
if (eventType == (int)SwapEventTypeEnum.)
{
foreach (var item in flowList.Where(x => x.PositionType > 0))
{
item.Quantity = swapEvent.unwindData.CloseQty;
item.PositionQty = swapEvent.unwindData.ClosePercent == 1
? 0
: swapEvent.unwindData.PositionQty - swapEvent.unwindData.CloseQty;
}
}
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)
if (eventType == (int)SwapEventTypeEnum. && swapEvent.unwindData.SwapMarginAmount != 0)
{
AddClientCash(td, Convert.ToDouble(swapEvent.unwindData.SwapMarginAmount), ClientCashInCashOut._应付预付金, swapEvent.unwindData.ValueDate);
}
swapEvent.ClientCashId = clientCashId;
if (swapEvent.unwindData.CloseMethod == (int)CloseMethodEnum.)
td.UnWindDate = swapEvent.unwindData.UnwindDate;
if (eventType != (int)SwapEventTypeEnum.)
{
var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(swapEvent.unwindData.CloseQty);
var isFullClose = IsFullCloseAfterDeduction(swapEvent.unwindData, remainingStockEqvNotional, remainingTradeAmount);
if (isFullClose)
{
td.TradeStatus = "已平仓";
td.StockEqvNotional = 0;
td.TradeAmount = 0;
CallSaveSwapTradeClientCash(td, swapEvent.unwindData.ValueDate);
}
else
{
td.TradeStatus = ConsTrade.;
td.HasPartialUnWind = 1;
td.StockEqvNotional = remainingStockEqvNotional;
td.TradeAmount = remainingTradeAmount;
}
}
else if (swapEvent.unwindData.CloseMethod == (int)CloseMethodEnum.)
{
td.TradeStatus = "已平仓";
CallSaveSwapTradeClientCash(td, swapEvent.unwindData.ValueDate);
@@ -1883,12 +2111,6 @@ namespace YLErp.Modules.SwapModule
td.TradeStatus = ConsTrade.;
td.HasPartialUnWind = 1;
}
td.UnWindDate = swapEvent.unwindData.UnwindDate;
if (eventType != (int)SwapEventTypeEnum.)
{
td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
td.TradeAmount -= Convert.ToDouble(swapEvent.unwindData.CloseQty);
}
td.Notional = td.TradeAmount;
UpdateInitalPosition(flowList, swapEvent.unwindData, eventType);
@@ -1920,6 +2142,13 @@ namespace YLErp.Modules.SwapModule
// 与 SwapUnwind(L1270) 保持一致——缺少此转换会导致 SaveSwapDealInternal 的 B→A 还原出错
// (例如第二次部分平仓 50%(A) → 错误还原为 0.325 而非 0.50)。
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
if (eventType == (int)SwapEventTypeEnum.)
{
if (NormalizeFullCloseRequest(unwindData))
{
RecalculateNormalizedUnwindAmounts(unwindData);
}
}
string action = eventType == (int)SwapEventTypeEnum. ? ClientCashInCashOut._互换 : ClientCashInCashOut._平仓费;
ExecuteInTransaction(() =>
{
@@ -2053,8 +2282,17 @@ namespace YLErp.Modules.SwapModule
else
{
// 平仓时才扣减持仓
position.PosiQuantity -= unwindData.CloseQty;
position.PosiNotionalValue = Math.Round(position.PosiNotionalValue - unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var remainingPositionQty = position.PosiQuantity - unwindData.CloseQty;
var remainingPositionNotional = Math.Round(
position.PosiNotionalValue - unwindData.CloseNotionalValue,
ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
position.PosiQuantity = unwindData.ClosePercent == 1
? 0
: remainingPositionQty;
position.PosiNotionalValue = unwindData.ClosePercent == 1
? 0
: remainingPositionNotional;
position.PosiTradingFee -= position.PosiTradingFee * unwindData.ClosePercent;
position.PosiTradingFeePending -= position.PosiTradingFeePending * unwindData.ClosePercent;
}
@@ -2068,10 +2306,13 @@ namespace YLErp.Modules.SwapModule
position.InterestFeePending += interest.InterestFee;
if ((interest.InterestMode == (int)InterestModeEnum. || interest.InterestMode == (int)InterestModeEnum.) && eventType == (int)SwapEventTypeEnum.)
{
position.InterestPrincipalFix = Math.Round(
var remainingInterestPrincipal = Math.Round(
position.InterestPrincipalFix - interest.InterestPrincipal,
ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
position.InterestPrincipalFix = unwindData.ClosePercent == 1
? 0
: remainingInterestPrincipal;
}
}
}
@@ -307,6 +307,14 @@ namespace YLErp.Modules.SwapModule
return DbContext.swap_flow_event.Where(eventExpression).ToList();
}
protected virtual List<swap_flow_event> FindCompletedFlowEvents(List<int> tradeIds)
{
return DbContext.swap_flow_event
.Where(x => tradeIds.Contains(x.SwapTradeId)
&& x.DataState == (int)SwapFlowDateStateEnum.)
.ToList();
}
#endregion
/// <summary>
@@ -352,6 +360,7 @@ namespace YLErp.Modules.SwapModule
var tradeRealPositionList = allTradePositionList.Where(t => !t.IsInitial).ToList();
var tradeExtendList = FindTradeExtends(tradeIds);
var eodSwapList = FindEodSwapsByDate(preSettleDate);
var completedFlowEvents = FindCompletedFlowEvents(tradeIds);
List<int> eventTyps = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
foreach (var td in tradeQueryList)
{
@@ -364,7 +373,10 @@ namespace YLErp.Modules.SwapModule
var realPositions = tradeRealPositionList.Where(s => s.SwapTradeId == td.id);
var posiList = positions.Where(x => x.PosiQuantity > 0).ToList();
var realPosiList = realPositions.ToList();
var interestList = positions.Where(x => x.InterestDirection > 0).ToList();
var tradeCompletedFlowEvents = completedFlowEvents.Where(x => x.SwapTradeId == td.id).ToList();
var interestList = SwapDealService.ResolveInterestLegPositionsAsOf(
positions.ToList(), realPosiList, tradeCompletedFlowEvents, settleDate)
.Where(x => x.InterestDirection > 0).ToList();
DateTime posiDate = td.TradeDate.Value;//交易日期
var lastEodSwap = eodSwapList.FirstOrDefault(x => x.SwapTradeId == td.id);
//上一交易日无日终归档,且不是交易日期,且当前收盘日期不是交易日期,报错
@@ -1351,8 +1363,9 @@ namespace YLErp.Modules.SwapModule
//持仓内容-利息腿
newEodPayPosition.InterestDirection = position.InterestDirection;
newEodPayPosition.InterestMode = position.InterestMode;
// ResolveInterestLegPositions 已提供平仓后的实时剩余本金,日终不再重复扣减。
newEodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix;
newEodPayPosition.InterestPrincipalFix *= (1 - closePercent);
// newEodPayPosition.InterestPrincipalFix *= (1 - closePercent);
newEodPayPosition.InterestRateDefault = position.InterestRateDefault;
newEodPayPosition.InterestSwapInterval = position.InterestSwapInterval;
newEodPayPosition.IsAnnualized = position.IsAnnualized;
@@ -1364,9 +1377,11 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.interest_rest_days = position.interest_rest_days;
newEodPayPosition.interest_rule = position.interest_rule;
//利息端估值用信息
newEodPayPosition.TdInterestPrincipal = position.InterestMode == (int)InterestModeEnum.
? posiNotionalValue
: interests.Count > 0 ? interests.First().InterestPrincipal : 0;
newEodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode)
? position.InterestPrincipalFix
: position.InterestMode == (int)InterestModeEnum.
? posiNotionalValue
: interests.Count > 0 ? interests.First().InterestPrincipal : 0;
if (interval != null)
{
newEodPayPosition.TdInterestRate = interval.Rate;
@@ -1397,8 +1412,11 @@ namespace YLErp.Modules.SwapModule
}
else
{
var pendingInterestBeforeSettlement = autoSwap
? interestAmountBeforeSettlement
: lastInterestIncomeSum + newEodPayPosition.TdInterestIncome;
newEodPayPosition.InterestIncomeSum = RoundEodInterest(
interestAmountBeforeSettlement - newEodPayPosition.TdCloseInterest);
pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
}
//持仓内容-利息腿-损益统计(本方视角)
+22 -16
View File
@@ -94,28 +94,34 @@ namespace YLErp.Modules.SwapModule
{
var obj = DbContext.swap_event.Where(O => O.id == swap_Event.id).FirstOrDefault();
var trade = DbContext.trade.Where(O => O.id == swap_Event.SwapTradeId).FirstOrDefault();
if (trade == null)
{
throw new ServiceException("未找到交易,请刷新页面后再次尝试!");
}
var oldMaturityDate = trade.ExerciseDate.Value;
if (obj != null && !string.IsNullOrEmpty(obj.EventData))
{
oldMaturityDate = JsonConvert.DeserializeObject<ExtenstionData>(obj.EventData).OldMaturityDate;
}
if (swap_Event.extenstionData.NewMaturityDate <= oldMaturityDate)
{
throw new ServiceException("新到期日应晚于原到期日!");
}
if (obj != null && obj.ValueDate >= swap_Event.ValueDate)
{
throw new ServiceException("新展期日应晚于原展期日!");
}
if (swap_Event.extenstionData.NewMaturityDate< trade.StartDate)
{
throw new ServiceException("新到期日不应早于交易开始日期!");
}
swap_Event.extenstionData.OldMaturityDate = oldMaturityDate;
string data = JsonConvert.SerializeObject(swap_Event.extenstionData);
if (obj != null)
{
obj.Invalid = true;
}
swap_Event.EventReason = "交易展期";
if (swap_Event.extenstionData.OldMaturityDate == swap_Event.extenstionData.NewMaturityDate)
{
throw new ServiceException("新到期日不应和原到期日一致!");
}
if (obj!=null&&obj.ValueDate >= swap_Event.ValueDate)
{
throw new ServiceException("新展期日应晚于原展期日!");
}
if (trade == null)
{
throw new ServiceException("未找到交易,请刷新页面后再次尝试!");
}
if (swap_Event.extenstionData.NewMaturityDate< trade.StartDate)
{
throw new ServiceException("新到期日不应早于交易开始日期!");
}
trade.ExerciseDate = swap_Event.extenstionData.NewMaturityDate;
//修改互换观察日到期日
new SwapTradeService(this).UpdateObservationDay(trade.id, swap_Event.extenstionData.OldMaturityDate, trade.ExerciseDate.Value);
+7 -7
View File
@@ -276,10 +276,10 @@ namespace YLErp.BLL
}
var swapPositions = db.Set<swap_position>()
.Where(sp => swapTradeIds.Contains(sp.SwapTradeId) && sp.IsInitial && sp.UnderlyingCode != null)
.Select(sp => new { sp.SwapTradeId, sp.PosiNetPrice, sp.UnderlyingCode })
.Select(sp => new { sp.SwapTradeId, sp.PosiGrossPrice, sp.UnderlyingCode })
.ToList();
var logger = LogFactory.GetLogger<tradeBLL>();
var posDict = swapPositions.GroupBy(sp => sp.SwapTradeId).ToDictionary(g => g.Key, g => g.First().PosiNetPrice);
var posDict = swapPositions.GroupBy(sp => sp.SwapTradeId).ToDictionary(g => g.Key, g => g.First().PosiGrossPrice);
var umProvider = DataCacheProvider.GetUnderlyingDataSource();
// 需求②:平仓/行权/互换交易,审批角色应取 CloseProcess 流程的节点角色,而非 TradeProcess
var closeProcessRoles = db.approvalprocess
@@ -312,14 +312,14 @@ namespace YLErp.BLL
tradeLinq.TradeSinglePrice = option.OpenCommission;
}
// --- 新增逻辑:针对收益互换类型,用 swap_position.PosiNetPrice 覆盖展示用的期初标的价格 ---
// --- 针对收益互换类型,用 swap_position.PosiGrossPrice 覆盖展示用的期初标的价格 ---
try
{
if (tradeLinq.TradeType == "收益互换" && posDict.TryGetValue(tradeLinq.id, out var netPrice) && netPrice > 0)
if (tradeLinq.TradeType == "收益互换" && posDict.TryGetValue(tradeLinq.id, out var grossPrice) && grossPrice > 0)
{
// 将期初价格覆盖为互换持仓的 PosiNetPrice(仅使用 PosiDirection != 0 的期初持仓)
logger.Info($"tradeOpeningProcessQuery.DAL override: tradeId={tradeLinq.id} beforeInitialSpot={tradeLinq.InitialSpotPrice} dbPosi={netPrice}");
tradeLinq.InitialSpotPrice = Convert.ToDouble(netPrice);
// 将期初价格覆盖为互换持仓的 PosiGrossPrice(仅使用 PosiDirection != 0 的期初持仓)
logger.Info($"tradeOpeningProcessQuery.DAL override: tradeId={tradeLinq.id} beforeInitialSpot={tradeLinq.InitialSpotPrice} dbPosi={grossPrice}");
tradeLinq.InitialSpotPrice = Convert.ToDouble(grossPrice);
}
}
catch (Exception ex)
+32 -24
View File
@@ -1,48 +1,56 @@
// 通过 /front/swappriceprecision 加载。可按 UnderlyingInstrumentType 修改;缺项或非法值由页面精度组件回退内置默认规则。
window.main = window.main || {};
window.main.swapPricePrecision = {
Stock: { integerDigits: 7, precision: 2 },
StockIndex: { integerDigits: 7, precision: 2 },
StockIF: { integerDigits: 7, precision: 4 },
CommodityFutures: { integerDigits: 7, precision: 4 },
CommoditySpot: { integerDigits: 7, precision: 4 },
NewOtcStock: { integerDigits: 7, precision: 4 },
HKStock: { integerDigits: 7, precision: 4 },
HKStockIndex: { integerDigits: 7, precision: 4 },
Fund: { integerDigits: 7, precision: 4 },
common: {
amount: { precision: 2, grouping: true },
quantity: { integerDigits: 16, precision: 2, grouping: true },
rate: { precision: 4 }
},
Stock: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
StockIndex: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
StockIF: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
CommodityFutures: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
CommoditySpot: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
NewOtcStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
HKStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
HKStockIndex: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
Fund: { integerDigits: 7, precision: 4, quantityPrecision: 4, quantityIntegerDigits: 12 },
Bond: {
quantityPrecision: 0, quantityIntegerDigits: 16,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
CreditBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
OtherBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBFutures: { integerDigits: 8, precision: 4 },
OtherFutures: { integerDigits: 8, precision: 4 },
GoldSpot: { integerDigits: 8, precision: 4 },
OtherSpot: { integerDigits: 8, precision: 4 },
AbroadFutures: { integerDigits: 8, precision: 4 },
AbroadSpot: { integerDigits: 8, precision: 4 },
AbroadStock: { integerDigits: 8, precision: 2 },
AbroadStockIndex: { integerDigits: 8, precision: 4 },
ExRate: { integerDigits: 2, precision: 8 },
Shibor: { integerDigits: 2, precision: 4 },
FixingRepoRate: { integerDigits: 2, precision: 4 },
RateYield: {integerDigits: 6, precision: 8},
BondIndex: {integerDigits: 6, precision: 4},
// TODO: 利率收益率(6+8)、债券指数(6+4)、黄金期货(6+4)待对应的 UnderlyingInstrumentType 枚举确认后启用。
TBFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
OtherFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
GoldFutures: { quantityIntegerDigits: 12 },
GoldSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
OtherSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadStock: { integerDigits: 8, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadStockIndex: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
ExRate: { integerDigits: 2, precision: 8, quantityPrecision: 8, quantityIntegerDigits: 16 },
Shibor: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
FixingRepoRate: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
RateYield: {integerDigits: 6, precision: 8, quantityPrecision: 2, quantityIntegerDigits: 12},
BondIndex: {integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12},
};
@@ -79,6 +79,10 @@ namespace YLErp.Web.Controllers
}
/// <summary>
/// 获取某债券期间付息,扣除当天已消费部分
/// <para>【已废弃·不再用于平仓/互换预览】分红展示改由后端 SwapDealService.GetPreEodDividendSum
/// 读 EOD PosiDividendSum 提供(方案C,单一可信源)。前端 unwindSwapTrade.js / incomeSwapTrade.js
/// 的 getDivindIn 不再调用本接口。本接口仅保留供历史调用方,consumedDividend 查询无日期过滤的
/// 隐患随废弃自然消解,不再单独修复。</para>
/// </summary>
/// <param name="startDate"></param>
/// <param name="endDate"></param>
@@ -55,7 +55,7 @@
{{dateFormat(extenstion.extenstionData.OldMaturityDate)}}
</td>
<td>
<vue-datepicker :holiday="1" v-model="extenstion.extenstionData.NewMaturityDate" :disabled="!edit" style="width:100px;"/>
<vue-datepicker :holiday="1" :mindate="extenstion.extenstionData.OldMaturityDate" v-model="extenstion.extenstionData.NewMaturityDate" :disabled="!edit" style="width:100px;"/>
</td>
<td>{{extenstion.OptName}}</td>
<td>{{dateFormat(extenstion.OptTime,'YYYY-MM-DD HH:mm:ss')}}</td>
+9 -9
View File
@@ -42,15 +42,15 @@
<tbody>
<tr>
<td>成交名义本金</td>
<td>{{deal.NotionalValue}}</td>
<td>{{formatAmount(deal.NotionalValue)}}</td>
<td>持仓名义本金</td>
<td>{{deal.PosiNotionalValue}}</td>
<td>{{formatAmount(deal.PosiNotionalValue)}}</td>
</tr>
<tr>
<td>成交数量</td>
<td> {{deal.NotionalQty}}</td>
<td> {{formatQuantity(deal.NotionalQty)}}</td>
<td>持仓数量</td>
<td> {{deal.PositionQty2}}</td>
<td> {{formatQuantity(deal.PositionQty2)}}</td>
</tr>
<tr>
<td>起始日期</td>
@@ -66,7 +66,7 @@
</tr>
<tr>
<td>平仓总额</td>
<td> {{deal.SwapCloseAmount}}</td>
<td> {{formatAmount(deal.SwapCloseAmount)}}</td>
<td></td>
<td></td>
</tr>
@@ -98,7 +98,7 @@
<td>
<vue-number-input v-model="item.InterestFee" v-bind:format="inputFormatInterestAmount" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{item.InterestClosePnL}}</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
@@ -126,7 +126,7 @@
<td>
<vue-number-input v-model="item.InterestFee" v-bind:format="inputFormatInterestAmount" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{item.InterestClosePnL}}</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
@@ -177,13 +177,13 @@
<span title="使用系统标的价格" class="glyphicon glyphicon-refresh"></span>
</a>
</td>
<td>{{floatPosition.Quantity}}</td>
<td>{{formatQuantity(floatPosition.Quantity)}}</td>
<td>
<vue-number-input v-model="floatPosition.TradingFee" v-bind:format="inputFormatInterestAmount" v-on:input="changeTradingFee"></vue-number-input>
<div class="bubble-box" style="margin-left:6px;">我方{{floatPosition.PayDirection==1?"支付":"收取"}}交易费用</div>
</td>
<td> <vue-number-input v-model="floatPosition.DividendIn" v-bind:format="inputFormatDividend" v-on:input="changeTradingFee"></vue-number-input></td>
<td style="font-size:18px;">{{floatPosition.FloatPnlSum}}</td>
<td style="font-size:18px;">{{formatAmount(floatPosition.FloatPnlSum)}}</td>
</tr>
</tbody>
</table>
@@ -19,6 +19,8 @@
</script>
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/fast/fastVue.components.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/front/swappriceprecision?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/app/swaptrade/swapPricePrecisionHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapLongShort.js?v=@HtmlUtil.JsVersion"></script>
}
<div class="pb-3" id="vueDiv">
@@ -26,11 +28,11 @@
<div id="commomArea" class="row" style="height:140px">
<div class="form-group col-md-4">
<label class="formlabel">成交名义本金</label>
<input class="text-box" v-model="deal.NotionalValue" readonly="readonly" type="text" />
<input class="text-box" :value="formatAmount(deal.NotionalValue)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-4">
<label class="formlabel">持仓名义本金</label>
<input class="text-box" v-model="deal.PosiNotionalValue" readonly="readonly" type="text" />
<input class="text-box" :value="formatAmount(deal.PosiNotionalValue)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-4">
<label class="formlabel">起始日期</label>
@@ -46,7 +48,7 @@
</div>
<div class="form-group col-md-4">
<label class="formlabel">平仓总额</label>
<input class="text-box" v-model="deal.SwapCloseAmount" readonly="readonly" type="text" />
<input class="text-box" :value="formatAmount(deal.SwapCloseAmount)" readonly="readonly" type="text" />
</div>
</div>
</div>
@@ -73,7 +75,7 @@
<td>
<vue-number-input v-model="item.InterestFee" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{item.InterestClosePnL}}</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
@@ -101,7 +103,7 @@
<td>
<vue-number-input v-model="item.InterestFee" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{item.InterestClosePnL}}</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
@@ -20,6 +20,8 @@
</script>
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/fast/fastVue.components.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/front/swappriceprecision?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/app/swaptrade/swapPricePrecisionHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindLongShort.js?v=@HtmlUtil.JsVersion"></script>
}
<div class="pb-3" id="vueDiv">
@@ -27,20 +29,20 @@
<div id="commomArea" class="row" style="height:140px">
<div class="form-group col-md-3">
<label class="formlabel">成交名义本金</label>
<input class="text-box" v-model="deal.NotionalValue" readonly="readonly" type="text" />
<input class="text-box" :value="formatAmount(deal.NotionalValue)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">持仓名义本金</label>
<input class="text-box" v-model="deal.PosiNotionalValue" readonly="readonly" type="text" />
<input class="text-box" :value="formatAmount(deal.PosiNotionalValue)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">成交数量</label>
<input class="text-box" v-model="deal.NotionalQty" readonly="readonly" type="text" />
<input class="text-box" :value="formatQuantity(deal.NotionalQty)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">持仓数量</label>
<input class="text-box" v-model="deal.PositionQty" readonly="readonly" type="text" />
<input class="text-box" :value="formatQuantity(deal.PositionQty)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">起始日期</label>
@@ -60,7 +62,7 @@
</div>
<div class="form-group col-md-3">
<label class="formlabel">平仓总额</label>
<input class="text-box" v-model="deal.SwapCloseAmount" readonly="readonly" type="text" />
<input class="text-box" :value="formatAmount(deal.SwapCloseAmount)" readonly="readonly" type="text" />
</div>
</div>
</div>
@@ -89,7 +91,7 @@
<td>
<vue-number-input v-model="item.InterestAmount" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{item.InterestClosePnL}}</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
@@ -113,7 +115,7 @@
<td>
<vue-number-input v-model="item.InterestAmount" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{item.InterestClosePnL}}</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
+11 -11
View File
@@ -33,20 +33,20 @@
<div id="commomArea" class="row" style="height:157px">
<div class="form-group col-md-3">
<label class="formlabel">成交名义本金</label>
<input class="text-box" v-model="deal.NotionalValue" readonly="readonly" type="text" />
<input class="text-box" :value="formatAmount(deal.NotionalValue)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">持仓名义本金</label>
<input class="text-box" v-model="deal.PosiNotionalValue" readonly="readonly" type="text" />
<input class="text-box" :value="formatAmount(deal.PosiNotionalValue)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">成交数量</label>
<input class="text-box" v-model="deal.NotionalQty" readonly="readonly" type="text" />
<input class="text-box" :value="formatQuantity(deal.NotionalQty)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">持仓数量</label>
<input class="text-box" v-model="deal.PositionQty2" readonly="readonly" type="text" />
<input class="text-box" :value="formatQuantity(deal.PositionQty2)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">平仓方式</label>
@@ -64,7 +64,7 @@
</div>
<div class="form-group col-md-3" v-show="deal.CloseType==1">
<label class="formlabel">平仓数量</label>
<vue-number-input v-model="deal.CloseQty" v-on:input="changeCloseQty" v-bind:format="inputFormatTradeAmount"></vue-number-input>
<vue-number-input v-model="deal.CloseQty" v-on:input="changeCloseQty" v-bind:format="getQuantityInputFormat()"></vue-number-input>
</div>
<div class="form-group col-md-3" v-show="deal.CloseType==2">
<label class="formlabel">平仓比例</label>
@@ -96,7 +96,7 @@
</div>
<div class="form-group col-md-3">
<label class="formlabel">平仓总额</label>
<input class="text-box" v-model="deal.SwapCloseAmount" readonly="readonly" type="text" />
<input class="text-box" :value="formatAmount(deal.SwapCloseAmount)" readonly="readonly" type="text" />
</div>
</div>
</div>
@@ -125,7 +125,7 @@
<td>
<vue-number-input v-model="item.InterestAmount" v-bind:format="inputFormatCloseAmount" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{item.InterestClosePnL}}</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
@@ -165,7 +165,7 @@
<td>
<vue-number-input v-model="item.InterestAmount" v-bind:format="inputFormatCloseAmount" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{item.InterestClosePnL}}</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
@@ -214,7 +214,7 @@
<span title="使用系统标的价格" class="glyphicon glyphicon-refresh"></span>
</a>
</td>
<td>{{deal.CloseQty}}</td>
<td>{{formatQuantity(deal.CloseQty)}}</td>
<td>
<vue-number-input v-model="floatPosition.TradingFee" v-bind:format="inputFormatCloseAmount" v-on:input="changeTradingFee"></vue-number-input>
<div class="bubble-box">我方{{floatPosition.PayDirection==1?"支付":"收取"}}交易费用</div>
@@ -222,8 +222,8 @@
<td>
<vue-number-input v-model="floatPosition.TradingFeePending" v-bind:format="inputFormatEqvNotional" disabled></vue-number-input>
</td>
<td>{{floatPosition.DividendIn}}</td>
<td style="font-size:18px;">{{floatPosition.FloatPnlSum}}</td>
<td>{{formatAmount(floatPosition.DividendIn)}}</td>
<td style="font-size:18px;">{{formatAmount(floatPosition.FloatPnlSum)}}</td>
</tr>
</tbody>
</table>
@@ -102,6 +102,8 @@
<script src="@HtmlUtil.BasicDataJs("客户")"></script>
<script src="~/Scripts/fast/fastVue.components.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/tradeHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/front/swappriceprecision?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/app/swaptrade/swapPricePrecisionHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/trade/tradeUpload.js?v=2"></script>
<script src="~/Scripts/app/swaptrade/step.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/SwapflowList.js?v=@HtmlUtil.JsVersion"></script>
@@ -231,15 +233,15 @@
</div>
<div class="form-group">
<label class="formlabel ">成交数量</label>
<vue-number-input v-model="swapflow.TradingQty" v-bind:format="inputFormatTradeAmount"></vue-number-input>
<vue-number-input v-model="swapflow.TradingQty" v-bind:format="getQuantityInputFormat()"></vue-number-input>
</div>
<div class="form-group">
<label class="formlabel ">成交金额</label>
<vue-number-input v-model="swapflow.TradingAmount" v-bind:format="inputFormatTradePrice"></vue-number-input>
<vue-number-input v-model="swapflow.TradingAmount" v-bind:format="inputFormatSwapAmount"></vue-number-input>
</div>
<div class="form-group">
<label class="formlabel ">交易费用</label>
<vue-number-input v-model="swapflow.TradingFee" v-bind:format="inputFormatTradePrice"></vue-number-input>
<vue-number-input v-model="swapflow.TradingFee" v-bind:format="inputFormatSwapAmount"></vue-number-input>
</div>
<div class="form-group">
<label class="formlabel ">成交均价</label>
+1 -1
View File
@@ -493,7 +493,7 @@
<vue-swap-price-input :key="getPosiPriceFormatKey(item,'normalPosiGrossPrice')" v-model="item.PosiGrossPrice" v-bind:format="getPosiPriceInputFormat(item,'grossPrice')" v-on:input="changeSpotPrice(item)"></vue-swap-price-input>
</td>
<td>
<vue-number-input v-model="item.PosiQuantity" v-on:input="changeQuantity(item)" v-bind:format="inputFormatPositionQuantityFixed2"></vue-number-input>{{item.underlying!=null?item.underlying.QuoteUnitString:''}}
<vue-number-input v-model="item.PosiQuantity" v-on:input="changeQuantity(item)" v-bind:format="getQuantityInputFormat(item)"></vue-number-input>{{item.underlying!=null?item.underlying.QuoteUnitString:''}}
</td>
<td>
<template v-if="posiFeeModePercent">
+45 -44
View File
@@ -32,6 +32,7 @@
var sr = trade.trade_extend.ExtendObj.SettlementRules;
bool hideFloatingIncomeDirection = PS.Config.ErpElement.SwapFloatingIncomeReceiveOnlyMode;
string SwapPriceData(decimal? value) => value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
string SwapCommonData(object value) => value == null ? string.Empty : Convert.ToString(value, CultureInfo.InvariantCulture);
}
@section CSS{
<link href="~/Style/Css/swapTradeView.css?@HtmlUtil.JsVersion" rel="stylesheet" />
@@ -134,7 +135,7 @@
</tr>
<tr>
<td>名义本金</td>
<td class="color-bule">@trade.OriginalStockEqvNotional.OtcFormat(OtcFormatFlag.StockEqvNotional)</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(trade.OriginalStockEqvNotional)" data-kind="amount"></span></td>
</tr>
@*<tr>
<td>初始预付金</td>
@@ -277,9 +278,9 @@
<td class="@bgclass" style="width:116px !important;">@((SwapDirectionEnum)item.InterestDirection)</td>
<td>@((InterestModeEnum)item.InterestMode)</td>
<td>@item.HappenDate.OtcFormatDate()</td>
<td>@item.InterestPrincipalFix.OtcFormat(OtcFormatFlag.StockEqvNotional)</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestPrincipalFix)" data-kind="amount"></span></td>
<td>@item.Currency</td>
<td>@item.InterestRateDefault.OtcFormatPercent(4)</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestRateDefault)" data-kind="rate"></span></td>
<td>@(item.IsAnnualized ? "是" : "否")</td>
<td>
<button class="btn btn-sm btn-outline-danger" type="button" onclick="showSwapRate('@(item.InterestSwapInterval)', true)" style="height:22px;">查看</button>
@@ -342,11 +343,11 @@
<tr class="color-bule">
<td class="@bgclass" style="width:116px !important;">@((SwapDirectionEnum)item.InterestDirection)</td>
<td>@((InterestModeEnum)item.InterestMode)</td>
<td>@interestPrice?.OtcFormat(OtcFormatFlag.StockEqvNotional)</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(interestPrice)" data-kind="amount"></span></td>
<td>
@(string.IsNullOrEmpty(item.FloatRateUnderlyingCode) ? "无" : item.FloatRateUnderlyingCode)
@sign
@item.InterestRateDefault.OtcFormatPercent(4)
<span class="js-swap-common" data-value="@SwapCommonData(item.InterestRateDefault)" data-kind="rate"></span>
</td>
<td>@(item.IsAnnualized ? "是" : "否")</td>
<td>
@@ -422,7 +423,7 @@
</td>
}
<td>
@item.PosiQuantity.OtcFormat(OtcFormatFlag.StockEqvNotional)
<span class="js-swap-common" data-value="@SwapCommonData(item.PosiQuantity)" data-kind="quantity" data-instrument-type="@item.UnderlyingInstrumentType"></span>
</td>
<td>
@if (item.PosiFeeType == 0)
@@ -435,7 +436,7 @@
}
</td>
<td>
@item.PosiTradingFeePending.OtcFormat(OtcFormatFlag.StockEqvNotional)
<span class="js-swap-common" data-value="@SwapCommonData(item.PosiTradingFeePending)" data-kind="amount"></span>
</td>
</tr>
}
@@ -507,9 +508,9 @@
<td class="@bgclass" style="width:116px !important;">@((SwapDirectionEnum)item.InterestDirection)</td>
<td>@((InterestModeEnum)item.InterestMode)</td>
<td>@item.HappenDate.OtcFormatDate()</td>
<td>@item.InterestPrincipalFix.OtcFormat(OtcFormatFlag.StockEqvNotional)</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestPrincipalFix)" data-kind="amount"></span></td>
<td>@item.Currency</td>
<td>@item.InterestRateDefault.OtcFormatPercent(4)</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestRateDefault)" data-kind="rate"></span></td>
<td>@(item.IsAnnualized ? "是" : "否")</td>
<td><button class="btn btn-sm btn-outline-danger" type="button" onclick="showSwapRate('@(item.InterestSwapInterval)')" style="height:22px;">查看</button></td>
</tr>
@@ -567,11 +568,11 @@
<tr class="color-bule">
<td class="@bgclass" style="width:116px !important;">@((SwapDirectionEnum)item.InterestDirection)</td>
<td>@((InterestModeEnum)item.InterestMode)</td>
<td>@interestPrice?.OtcFormat(OtcFormatFlag.StockEqvNotional)</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(interestPrice)" data-kind="amount"></span></td>
<td>
@(string.IsNullOrEmpty(item.FloatRateUnderlyingCode) ? "无" : item.FloatRateUnderlyingCode)
@sign
@item.InterestRateDefault.OtcFormatPercent(4)
<span class="js-swap-common" data-value="@SwapCommonData(item.InterestRateDefault)" data-kind="rate"></span>
</td>
<td>@(item.IsAnnualized ? "是" : "否")</td>
<td>@(item.InterestType == 0 ? "单利" : "复利")</td>
@@ -645,10 +646,10 @@
</td>
}
<td>
@item.PosiQuantity.OtcFormat(OtcFormatFlag.StockEqvNotional)
<span class="js-swap-common" data-value="@SwapCommonData(item.PosiQuantity)" data-kind="quantity" data-instrument-type="@item.UnderlyingInstrumentType"></span>
</td>
<td>@item.PosiNotionalValue.OtcFormat(OtcFormatFlag.StockEqvNotional)</td>
<td>@item.PosiTradingFeePending.OtcFormat(OtcFormatFlag.StockEqvNotional)</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.PosiNotionalValue)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.PosiTradingFeePending)" data-kind="amount"></span></td>
<td>@item.PosiStartDate.OtcFormatDate()</td>
<td>@item.PosiMatuirityDate.OtcFormatDate()</td>
</tr>
@@ -709,14 +710,14 @@
@if (tc.CloseType == 1)
{
<td class="tdRight">平仓数量</td>
<td class="color-bule">@((PS.Config.IsUseDisplayNotional ? tc.CloseQty * (trade.CountRatio ?? 1) : tc.CloseQty).OtcFormatNotional())</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(PS.Config.IsUseDisplayNotional ? tc.CloseQty * (trade.CountRatio ?? 1) : tc.CloseQty)" data-kind="quantity" data-instrument-type="@trade.UnderlyingInstrumentType"></span></td>
}
else
{
<td class="tdRight">平仓比例</td>
<td class="color-bule">@(tc.ClosePercent.OtcFormatPercent(4))</td>
<td class="tdRight">平仓名义本金</td>
<td class="color-bule">@(tc.CloseNotionalValue.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(tc.CloseNotionalValue)" data-kind="amount"></span></td>
}
</tr>
@@ -734,9 +735,9 @@
</tr>
<tr>
<td class="tdRight">实现盈亏</td>
<td class="color-bule">@(tc.SwapRealizedPnL.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(tc.SwapRealizedPnL)" data-kind="amount"></span></td>
<td class="tdRight">平仓总额</td>
<td class="color-bule">@(tc.SwapCloseAmount.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(tc.SwapCloseAmount)" data-kind="amount"></span></td>
</tr>
<tr>
@@ -762,9 +763,9 @@
<tr class="color-bule">
<td class="@bgclass" style="width:116px !important;">@((SwapDirectionEnum)item.InterestDirection)</td>
<td>@(item.InterestModeStr)</td>
<td>@(item.InterestPrincipal.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(item.InterestAmount.OtcFormatMoney())</td>
<td>@(item.InterestClosePnL.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestPrincipal)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestAmount)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestClosePnL)" data-kind="amount"></span></td>
</tr>
}
</tbody>
@@ -798,9 +799,9 @@
<td>@(item.InterestStartDate.OtcFormatDate())</td>
<td>@(item.InterestEndDate.OtcFormatDate())</td>
<td>@(item.Rate.OtcFormatPercent())</td>*@
<td>@(item.InterestFee.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(item.InterestAmount.OtcFormatMoney())</td>
<td>@(item.InterestClosePnL.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestFee)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestAmount)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestClosePnL)" data-kind="amount"></span></td>
</tr>
}
</tbody>
@@ -855,11 +856,11 @@
<td><span class="js-swap-price" data-value="@SwapPriceData(closeFloat.PosiGrossPrice)" data-instrument-type="@closeFloat.UnderlyingInstrumentType" data-field="grossPrice"></span></td>
<td><span class="js-swap-price" data-value="@SwapPriceData(closeFloat.TradingAmountAvg)" data-instrument-type="@closeFloat.UnderlyingInstrumentType" data-field="grossPrice"></span></td>
}
<td>@(closeFloat.Quantity.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(closeFloat.TradingFee.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(closeFloat.TradingFeePending.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(closeFloat.DividendIn.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td style="font-size:18px;">@(closeFloat.FloatPnlSum.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(closeFloat.Quantity)" data-kind="quantity" data-instrument-type="@closeFloat.UnderlyingInstrumentType"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(closeFloat.TradingFee)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(closeFloat.TradingFeePending)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(closeFloat.DividendIn)" data-kind="amount"></span></td>
<td style="font-size:18px;"><span class="js-swap-common" data-value="@SwapCommonData(closeFloat.FloatPnlSum)" data-kind="amount"></span></td>
</tr>
</tbody>
</table>
@@ -893,9 +894,9 @@
</tr>
<tr>
<td class="tdRight">平仓总额</td>
<td class="color-bule">@(eod.NotionalValue.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(eod.NotionalValue)" data-kind="amount"></span></td>
<td class="tdRight">实现盈亏</td>
<td class="color-bule">@(eod.TdRealizedPnL.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(eod.TdRealizedPnL)" data-kind="amount"></span></td>
</tr>
</table>
}
@@ -918,7 +919,7 @@
<td class="tdRight">互换序号</td>
<td class="color-bule">@(index++)</td>
<td class="tdRight">互换名义本金</td>
<td class="color-bule">@(tc.PosiNotionalValue.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(tc.PosiNotionalValue)" data-kind="amount"></span></td>
</tr>
<tr>
<td class="tdRight">起始日期</td>
@@ -934,9 +935,9 @@
</tr>
<tr>
<td class="tdRight">平仓总额</td>
<td class="color-bule">@(tc.SwapCloseAmount.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(tc.SwapCloseAmount)" data-kind="amount"></span></td>
<td class="tdRight">实现盈亏</td>
<td class="color-bule">@(tc.SwapRealizedPnL.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td class="color-bule"><span class="js-swap-common" data-value="@SwapCommonData(tc.SwapRealizedPnL)" data-kind="amount"></span></td>
</tr>
<tr>
@@ -962,9 +963,9 @@
var bgclass = item.InterestDirection == (int)SwapDirectionEnum.收取 ? "swapget" : "swappay";
<tr class="color-bule">
<td class="@bgclass" style="width:116px !important;">@((SwapDirectionEnum)item.InterestDirection)</td>
<td>@(item.InterestFee.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(item.InterestAmount.OtcFormatMoney())</td>
<td>@(item.InterestClosePnL.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestFee)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestAmount)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestClosePnL)" data-kind="amount"></span></td>
</tr>
}
</tbody>
@@ -994,9 +995,9 @@
var bgclass = item.InterestDirection == (int)SwapDirectionEnum.收取 ? "swapget" : "swappay";
<tr class="color-bule">
<td class="@bgclass" style="width:116px !important;">@((SwapDirectionEnum)item.InterestDirection)</td>
<td>@(item.InterestFee.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(item.InterestAmount.OtcFormatMoney())</td>
<td>@(item.InterestClosePnL.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestFee)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestAmount)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestClosePnL)" data-kind="amount"></span></td>
</tr>
}
</tbody>
@@ -1057,10 +1058,10 @@
<td><span class="js-swap-price" data-value="@SwapPriceData(closeFloat.TradingAmountAvg * multiplier)" data-instrument-type="@closeFloat.UnderlyingInstrumentType" data-field="grossPrice"></span></td>
}
<td><span class="js-swap-price" data-value="@SwapPriceData(closeFloat.TradingAmountAvg * multiplier)" data-instrument-type="@closeFloat.UnderlyingInstrumentType" data-field="grossPrice"></span></td>
<td>@((closeFloat.PositionQty??0).OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(closeFloat.TradingFee.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(closeFloat.DividendIn.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td>@(closeFloat.FloatPnlSum.OtcFormat(OtcFormatFlag.StockEqvNotional))</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(closeFloat.PositionQty ?? 0)" data-kind="quantity" data-instrument-type="@closeFloat.UnderlyingInstrumentType"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(closeFloat.TradingFee)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(closeFloat.DividendIn)" data-kind="amount"></span></td>
<td><span class="js-swap-common" data-value="@SwapCommonData(closeFloat.FloatPnlSum)" data-kind="amount"></span></td>
</tr>
</tbody>
</table>
+75
View File
@@ -0,0 +1,75 @@
/**
* _proof_short_income.js 红测试原型 Node 可跑无需 jest 依赖
* ============================================================================
* 目的证明"income 页 MarkClosePnl 缺 longRatio"是一个真实存在但当前测试未覆盖的不一致
*
* 公式逐字抄录自团队金标准 YLErpDAL/Helpers/FrontendCalcReference.cs
* CalcUnwind (unwindSwapTrade.js) : longRatio
* CalcIncome (incomeSwapTrade.js) : longRatio 差异点
*
* 跑法node YLErpWeb/fe-tests/_proof_short_income.js
*/
'use strict';
// ---- unwind 页公式(对应 FrontendCalcReference.CalcUnwind:42-47,含 longRatio----
function calcUnwindMarkClosePnl(input) {
const scale = input.multiplier === 100 ? 0.01 : 1;
const floatRatio = input.payDirection === 1 ? 1 : -1;
const longRatio = input.positionType === 1 ? 1 : -1;
const v = input.closeQty * (input.tradingAmountAvg * scale - input.posiGrossPrice) * floatRatio * longRatio * 10000;
const rounded = Math.round(v) / 10000;
return Math.round(rounded * 100) / 100; // toFixed(2)
}
// ---- income 页公式(对应 FrontendCalcReference.CalcIncome:105-108,无 longRatio----
function calcIncomeMarkClosePnl(input) {
const scale = input.multiplier === 100 ? 0.01 : 1;
const floatRatio = input.payDirection === 1 ? 1 : -1;
// 注意:此处按照当前生产代码,没有乘以 longRatio
const v = input.positionQty * input.contractSize * (input.tradingAmountAvg * scale - input.posiGrossPrice) * floatRatio;
return Math.round(v * 100) / 100;
}
// ---- 修复后 income 公式(补上 longRatio,与 unwind / 后端一致)----
function calcIncomeMarkClosePnlFixed(input) {
const scale = input.multiplier === 100 ? 0.01 : 1;
const floatRatio = input.payDirection === 1 ? 1 : -1;
const longRatio = input.positionType === 1 ? 1 : -1;
const v = input.positionQty * input.contractSize * (input.tradingAmountAvg * scale - input.posiGrossPrice) * floatRatio * longRatio;
return Math.round(v * 100) / 100;
}
// 同一笔债券 TRS:期初全价 1.02,期末(互换/平仓价)105(×100形态→1.05),价差 0.03
const base = { multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, contractSize: 1 };
const longCase = { ...base, closeQty: 10000, positionQty: 10000, payDirection: 1, positionType: 1 };
const shortCase = { ...base, closeQty: 10000, positionQty: 10000, payDirection: 1, positionType: 2 };
function check(name, cond) {
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${name}`);
return cond;
}
console.log('=== 场景A:多头(PositionType=1)—— 两页理应一致 ===');
const aU = calcUnwindMarkClosePnl(longCase);
const aI = calcIncomeMarkClosePnl(longCase);
console.log(` unwind=${aU} income=${aI}`);
let allPass = true;
allPass &= check('多头:unwind == income', aU === aI);
console.log('=== 场景B:空头(PositionType=2)—— 当前代码两页符号相反(红)===');
const bU = calcUnwindMarkClosePnl(shortCase);
const bI = calcIncomeMarkClosePnl(shortCase);
console.log(` unwind=${bU} income=${bI} (空头价格涨应亏损,unwind 正确为负,income 错为正)`);
allPass &= check('空头:unwind == income (当前代码会 FAIL → 证明 bug 存在)', bU === bI);
console.log('=== 场景C:空头 + 修复后 income(补 longRatio)—— 应一致(绿)===');
const bIf = calcIncomeMarkClosePnlFixed(shortCase);
console.log(` unwind=${bU} incomeFixed=${bIf}`);
allPass &= check('空头:unwind == incomeFixed (修复后 PASS → 证明改动可修复)', bU === bIf);
console.log('');
if (allPass) {
console.log('✅ 全部通过(若场景B也PASS,说明已修复或无空头场景)');
} else {
console.log('❌ 场景B 失败 = 当前代码在「空头+income」下两页算出相反符号 → 真实不一致,且现有 FC_001~009 全为多头未覆盖。');
}
@@ -0,0 +1,73 @@
/**
* markClosePnlShortConsistency.test.js 空头场景下 unwind/income 两页 MarkClosePnl 一致性红测试
* ============================================================================
* 状态当前为 RED证明 income MarkClosePnl longRatio 的真实不一致
* 修复 incomeSwapTrade.js:207 YLErpDAL/Helpers/FrontendCalcReference.CalcIncome:107
* 补上 longRatio 本文件应全部转 GREEN
*
* 背景
* - 团队金标准 FrontendCalcReference 明确记录两页差异unwind longRatio
* income longRatioCalcIncome:107注释"无 longRatio"
* - 现有特征化测试 FrontendCalcCharacterizationTest FC_001~009 income 场景
* FC_006~009全部 PositionType=1多头唯一空头场景 FC_005 unwind
* 因此 income 的空头分支从未被覆盖 bug 长期未被发现
* - 后端 ValidateFrontendPnL 用同一 CalcIncome 重算比对公式同源故永远自洽抓不到
*
* 公式逐字抄录来源见注释行号与生产代码一致不改动任何生产文件
*/
const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
// unwind 页 MarkClosePnlunwindSwapTrade.js:313,含 longRatio
function PROD_unwindMarkClosePnl({ closeQty, deliveryPrice, initPosiNetPrice, payDirection, positionType }) {
const floatRatio = payDirection === 1 ? 1 : -1;
const longRatio = positionType === 1 ? 1 : -1;
let v = Math.round(closeQty * (deliveryPrice - initPosiNetPrice) * floatRatio * longRatio * 10000) / 10000;
return Number(v.toFixed(2));
}
// income 页 MarkClosePnlincomeSwapTrade.js:207,无 longRatio
function PROD_incomeMarkClosePnl({ positionAmount, deliveryPrice, initPosiGrossPrice, payDirection }) {
const floatRatio = payDirection === 1 ? 1 : -1;
let v = positionAmount * (deliveryPrice - initPosiGrossPrice) * floatRatio;
return Number(v.toFixed(2));
}
// 金标准(swapCalc.calcMarkClosePnl,对齐 C# FrontendCalcReference.CalcUnwind,含 longRatio
function GOLD({ closeQty, tradingAmountAvg, scale, entryPrice, payDirection, positionType }) {
const floatRatio = payDirection === 1 ? 1 : -1;
const longRatio = positionType === 1 ? 1 : -1;
return SwapCalc.calcMarkClosePnl(closeQty, tradingAmountAvg, scale, entryPrice, floatRatio, longRatio);
}
// 同一笔债券 TRS:期初全价 1.02,期末 105(×100形态→1.05),价差 0.03;数量 10000
const CASE = {
closeQty: 10000, positionAmount: 10000,
deliveryPrice: 105, initPosiNetPrice: 1.02, initPosiGrossPrice: 1.02,
tradingAmountAvg: 105, scale: 0.01, entryPrice: 1.02,
payDirection: 1,
};
describe('MarkClosePnl 两页一致性(多头,应一致)', () => {
test('多头:unwind == income == gold', () => {
const u = PROD_unwindMarkClosePnl({ ...CASE, positionType: 1 });
const i = PROD_incomeMarkClosePnl({ ...CASE, });
const g = GOLD({ ...CASE, positionType: 1 });
expect(u).toBe(i);
expect(i).toBe(g);
});
});
describe('MarkClosePnl 两页一致性(空头,当前 RED)', () => {
test('空头:unwind == income(当前 FAIL → 证明 income 缺 longRatio 的 bug', () => {
const u = PROD_unwindMarkClosePnl({ ...CASE, positionType: 2 });
const i = PROD_incomeMarkClosePnl({ ...CASE, });
// 空头价格涨应亏损:unwind = -300income 当前 = +300(符号反了)
expect(u).toBe(i);
});
test('空头:income 应等于 gold(补 longRatio 后才会 PASS', () => {
const i = PROD_incomeMarkClosePnl({ ...CASE, });
const g = GOLD({ ...CASE, positionType: 2 });
expect(i).toBe(g);
});
});
+13
View File
@@ -182,6 +182,19 @@ describe('多次部分平仓:全部↔部分切换 CloseQty 不跳变(占期
expectClose(closeQty, 25000000, '应=25000000 不受 JS 浮点偏差影响');
expect(closeQty).not.toBe(24999999.999999996);
});
test('trade2308: full close uses the remaining quantity after fixed6 percent formatting', () => {
const notionalValue = 9812312.31;
const posiNotionalValue = 4906156.15;
const oriClosePercent = posiNotionalValue / notionalValue;
const closePercent = 0.5;
const positionQty = 5000000;
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(
closePercent, oriClosePercent, positionQty);
expect(closeQty).toBe(positionQty);
});
});
describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', () => {
@@ -0,0 +1,202 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..');
const read = (relativePath) => fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
const precisionConfigSrc = read('App_Data/Config/swappriceprecision.js');
const precisionHelperSrc = read('wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js');
function loadConfiguredPrecision() {
const context = { window: {} };
vm.runInNewContext(precisionConfigSrc, context);
return context.window.main.swapPricePrecision;
}
function loadHelper(config) {
const context = { window: { main: { swapPricePrecision: config } } };
vm.runInNewContext(precisionHelperSrc, context);
return context.swapPricePrecision;
}
function activeViewSource(source) {
return source.replace(/@\*[\s\S]*?\*@/g, '');
}
describe('swap price precision common wiring', () => {
test('configured and fallback common precision expose 2/2/4', () => {
const configured = loadConfiguredPrecision();
expect(configured.common).toEqual({
amount: { precision: 2, grouping: true },
quantity: { integerDigits: 16, precision: 2, grouping: true },
rate: { precision: 4 }
});
const helper = loadHelper(configured);
expect(helper.getCommonPrecision('amount')).toBe(2);
expect(helper.getCommonPrecision('quantity')).toBe(2);
expect(helper.getCommonPrecision('rate')).toBe(4);
const expectedQuantityPrecisions = {
Stock: 2,
StockIndex: 2,
StockIF: 2,
CommodityFutures: 2,
CommoditySpot: 2,
NewOtcStock: 2,
HKStock: 2,
HKStockIndex: 2,
Fund: 4,
Bond: 0,
TBonds: 0,
CreditBonds: 0,
OtherBonds: 0,
TBFutures: 2,
OtherFutures: 2,
GoldSpot: 2,
OtherSpot: 2,
AbroadFutures: 2,
AbroadSpot: 2,
AbroadStock: 2,
AbroadStockIndex: 2,
ExRate: 8,
Shibor: 2,
FixingRepoRate: 2,
RateYield: 2,
BondIndex: 2
};
Object.entries(expectedQuantityPrecisions).forEach(([instrumentType, precision]) => {
expect(configured[instrumentType].quantityPrecision).toBe(precision);
expect(helper.getCommonPrecision('quantity', instrumentType)).toBe(precision);
expect(helper.getCommonInputFormat('quantity', { append: '' }, instrumentType).precision).toBe(precision);
});
const expectedQuantityIntegerDigits = {
Stock: 12, StockIndex: 12, StockIF: 12, CommodityFutures: 12, CommoditySpot: 12,
NewOtcStock: 12, HKStock: 12, HKStockIndex: 12, Fund: 12, TBFutures: 12,
OtherFutures: 12, GoldFutures: 12, GoldSpot: 12, OtherSpot: 12, AbroadFutures: 12,
AbroadSpot: 12, AbroadStock: 12, AbroadStockIndex: 12, Shibor: 12,
FixingRepoRate: 12, RateYield: 12, BondIndex: 12,
Bond: 16, TBonds: 16, CreditBonds: 16, OtherBonds: 16, ExRate: 16
};
Object.entries(expectedQuantityIntegerDigits).forEach(([instrumentType, integerDigits]) => {
expect(configured[instrumentType].quantityIntegerDigits).toBe(integerDigits);
expect(helper.getCommonInputFormat('quantity', { append: '' }, instrumentType).integerDigits).toBe(integerDigits);
});
expect(helper.getCommonPrecision('quantity', 'OtherRate')).toBe(2);
expect(helper.getCommonInputFormat('quantity', { append: '' }, 'Fund').precision).toBe(4);
expect(helper.getCommonInputFormat('quantity', { append: '' }, 'Bond').precision).toBe(0);
expect(helper.getCommonInputFormat('quantity', { append: '' }, 'OtherRate').integerDigits).toBe(16);
const input = helper.getCommonInputFormat('amount', { append: '', trimTailZeros: true });
expect(input).toEqual(expect.objectContaining({ precision: 2, grouping: true, trimTailZeros: false }));
expect(helper.getCommonInputFormat('rate', { append: '%' })).toEqual(
expect.objectContaining({ precision: 4, grouping: false }));
});
test('invalid common precision falls back and formatted values keep trailing zeros', () => {
const helper = loadHelper({
common: {
amount: { precision: -1 },
quantity: { precision: 14 },
rate: { precision: 'invalid' }
}
});
expect(helper.getCommonPrecision('amount')).toBe(2);
expect(helper.getCommonPrecision('quantity')).toBe(2);
expect(helper.getCommonPrecision('rate')).toBe(4);
const configured = loadHelper({
common: {
amount: { precision: 3 },
quantity: { precision: 1 },
rate: { precision: 2 }
}
});
expect(configured.formatCommon('amount', '12.5')).toBe('12.500');
expect(configured.formatCommon('quantity', 1)).toBe('1.0');
expect(configured.formatCommon('rate', '0.0123')).toBe('1.23%');
expect(configured.formatCommon('amount', '1234.5')).toBe('1,234.500');
expect(configured.normalizeCommon('amount', '1234.5')).toBe('1234.500');
expect(configured.formatCommon('amount', NaN)).toBe('');
expect(configured.formatCommon('amount', Infinity)).toBe('');
});
test('common grouping can be disabled independently from precision', () => {
const helper = loadHelper({
common: {
amount: { precision: 2, grouping: false },
quantity: { precision: 2, grouping: false },
rate: { precision: 4, grouping: false }
}
});
expect(helper.formatCommon('amount', '1234.5')).toBe('1234.50');
expect(helper.getCommonInputFormat('quantity', { append: '' }).grouping).toBe(false);
});
test('missing or invalid asset quantity precision falls back to common quantity precision', () => {
const helper = loadHelper({
common: { quantity: { precision: 3, grouping: true } },
Stock: { quantityPrecision: 'invalid' },
Bond: {}
});
expect(helper.getCommonPrecision('quantity', 'Stock')).toBe(3);
expect(helper.getCommonPrecision('quantity', 'Bond')).toBe(3);
expect(helper.getCommonPrecision('quantity', 'OtherRate')).toBe(3);
expect(helper.getCommonInputFormat('quantity', { append: '' }, 'Stock').integerDigits).toBe(16);
expect(loadHelper({
common: { quantity: { integerDigits: 0, precision: 3 } },
Stock: { quantityIntegerDigits: 'invalid' }
}).getCommonInputFormat('quantity', { append: '' }, 'Stock').integerDigits).toBe(16);
});
test('SwapTrade2 common fields use the price helper without changing price responsibilities', () => {
const scripts = {
edit: read('wwwroot/Scripts/app/swaptrade/swapTradeEdit.js'),
income: read('wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js'),
unwind: read('wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js'),
longShort: read('wwwroot/Scripts/app/swaptrade/swapLongShort.js'),
unwindLongShort: read('wwwroot/Scripts/app/swaptrade/unwindLongShort.js'),
view: read('wwwroot/Scripts/app/swaptrade/swapTradeView.js'),
flow: read('wwwroot/Scripts/app/swaptrade/SwapflowList.js'),
helper: precisionHelperSrc
};
const view = read('Views/SwapTrade2/TradeView.cshtml');
const activeView = activeViewSource(view);
for (const script of Object.values(scripts)) {
expect(script).not.toMatch(/otcformat\.trading\.swap(?:Amount|Quantity|RateP)/);
}
expect(scripts.edit).toMatch(/getCommonInputFormat\('amount'/);
expect(scripts.edit).toMatch(/getCommonInputFormat\(\s*['"]quantity['"]/);
expect(scripts.edit).toMatch(/getCommonInputFormat\('rate'/);
expect(scripts.view).toContain("swapPricePrecision.formatCommon('rate', item.Rate)");
expect(scripts.flow).toMatch(/name:\s*'ytm'[\s\S]*formatter:\s*otcformat\.trading\.marginRateP/);
expect(scripts.flow).not.toMatch(/name:\s*'ytm'[\s\S]*formatCommon\('rate'/);
expect(scripts.unwind).toContain('ClosePercent = otcformat.fixed6');
expect(scripts.unwind).toContain('_.round(tradingFee, 2)');
expect(scripts.unwind).toContain('.toFixed(2)');
expect(scripts.edit).toContain('observationRatePrecision = 12');
expect(scripts.edit).toMatch(/roundObservationRate[\s\S]*swapPricePrecision\.roundDecimal/);
expect(scripts.helper).toContain('getRule: getRule');
expect(scripts.helper).toContain('getInputFormat: function');
expect(scripts.helper).toContain('roundForSubmit: function');
expect(scripts.helper).toContain('normalizeCommon: function');
expect(activeView).not.toContain('OtcFormatHelper');
expect(activeView).not.toMatch(/OtcFormatMoney|OtcFormatNotional/);
expect(activeView).toContain('SwapCommonData');
expect(activeView).toMatch(/js-swap-common[\s\S]*data-kind="amount"/);
expect(activeView).toMatch(/js-swap-common[\s\S]*data-kind="quantity"/);
expect(activeView).toMatch(/js-swap-common[\s\S]*data-kind="rate"/);
expect(scripts.view).toContain('formatSwapCommonElements');
expect(scripts.edit).toContain('getQuantityInputFormat');
expect(scripts.unwind).toContain('getQuantityInputFormat');
expect(scripts.flow).toContain('getQuantityInputFormat');
expect(scripts.flow).toMatch(/formatSwapQuantity = function \(value, options, rowObject\)/);
expect(scripts.flow).toMatch(/row\.UnderlyingInstrumentType[\s\S]*row\.InstrumentType[\s\S]*row\.position[\s\S]*UnderlyingInstrumentType/);
expect(scripts.flow).toContain("formatCommon('quantity', value, instrumentType)");
expect(activeView).toMatch(/data-kind="quantity"[^>]*data-instrument-type=/);
});
});
+9 -6
View File
@@ -43,6 +43,9 @@ function loadUnwindHelpers() {
vueDatePicker() { return {}; },
vueNumberInput() { return {}; }
},
swapPricePrecision: {
createVueInputComponent() { return {}; }
},
tradeHelper: { IsBond() { return false; } },
main: {
post() {
@@ -112,14 +115,14 @@ describe('base-rate pending trading fee', () => {
expectClose(result, 450.00);
});
test('partial close fee and pending fee both use the rounded opening fee allocation', () => {
const tradingFee = swapPosiFeeCalc.calcAllocatedTradingFee(
113.46, consPosiFeeType.Percent, 1.1234, 4039.2, 4000, 10098, 10000);
test('a manually adjusted pending fee does not override the base-rate close fee', () => {
const tradingFee = swapPosiFeeCalc.calcTradingFee(
consPosiFeeType.Unit, 0.123456, 0, 10000);
const pendingFee = swapPosiFeeCalc.calcTradingFeePending(
113.46, consPosiFeeType.Percent, 1.1234, 4039.2, 4000, 10098, 10000, 0.4);
1235.56, consPosiFeeType.Unit, 0.123456, 0, 10000, 0, 10000, 1);
expectClose(tradingFee, 45.38);
expectClose(pendingFee, 45.38);
expectClose(tradingFee, 1234.56);
expectClose(pendingFee, 1235.56);
});
test('without a configured base rate, the legacy close-percent calculation remains', () => {
@@ -31,6 +31,10 @@
main.message("新到期日不能为空");
return;
}
if (this.extenstion.extenstionData.NewMaturityDate <= this.extenstion.extenstionData.OldMaturityDate) {
main.message("新到期日应晚于原到期日");
return;
}
main.post("/swaptrade2/SaveExtension", { swap_Event: thisObj.extenstion })
.done(function (res) {
thisObj.extenstion.id = res.obj.id;
@@ -80,4 +84,4 @@
components: {
'vue-datepicker': FastVue.vueDatePicker(),
}
});
});
@@ -1,7 +1,16 @@
//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 inputFormatSwapAmount = swapPricePrecision.getCommonInputFormat('amount', { negative: true, append: '' });
const formatSwapAmount = value => swapPricePrecision.formatCommon('amount', value);
const formatSwapQuantity = function (value, options, rowObject) {
const row = rowObject || {};
const instrumentType = row.UnderlyingInstrumentType
|| row.InstrumentType
|| (row.position && row.position.UnderlyingInstrumentType)
|| '';
return swapPricePrecision.formatCommon('quantity', value, instrumentType);
};
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
var clients = ylotc.clients;
const consUnderlyingFlag = (function () {
@@ -268,19 +277,19 @@ function getColModelGridStep1() {
label: '成交数量/张数',
width: 160,
align: 'center',
formatter: otcformat.trading.notional
formatter: formatSwapQuantity
}, {
name: 'TradingAmount',
label: '成交金额(元)',
width: 210,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'TradingFee',
label: '交易费用',
width: 90,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'TradingAmountAvg',
label: '成交全价',
@@ -372,13 +381,13 @@ function getColModelGridStep2() {
label: '成交数量/张数',
width: 90,
align: 'center',
formatter: otcformat.trading.notional
formatter: formatSwapQuantity
}, {
name: 'TradingAmount',
label: '成交金额(元)',
width: 90,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'TradingAmountAvg',
label: '成交均价',
@@ -391,7 +400,7 @@ function getColModelGridStep2() {
label: '交易费用',
width: 90,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'TradingAmountFeeAvg',
label: '含费均价',
@@ -521,13 +530,13 @@ function getColModelGridStep3() {
label: '成交数量/张数',
width: 160,
align: 'center',
formatter: otcformat.trading.notional
formatter: formatSwapQuantity
}, {
name: 'TradingAmount',
label: '成交金额(元)',
width: 160,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'ContractSize',
label: '乘数',
@@ -540,31 +549,31 @@ function getColModelGridStep3() {
label: '交易费用佣金',
width: 160,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'TradingFeePending',
label: '待结算交易费用佣金',
width: 160,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'DividendPending',
label: '待结算分红收益',
width: 160,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'MarkClosePnl',
label: '浮动端平仓盈亏·浮动',
width: 160,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'DividendIn',
label: '浮动端平仓盈亏·分红',
width: 160,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}
];
return col;
@@ -664,26 +673,26 @@ function getColModelGridStep4() {
label: '名义数量',
width: 160,
align: 'center',
formatter: otcformat.trading.notional
formatter: formatSwapQuantity
}, {
name: 'position.PosiNotionalValue',
label: '名义本金',
width: 160,
align: 'center',
formatter: otcformat.trading.StockEqvNotional
formatter: formatSwapAmount
}
, {
name: 'position.PosiTradingFee',
label: '交易费用佣金',
width: 160,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'position.PosiTradingFeePending',
label: '待实现交易费用佣金',
width: 160,
align: 'center',
formatter: otcformat.trading.umprice
formatter: formatSwapAmount
}, {
name: 'position.PosiStartDate',
label: '起始日期',
@@ -861,6 +870,7 @@ var vue = new Vue({
SwapTradeNo: "",
BsType: 1,
UnderlyingCode: "",
UnderlyingInstrumentType: "",
TradingQty: 0,
TradingAmount: 0,
TradingFee: 0,
@@ -940,8 +950,8 @@ var vue = new Vue({
const thisObj = this;
main.post(`/swapTrade2/SearchTodayWhetherFRData?dateTime=${this.FRData.date}`).done(function (resp) {
if (resp.UnderlyingCode) { // 如果这天有fr007获取值和id,隐藏警告
thisObj.FRData.value = (resp.ReferencePrice * 100).toFixed(4);
thisObj.FRData.oldValue = (resp.ReferencePrice * 100).toFixed(4);
thisObj.FRData.value = (resp.ReferencePrice * 100).toFixed(swapPricePrecision.getCommonPrecision('rate'));
thisObj.FRData.oldValue = (resp.ReferencePrice * 100).toFixed(swapPricePrecision.getCommonPrecision('rate'));
thisObj.FRData.id = resp.id;
thisObj.isShowWarning = false
thisObj.isFromArtifical = resp.DataSource === "人工"
@@ -1014,6 +1024,12 @@ var vue = new Vue({
this.tradeDate = page.valueDate; //将1970/08/08转化成1970-08-08
return this.tradeDate;
},
getQuantityInputFormat() {
return swapPricePrecision.getCommonInputFormat(
'quantity',
{ append: '' },
this.swapflow.UnderlyingInstrumentType);
},
addNew() {
this.tradeDate = page.valueDate;
this.initSwapFlow();
@@ -1029,6 +1045,7 @@ var vue = new Vue({
SwapTradeNo: "",
BsType: 1,
UnderlyingCode: "",
UnderlyingInstrumentType: "",
TradingQty: 0,
TradingAmount: 0,
TradingFee: 0,
@@ -1126,6 +1143,7 @@ var vue = new Vue({
thisObj.swapflow.SwapTradeNo = item.SwapTradeNo;
thisObj.swapflow.BsType = item.BsType;
thisObj.swapflow.UnderlyingCode = item.UnderlyingCode;
thisObj.swapflow.UnderlyingInstrumentType = item.UnderlyingInstrumentType || item.InstrumentType || "";
thisObj.swapflow.TradingQty = item.TradingQty;
thisObj.swapflow.TradingAmount = item.TradingAmount;
thisObj.swapflow.TradingFee = item.TradingFee;
@@ -1155,6 +1173,7 @@ var vue = new Vue({
setUnderlyingCode(data) {
this.swapflow.ContractSize = data.ContractSize;
this.swapflow.UnderlyingName = data.Name;
this.swapflow.UnderlyingInstrumentType = data.InstrumentType || data.UnderlyingInstrumentType || "";
},
changeClient: function (client) {
this.swapflow.ClientId = client.id;
@@ -1,12 +1,14 @@
//otcformat禁止千分位分组
window.otcformat.options.disableGrouping = true;
const inputFormatSwapRate = Object.freeze({ precision: otcformat.trading.premiumRateP.precision, append: '%' });
const inputFormatSwapRate = swapPricePrecision.getCommonInputFormat('rate', { append: '%' });
const inputFormatTradePrice = Object.freeze({ precision: otcformat.trading.tradePrice.precision, append: '' });
const inputFormatTradeAmount = Object.freeze({ precision: otcformat.trading.notional.precision, append: '' });
const inputFormatEqvNotional = Object.freeze({ precision: 2, append: '', negative: true, trimTailZeros: false });
const inputFormatInterestAmount = Object.freeze({ precision: 2, append: '', negative: true, trimTailZeros: false });
const inputFormatDividend = Object.freeze({ precision: 2, append: '', negative: true });
const inputFormatEqvNotional = swapPricePrecision.getCommonInputFormat('amount', { append: '', negative: true });
const inputFormatInterestAmount = swapPricePrecision.getCommonInputFormat('amount', { append: '', negative: true });
const inputFormatDividend = swapPricePrecision.getCommonInputFormat('amount', { append: '', negative: true });
const swapInstrumentType = (model.FlowEvents || []).find(item => item && item.UnderlyingInstrumentType)?.UnderlyingInstrumentType || model.UnderlyingInstrumentType || '';
const formatSwapAmount = value => swapPricePrecision.normalizeCommon('amount', value);
const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity', value, swapInstrumentType);
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 });
@@ -60,6 +62,12 @@ const vue = new Vue({
});
},
methods: {
formatAmount(value) {
return swapPricePrecision.formatCommon('amount', value);
},
formatQuantity(value) {
return swapPricePrecision.formatCommon('quantity', value, swapInstrumentType);
},
isAfterMaxIncomeValueDate(valueDate) {
return valueDate && MaxIncomeValueDate && valueDate > MaxIncomeValueDate;
},
@@ -120,31 +128,31 @@ const vue = new Vue({
return pricef;
},
dataFormat() {
this.deal.NotionalValue = otcformat.fixed2(parseFloat(this.deal.NotionalValue));
this.deal.PosiNotionalValue = otcformat.fixed2(parseFloat(this.deal.PosiNotionalValue));
this.deal.NotionalQty = otcformat.trading.notional(this.deal.NotionalQty);
this.deal.PositionQty2 = otcformat.trading.notional(this.deal.PositionQty);
this.floatPosition.Quantity = otcformat.trading.notional(this.floatPosition.Quantity);
this.floatPosition.PositionQty = otcformat.trading.notional(this.floatPosition.PositionQty);
this.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(this.deal.SwapCloseAmount);
this.deal.NotionalValue = formatSwapAmount(this.deal.NotionalValue);
this.deal.PosiNotionalValue = formatSwapAmount(this.deal.PosiNotionalValue);
this.deal.NotionalQty = formatSwapQuantity(this.deal.NotionalQty);
this.deal.PositionQty2 = formatSwapQuantity(this.deal.PositionQty);
this.floatPosition.Quantity = formatSwapQuantity(this.floatPosition.Quantity);
this.floatPosition.PositionQty = formatSwapQuantity(this.floatPosition.PositionQty);
this.deal.SwapCloseAmount = formatSwapAmount(this.deal.SwapCloseAmount);
//this.floatPosition.PosiNetPrice = otcformat.trading.umprice(this.floatPosition.PosiNetPrice);
//this.floatPosition.PosiGrossPrice = otcformat.trading.umprice(this.floatPosition.PosiGrossPrice);
this.floatPosition.TradingFee = otcformat.trading.StockEqvNotional(this.floatPosition.TradingFee);
this.floatPosition.DividendIn = parseFloat(this.floatPosition.DividendIn).toFixed(2);
this.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(this.floatPosition.MarkClosePnl);
this.floatPosition.TradingFee = formatSwapAmount(this.floatPosition.TradingFee);
this.floatPosition.DividendIn = formatSwapAmount(this.floatPosition.DividendIn);
this.floatPosition.MarkClosePnl = formatSwapAmount(this.floatPosition.MarkClosePnl);
this.interestList.forEach(x => {
//x.Principal = otcformat.trading.StockEqvNotional(x.Principal);
//x.Principal = formatSwapAmount(x.Principal);
//x.Rate = otcformat.fixed6(x.Rate);
x.InterestFee = otcformat.trading.StockEqvNotional(x.InterestFee);
x.InterestAmount = otcformat.trading.StockEqvNotional(x.InterestAmount);
x.InterestClosePnL = otcformat.trading.StockEqvNotional(x.InterestClosePnL);
x.InterestFee = formatSwapAmount(x.InterestFee);
x.InterestAmount = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
//x.InterestStartDate = x.InterestStartDate ? x.InterestStartDate.substr(0, 10) : "";
//x.InterestEndDate = x.InterestEndDate ? x.InterestEndDate.substr(0, 10) : "";
});
this.marginList.forEach(x => {
x.InterestFee = otcformat.trading.StockEqvNotional(x.InterestFee);
x.InterestAmount = otcformat.trading.StockEqvNotional(x.InterestAmount);
x.InterestClosePnL = otcformat.trading.StockEqvNotional(x.InterestClosePnL);
x.InterestFee = formatSwapAmount(x.InterestFee);
x.InterestAmount = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
});
},
setValueDate(e) {//修改平仓日期
@@ -183,7 +191,7 @@ const vue = new Vue({
changeInterestAmount(item) {//修改利息金额
let InterestFee = item.InterestFee == "" ? 0 : parseFloat(item.InterestFee);
let interestRatio = item.InterestDirection == 1 ? 1 : -1;
item.InterestClosePnL = otcformat.trading.StockEqvNotional(parseFloat(item.InterestAmount) * interestRatio + InterestFee * interestRatio);
item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + InterestFee * interestRatio);
this.calcCloseAmount();
},
calcFloatClosePnl() {//计算浮动端平仓盈亏
@@ -197,7 +205,7 @@ const vue = new Vue({
// CloseNotionalValue 是期初全价折算后的名义本金,直接乘价差会重复包含期初价格。
let positionAmount = parseFloat(thisObj.floatPosition.Quantity) * parseFloat(thisObj.floatPosition.ContractSize || 1);
thisObj.floatPosition.MarkClosePnl = positionAmount * (deliveryPrice - thisObj.initPosiGrossPrice) * floatRatio;
thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红
thisObj.floatPosition.MarkClosePnl = formatSwapAmount(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红
// 守卫: 浮动盈亏合计必须保留 2 位小数 → 对应历史 bug 3c5f25a5(原代码缺精度保留)
// 数值由 swapCalc.calcFloatPnlSum 计算, 此处 .toFixed(2) 仅保留字符串类型以兼容下游
thisObj.floatPosition.FloatPnlSum = SwapCalc.calcFloatPnlSum(thisObj.floatPosition.MarkClosePnl, TradingFee, TradingFeePending, DividendIn).toFixed(2);
@@ -205,7 +213,7 @@ const vue = new Vue({
},
//calcClosePnL() {//计算浮动端平仓盈亏
// let pnl = parseFloat(this.floatPosition.ClosePnL) - parseFloat(this.floatPosition.TradingFee);
// this.floatPosition.ClosePnL = otcformat.trading.StockEqvNotional(pnl);
// this.floatPosition.ClosePnL = formatSwapAmount(pnl);
// this.calcCloseAmount();
//},
calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付
@@ -237,9 +245,9 @@ const vue = new Vue({
thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount;
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;
});
thisObj.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(thisObj.deal.SwapCloseAmount);
thisObj.deal.SwapRealizedPnL = otcformat.trading.StockEqvNotional(thisObj.deal.SwapRealizedPnL);
thisObj.deal.SwapMarginRebatePnl = otcformat.trading.StockEqvNotional(thisObj.deal.SwapMarginRebatePnl);
thisObj.deal.SwapCloseAmount = formatSwapAmount(thisObj.deal.SwapCloseAmount);
thisObj.deal.SwapRealizedPnL = formatSwapAmount(thisObj.deal.SwapRealizedPnL);
thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl);
},
getInterestList() {//根据平仓日期获取利息腿信息
var thisObj = this;
@@ -258,18 +266,14 @@ const vue = new Vue({
},
getDivindIn() {
var thisObj = this;
let ratio = this.floatPosition.PositionType == 1 ? 1 : -1;
let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1;
var postData = { startDate: thisObj.TradeStartDate, endDate: thisObj.deal.UnwindDate, underlyingCode: thisObj.floatPosition.UnderlyingCode, tradeId: thisObj.deal.SwapTradeId, unwindDate: thisObj.deal.UnwindDate }
main.post("/BondPayment/GetBondPayMentInterest", postData, { async: false }).done(function (resp) {
let totalDividend = parseFloat(thisObj.deal.NotionalQty) * resp.obj.totalInterest * ratio * floatRatio;//总的
let consumedDividend = Math.abs(parseFloat(resp.obj.consumedDividend ?? 0)) * ratio * floatRatio;//已实现的
// 互换是全量消费,consumedDividend>0 表示分红已被当天互换消费,归0
thisObj.floatPosition.DividendIn = Math.abs(consumedDividend) > 0 ? parseFloat((totalDividend - consumedDividend).toFixed(2)) : parseFloat(totalDividend.toFixed(2));
thisObj.floatPosition.DividendPending = 0;
thisObj.calcFloatClosePnl();
thisObj.dataFormat();
});
// 方案C:分红改由后端 InitIncome 读 EOD PosiDividendSum 填入 floatPosition.DividendIn(单一可信源)。
// 前端不再调用 GetBondPayMentInterest 自算——消除"期初持仓×totalInterest"对已平仓部分的重复计入。
// floatPosition.DividendIn 保持后端返回值不动(=本次互换要落袋的全量待实现)。
// DividendPending(待结算)互换页保持 0:互换=全量结清,结清后账上无待结算。
// ↳ 与平仓页不同:平仓只拿走一部分,剩余持仓仍有待结算 → 平仓页 DividendPending=PosiDividendSum(后端值)。
thisObj.floatPosition.DividendPending = 0;
thisObj.calcFloatClosePnl();
thisObj.dataFormat();
},
incomeTrade() {//互换
var thisObj = this;
@@ -94,6 +94,7 @@
function calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty) {
var ori = Number(oriClosePercent);
if (ori === 0) return 0;
if (Number(closePercent) >= ori) return Number(positionQty);
return roundHalfAwayFromZero(Number(positionQty) * (Number(closePercent) / ori), 2);
}
@@ -1,6 +1,9 @@
//otcformat禁止千分位分组
window.otcformat.options.disableGrouping = true;
const inputFormatEqvNotional = Object.freeze({ precision: 2, append: '', trimTailZeros: false });
const inputFormatEqvNotional = swapPricePrecision.getCommonInputFormat('amount', { append: '' });
const swapInstrumentType = (model.FlowEvents || []).find(item => item && item.UnderlyingInstrumentType)?.UnderlyingInstrumentType || model.UnderlyingInstrumentType || '';
const formatSwapAmount = value => swapPricePrecision.normalizeCommon('amount', value);
const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity', value, swapInstrumentType);
let ValueDate = model.ValueDate;
const vue = new Vue({
el: '#vueDiv',
@@ -22,6 +25,12 @@ const vue = new Vue({
this.setValueDate();
},
methods: {
formatAmount(value) {
return swapPricePrecision.formatCommon('amount', value);
},
formatQuantity(value) {
return swapPricePrecision.formatCommon('quantity', value, swapInstrumentType);
},
initDeal() {
this.interestList = model.FlowEvents.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
@@ -42,29 +51,29 @@ const vue = new Vue({
}
},
dataFormat() {
this.deal.NotionalValue = otcformat.fixed2(parseFloat(this.deal.NotionalValue));
this.deal.PosiNotionalValue = otcformat.fixed2(parseFloat(this.deal.PosiNotionalValue));
this.deal.NotionalQty = otcformat.trading.notional(this.deal.NotionalQty);
this.deal.PositionQty = otcformat.trading.notional(this.deal.PositionQty);
this.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(this.deal.SwapCloseAmount);
this.deal.NotionalValue = formatSwapAmount(this.deal.NotionalValue);
this.deal.PosiNotionalValue = formatSwapAmount(this.deal.PosiNotionalValue);
this.deal.NotionalQty = formatSwapQuantity(this.deal.NotionalQty);
this.deal.PositionQty = formatSwapQuantity(this.deal.PositionQty);
this.deal.SwapCloseAmount = formatSwapAmount(this.deal.SwapCloseAmount);
this.interestList.forEach(x => {
//x.Principal = otcformat.trading.StockEqvNotional(x.Principal);
//x.Principal = formatSwapAmount(x.Principal);
//x.Rate = otcformat.fixed6(x.Rate);
x.InterestFee = otcformat.trading.StockEqvNotional(x.InterestFee);
x.InterestAmount = otcformat.trading.StockEqvNotional(x.InterestAmount);
x.InterestClosePnL = otcformat.trading.StockEqvNotional(x.InterestClosePnL);
x.InterestFee = formatSwapAmount(x.InterestFee);
x.InterestAmount = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
//x.InterestStartDate = x.InterestStartDate ? x.InterestStartDate.substr(0, 10) : "";
//x.InterestEndDate = x.InterestEndDate ? x.InterestEndDate.substr(0, 10) : "";
});
this.marginList.forEach(x => {
x.InterestFee = otcformat.trading.StockEqvNotional(x.InterestFee);
x.InterestAmount = otcformat.trading.StockEqvNotional(x.InterestAmount);
x.InterestClosePnL = otcformat.trading.StockEqvNotional(x.InterestClosePnL);
x.InterestFee = formatSwapAmount(x.InterestFee);
x.InterestAmount = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
});
},
changeInterestAmount(item) {//修改利息金额
let interestRatio = item.InterestDirection == 1 ? 1 : -1;
item.InterestClosePnL = otcformat.trading.StockEqvNotional(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee));
item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee));
this.calcCloseAmount();
},
calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付
@@ -86,10 +95,10 @@ const vue = new Vue({
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;
thisObj.deal.SwapMarginAmount = parseFloat(thisObj.deal.SwapMarginAmount) + parseFloat(x.InterestPrincipal);
});
thisObj.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(thisObj.deal.SwapCloseAmount);
thisObj.deal.SwapRealizedPnL = otcformat.trading.StockEqvNotional(thisObj.deal.SwapRealizedPnL);
thisObj.deal.SwapMarginRebatePnl = otcformat.trading.StockEqvNotional(thisObj.deal.SwapMarginRebatePnl);
thisObj.deal.SwapMarginAmount = otcformat.trading.StockEqvNotional(thisObj.deal.SwapMarginAmount);
thisObj.deal.SwapCloseAmount = formatSwapAmount(thisObj.deal.SwapCloseAmount);
thisObj.deal.SwapRealizedPnL = formatSwapAmount(thisObj.deal.SwapRealizedPnL);
thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl);
thisObj.deal.SwapMarginAmount = formatSwapAmount(thisObj.deal.SwapMarginAmount);
},
getInterestList() {//根据平仓日期获取利息腿信息
var thisObj = this;
@@ -1,47 +1,57 @@
var swapPricePrecision = (function (global) {
const defaults = Object.freeze({
Stock: { integerDigits: 7, precision: 2 },
StockIndex: { integerDigits: 7, precision: 2 },
StockIF: { integerDigits: 7, precision: 4 },
CommodityFutures: { integerDigits: 7, precision: 4 },
CommoditySpot: { integerDigits: 7, precision: 4 },
NewOtcStock: { integerDigits: 7, precision: 4 },
HKStock: { integerDigits: 7, precision: 4 },
HKStockIndex: { integerDigits: 7, precision: 4 },
Fund: { integerDigits: 7, precision: 4 },
common: {
amount: { precision: 2, grouping: true },
quantity: { integerDigits: 16, precision: 2, grouping: true },
rate: { precision: 4 }
},
Stock: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
StockIndex: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
StockIF: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
CommodityFutures: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
CommoditySpot: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
NewOtcStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
HKStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
HKStockIndex: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
Fund: { integerDigits: 7, precision: 4, quantityPrecision: 4, quantityIntegerDigits: 12 },
Bond: {
quantityPrecision: 0, quantityIntegerDigits: 16,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
CreditBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
OtherBonds: {
quantityPrecision: 0, quantityIntegerDigits: 16,
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBFutures: { integerDigits: 8, precision: 4 },
OtherFutures: { integerDigits: 8, precision: 4 },
GoldSpot: { integerDigits: 8, precision: 4 },
OtherSpot: { integerDigits: 8, precision: 4 },
AbroadFutures: { integerDigits: 8, precision: 4 },
AbroadSpot: { integerDigits: 8, precision: 4 },
AbroadStock: { integerDigits: 8, precision: 2 },
AbroadStockIndex: { integerDigits: 8, precision: 4 },
ExRate: { integerDigits: 2, precision: 8 },
Shibor: { integerDigits: 2, precision: 4 },
FixingRepoRate: { integerDigits: 2, precision: 4 },
RateYield: {integerDigits: 6, precision: 8},
BondIndex: {integerDigits: 6, precision: 4},
TBFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
OtherFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
GoldFutures: { quantityIntegerDigits: 12 },
GoldSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
OtherSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadStock: { integerDigits: 8, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 },
AbroadStockIndex: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
ExRate: { integerDigits: 2, precision: 8, quantityPrecision: 8, quantityIntegerDigits: 16 },
Shibor: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
FixingRepoRate: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 },
RateYield: {integerDigits: 6, precision: 8, quantityPrecision: 2, quantityIntegerDigits: 12},
BondIndex: {integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12},
});
@@ -121,6 +131,18 @@ var swapPricePrecision = (function (global) {
return { integerDigits: integerDigits, precision: precision };
}
function normalizeCommonRule(rule) {
if (!rule || typeof rule !== 'object') return null;
const precision = Number(rule.precision);
if (!Number.isInteger(precision) || precision < 0 || precision > 13) return null;
const integerDigits = Number(rule.integerDigits);
return {
precision: precision,
integerDigits: Number.isInteger(integerDigits) && integerDigits >= 1 && integerDigits <= 18 ? integerDigits : undefined,
grouping: typeof rule.grouping === 'boolean' ? rule.grouping : undefined
};
}
function findRule(source, instrumentType, field) {
const typeRule = source && source[instrumentType];
return typeRule ? normalizeRule(typeRule[field] || typeRule) : null;
@@ -132,6 +154,73 @@ var swapPricePrecision = (function (global) {
return findRule(global.main && global.main.swapPricePrecision, instrumentType, field) || fallback;
}
function formatFixed(value, precision) {
const normalized = normalizeDecimal(roundDecimal(value, precision));
if (!normalized) return '';
const negative = normalized.charAt(0) === '-';
const parts = (negative ? normalized.substring(1) : normalized).split('.');
const integerPart = parts[0];
if (precision === 0) return (negative ? '-' : '') + integerPart;
return (negative ? '-' : '') + integerPart + '.' + (parts[1] || '').padEnd(precision, '0');
}
function groupDecimal(value) {
if (!value) return value;
const negative = value.charAt(0) === '-';
const source = negative ? value.substring(1) : value;
const parts = source.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return (negative ? '-' : '') + parts.join('.');
}
function getQuantityPrecision(instrumentType, fallback) {
if (!instrumentType) return fallback;
const configured = global.main && global.main.swapPricePrecision;
const typeRule = (configured || defaults)[instrumentType];
if (!typeRule || !Object.prototype.hasOwnProperty.call(typeRule, 'quantityPrecision')) return fallback;
const precision = Number(typeRule.quantityPrecision);
return Number.isInteger(precision) && precision >= 0 && precision <= 13 ? precision : fallback;
}
function getQuantityIntegerDigits(instrumentType, fallback) {
if (!instrumentType) return fallback;
const configured = global.main && global.main.swapPricePrecision;
const typeRule = (configured || defaults)[instrumentType];
if (!typeRule || !Object.prototype.hasOwnProperty.call(typeRule, 'quantityIntegerDigits')) return fallback;
const integerDigits = Number(typeRule.quantityIntegerDigits);
return Number.isInteger(integerDigits) && integerDigits >= 1 && integerDigits <= 18 ? integerDigits : fallback;
}
function getCommonRule(kind, instrumentType) {
const fallback = normalizeCommonRule(defaults.common[kind]) || { precision: 2, grouping: false };
const configured = global.main && global.main.swapPricePrecision
&& global.main.swapPricePrecision.common;
const configuredRule = normalizeCommonRule(configured && configured[kind]);
const rule = configuredRule || fallback;
if (rule.grouping === undefined) rule.grouping = fallback.grouping;
if (rule.integerDigits === undefined) rule.integerDigits = fallback.integerDigits;
if (kind === 'quantity') {
rule.precision = getQuantityPrecision(instrumentType, rule.precision);
rule.integerDigits = getQuantityIntegerDigits(instrumentType, rule.integerDigits);
}
return rule;
}
function formatCommon(kind, value, instrumentType, options) {
if (value === null || value === undefined || value === '') return '';
if (instrumentType && typeof instrumentType === 'object') {
options = instrumentType;
instrumentType = options.instrumentType;
}
const rule = getCommonRule(kind, instrumentType);
const displayValue = kind === 'rate' ? shiftDecimal(value, 2) : value;
const formatted = formatFixed(displayValue, rule.precision);
if (!formatted) return '';
const grouping = options && options.grouping !== undefined ? !!options.grouping : rule.grouping;
const text = grouping ? groupDecimal(formatted) : formatted;
return kind === 'rate' ? text + '%' : text;
}
function format(value, instrumentType, field) {
const rule = getRule(instrumentType, field);
if (value === null || value === undefined || value === '') return '';
@@ -260,6 +349,26 @@ var swapPricePrecision = (function (global) {
return Object.freeze({
getRule: getRule,
getCommonPrecision: function (kind, instrumentType) {
return getCommonRule(kind, instrumentType).precision;
},
getCommonInputFormat: function (kind, options, instrumentType) {
const inputOptions = Object.assign({}, options || {});
const type = instrumentType || inputOptions.instrumentType;
delete inputOptions.instrumentType;
const rule = getCommonRule(kind, type);
const result = Object.assign(inputOptions, {
precision: rule.precision,
grouping: inputOptions.grouping === undefined ? !!rule.grouping : !!inputOptions.grouping,
trimTailZeros: false
});
if (rule.integerDigits !== undefined) result.integerDigits = rule.integerDigits;
return result;
},
formatCommon: formatCommon,
normalizeCommon: function (kind, value, instrumentType) {
return formatCommon(kind, value, instrumentType, { grouping: false });
},
getInputFormat: function (instrumentType, field, options) {
const rule = getRule(instrumentType, field);
return rule ? Object.assign({}, options, rule) : Object.assign({}, options);
@@ -10,13 +10,11 @@ const consAssetUnits = page.canAddNewTrader ? ylotc.assetunits
: ylotc.assetunits.filter(x => x.TraderIds.includes(page.Trade.TraderId));
const inputFormatInteger = Object.freeze({ precision: 0, append: '' });
const inputFormatEqvNotional = Object.freeze({ precision: 2, append: '', trimTailZeros: false });
const inputFormatTradeAmount = Object.freeze({ precision: otcformat.trading.notional.precision, append: '' });
const inputFormatPositionQuantityFixed2 = Object.freeze({ precision: 2, append: '', trimTailZeros: false });
const inputFormatSwapRate = Object.freeze({ precision: 4, negative: true, append: '%', trimTailZeros: false });
const inputFormatEqvNotional = swapPricePrecision.getCommonInputFormat('amount', { append: '' });
const inputFormatSwapRate = swapPricePrecision.getCommonInputFormat('rate', { negative: true, append: '%' });
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 inputFormatTradeSinglePriceFixed2 = Object.freeze({ precision: 2, negative: true, append: '', percent: false, trimTailZeros: false });
const inputFormatTradeSinglePrice = swapPricePrecision.getCommonInputFormat('amount', { negative: true, append: '', percent: false });
const inputFormatTradeSinglePriceFixed2 = swapPricePrecision.getCommonInputFormat('amount', { negative: true, append: '', percent: false });
const inputFormatPosiFeePercent = Object.freeze({ precision: 4, negative: true, append: '' });
const inputFormatPosiFeeUnit = Object.freeze({ precision: 6, negative: true, append: '' });
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
@@ -37,7 +35,7 @@ const observationRateTextFromPercent = function (value) {
const formatObservationRate = function (value) {
if (value === null || value === undefined || value === '') return '';
const number = Number(value);
return Number.isFinite(number) ? number.toFixed(4) : value;
return Number.isFinite(number) ? number.toFixed(swapPricePrecision.getCommonPrecision('rate')) : value;
};
const swapPosiFeeCalc = Object.freeze({
normalizeFeeType(feeType) {
@@ -270,6 +268,12 @@ const vue = new Vue({
});
},
methods: {
getQuantityInputFormat(item) {
return swapPricePrecision.getCommonInputFormat(
'quantity',
{ append: '' },
item && item.UnderlyingInstrumentType);
},
getPosiPriceInputFormat(item, field) {
return swapPricePrecision.getInputFormat(
item && item.UnderlyingInstrumentType,
@@ -563,7 +567,7 @@ const vue = new Vue({
},
//变更名义本金(仅格式化,不反算数量)
changeStockEqvNotional() {
this.trade.StockEqvNotional = otcformat.trading.StockEqvNotional(this.trade.StockEqvNotional);
this.trade.StockEqvNotional = swapPricePrecision.normalizeCommon('amount', this.trade.StockEqvNotional);
this.refreshPayTradingFeesByUnit();
//计算数量
// if (this.paySwapList.length > 0) {
@@ -651,7 +655,7 @@ const vue = new Vue({
// 守卫: 名义本金必须 round 到 2 位 → 对应历史 bug f873239a(缺 _.round); 外置到 swapCalc.calcStockEqvNotional
var deliveryPrice = this.roundStoragePrice(payItem, payItem.PosiGrossPrice, 'grossPrice');
var stockEqvNotional = SwapCalc.calcStockEqvNotional(deliveryPrice, national);//名义本金=期初价格*数量*乘数
this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional);
this.trade.StockEqvNotional = swapPricePrecision.normalizeCommon('amount', stockEqvNotional);
payItem.PosiNotionalValue = this.trade.StockEqvNotional;
this.refreshPayTradingFeesByUnit();
}
@@ -9,6 +9,15 @@ function formatSwapPriceElements() {
});
}
function formatSwapCommonElements() {
$('.js-swap-common').each(function () {
this.textContent = swapPricePrecision.formatCommon(
this.dataset.kind,
this.dataset.value,
this.dataset.instrumentType);
});
}
function deletetrade(id) { //无效化
main.confirm(page.ConfirmInfo, function () {
$.ajax({
@@ -502,7 +511,7 @@ var vueDetails = new Vue({
if (swapIntervals && swapIntervals.length>0) {
that.SwapIntervalList = JSON.parse(swapIntervals);
that.SwapIntervalList.forEach((item, index) => {
that.SwapIntervalList[index].Rate = otcformat.fixed4P(item.Rate);
that.SwapIntervalList[index].Rate = swapPricePrecision.formatCommon('rate', item.Rate);
// 如果结算日期为空,默认等于观察日期
if (!item.SettlementDate) {
that.SwapIntervalList[index].SettlementDate = item.Date;
@@ -553,6 +562,7 @@ function chk_onclick(obj) {
$(function () {
formatSwapPriceElements();
formatSwapCommonElements();
refreshEntryExit();
});
@@ -1,6 +1,9 @@
//otcformat禁止千分位分组
window.otcformat.options.disableGrouping = true;
const inputFormatEqvNotional = Object.freeze({ precision: 2, append: '', trimTailZeros: false });
const inputFormatEqvNotional = swapPricePrecision.getCommonInputFormat('amount', { append: '' });
const swapInstrumentType = (model.FlowEvents || []).find(item => item && item.UnderlyingInstrumentType)?.UnderlyingInstrumentType || model.UnderlyingInstrumentType || '';
const formatSwapAmount = value => swapPricePrecision.normalizeCommon('amount', value);
const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity', value, swapInstrumentType);
let dealDate = model.DealDate;
const vue = new Vue({
el: '#vueDiv',
@@ -21,6 +24,12 @@ const vue = new Vue({
this.dataFormat();
},
methods: {
formatAmount(value) {
return swapPricePrecision.formatCommon('amount', value);
},
formatQuantity(value) {
return swapPricePrecision.formatCommon('quantity', value, swapInstrumentType);
},
initDeal() {
this.interestList = model.FlowEvents.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
@@ -30,27 +39,27 @@ const vue = new Vue({
});
},
dataFormat() {
this.deal.NotionalValue = otcformat.fixed2(parseFloat(this.deal.NotionalValue));
this.deal.PosiNotionalValue = otcformat.fixed2(parseFloat(this.deal.PosiNotionalValue));
this.deal.NotionalQty = otcformat.trading.notional(this.deal.NotionalQty);
this.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(this.deal.SwapCloseAmount);
this.deal.PositionQty = otcformat.trading.notional(this.deal.PositionQty);
this.deal.NotionalValue = formatSwapAmount(this.deal.NotionalValue);
this.deal.PosiNotionalValue = formatSwapAmount(this.deal.PosiNotionalValue);
this.deal.NotionalQty = formatSwapQuantity(this.deal.NotionalQty);
this.deal.SwapCloseAmount = formatSwapAmount(this.deal.SwapCloseAmount);
this.deal.PositionQty = formatSwapQuantity(this.deal.PositionQty);
this.interestList.forEach(x => {
//x.Principal = otcformat.trading.StockEqvNotional(x.Principal);
//x.Principal = formatSwapAmount(x.Principal);
//x.Rate = otcformat.fixed6(x.Rate);
x.InterestAmount = otcformat.trading.StockEqvNotional(x.InterestAmount);
x.InterestClosePnL = otcformat.trading.StockEqvNotional(x.InterestClosePnL);
x.InterestAmount = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
//x.InterestStartDate = x.InterestStartDate ? x.InterestStartDate.substr(0, 10) : "";
//x.InterestEndDate = x.InterestEndDate ? x.InterestEndDate.substr(0, 10) : "";
});
this.marginList.forEach(x => {
x.InterestAmount = otcformat.trading.StockEqvNotional(x.InterestAmount);
x.InterestClosePnL = otcformat.trading.StockEqvNotional(x.InterestClosePnL);
x.InterestAmount = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
});
},
changeInterestAmount(item) {//修改利息金额
let interestRatio = item.InterestDirection == 1 ? 1 : -1;
item.InterestClosePnL = otcformat.trading.StockEqvNotional(parseFloat(item.InterestAmount) * interestRatio+ parseFloat(item.InterestFee));
item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio+ parseFloat(item.InterestFee));
this.calcCloseAmount();
},
calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付
@@ -73,10 +82,10 @@ const vue = new Vue({
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;
thisObj.deal.SwapMarginAmount = parseFloat(thisObj.deal.SwapMarginAmount) + parseFloat(x.InterestPrincipal) * interestRatio;
});
thisObj.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(thisObj.deal.SwapCloseAmount);
thisObj.deal.SwapRealizedPnL = otcformat.trading.StockEqvNotional(thisObj.deal.SwapRealizedPnL);
thisObj.deal.SwapMarginRebatePnl = otcformat.trading.StockEqvNotional(thisObj.deal.SwapMarginRebatePnl);
thisObj.deal.SwapMarginAmount = otcformat.trading.StockEqvNotional(thisObj.deal.SwapMarginAmount);
thisObj.deal.SwapCloseAmount = formatSwapAmount(thisObj.deal.SwapCloseAmount);
thisObj.deal.SwapRealizedPnL = formatSwapAmount(thisObj.deal.SwapRealizedPnL);
thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl);
thisObj.deal.SwapMarginAmount = formatSwapAmount(thisObj.deal.SwapMarginAmount);
},
closeTrade() {//平仓
var thisObj = this;
@@ -3,9 +3,11 @@ window.otcformat.options.disableGrouping = true;
const inputFormatSwapRate = Object.freeze({ precision: 4, append: '%', trimTailZeros: false });
const inputFormatTradePrice = Object.freeze({ precision: otcformat.trading.tradePrice.precision, append: '' });
const inputFormatTradeAmount = Object.freeze({ precision: otcformat.trading.notional.precision, append: '' });
const inputFormatEqvNotional = Object.freeze({ precision: 2, append: '', negative: true, trimTailZeros: false });
const inputFormatCloseAmount = Object.freeze({ precision: 2, append: '', negative: true, trimTailZeros: false });
const inputFormatEqvNotional = swapPricePrecision.getCommonInputFormat('amount', { append: '', negative: true });
const inputFormatCloseAmount = swapPricePrecision.getCommonInputFormat('amount', { append: '', negative: true });
const swapInstrumentType = (model.FlowEvents || []).find(item => item && item.UnderlyingInstrumentType)?.UnderlyingInstrumentType || model.UnderlyingInstrumentType || '';
const formatSwapAmount = value => swapPricePrecision.normalizeCommon('amount', value);
const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity', value, swapInstrumentType);
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 });
@@ -22,7 +24,7 @@ const swapPosiFeeCalc = {
const tradingFee = normalizedFeeType === consPosiFeeType.Unit
? normalizedFeeUnit * normalizedCloseQty
: normalizedFeeUnit / 100 * normalizedCloseNotionalValue;
return otcformat.trading.StockEqvNotional(_.round(tradingFee, 2));
return formatSwapAmount(_.round(tradingFee, 2));
},
calcAllocatedTradingFee(totalFee, feeType, feeUnit, closeNotionalValue, closeQty, notionalValue, notionalQty) {
const normalizedFeeUnit = Number(feeUnit) || 0;
@@ -37,7 +39,7 @@ const swapPosiFeeCalc = {
return null;
}
return otcformat.trading.StockEqvNotional(_.round((Number(totalFee) || 0) * closeBase / originalBase, 2));
return formatSwapAmount(_.round((Number(totalFee) || 0) * closeBase / originalBase, 2));
},
calcTradingFeePending(beforeCloseFee, feeType, feeUnit, closeNotionalValue, closeQty, notionalValue, notionalQty, closePercent) {
const allocatedFee = this.calcAllocatedTradingFee(
@@ -79,6 +81,15 @@ const vue = new Vue({
this.setUnwindDate();
},
methods: {
formatAmount(value) {
return swapPricePrecision.formatCommon('amount', value);
},
formatQuantity(value) {
return swapPricePrecision.formatCommon('quantity', value, swapInstrumentType);
},
getQuantityInputFormat() {
return swapPricePrecision.getCommonInputFormat('quantity', { append: '' }, swapInstrumentType);
},
getDeliveryPriceInputFormat() {
return swapPricePrecision.getInputFormat(
this.floatPosition && this.floatPosition.UnderlyingInstrumentType,
@@ -124,37 +135,37 @@ const vue = new Vue({
return pricef;
},
dataFormat() {
this.deal.NotionalValue = otcformat.fixed2(parseFloat(this.deal.NotionalValue));
this.deal.PosiNotionalValue = otcformat.fixed2(parseFloat(this.deal.PosiNotionalValue));
this.deal.CloseNotionalValue = otcformat.fixed2(parseFloat(this.deal.CloseNotionalValue));
this.deal.NotionalQty = otcformat.trading.notional(this.deal.NotionalQty);
this.deal.PositionQty2 = otcformat.trading.notional(this.deal.PositionQty);
this.floatPosition.Quantity = otcformat.trading.notional(this.floatPosition.Quantity);
this.floatPosition.PositionQty = otcformat.trading.notional(this.floatPosition.PositionQty);
this.deal.CloseQty = otcformat.trading.notional(this.deal.CloseQty);
this.deal.NotionalValue = formatSwapAmount(this.deal.NotionalValue);
this.deal.PosiNotionalValue = formatSwapAmount(this.deal.PosiNotionalValue);
this.deal.CloseNotionalValue = formatSwapAmount(this.deal.CloseNotionalValue);
this.deal.NotionalQty = formatSwapQuantity(this.deal.NotionalQty);
this.deal.PositionQty2 = formatSwapQuantity(this.deal.PositionQty);
this.floatPosition.Quantity = formatSwapQuantity(this.floatPosition.Quantity);
this.floatPosition.PositionQty = formatSwapQuantity(this.floatPosition.PositionQty);
this.deal.CloseQty = formatSwapQuantity(this.deal.CloseQty);
this.deal.ClosePercent = otcformat.fixed6(this.deal.ClosePercent);
this.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(this.deal.SwapCloseAmount);
this.deal.SwapCloseAmount = formatSwapAmount(this.deal.SwapCloseAmount);
//this.floatPosition.PosiNetPrice = otcformat.trading.tradeSinglePrice(this.floatPosition.PosiNetPrice);
//this.floatPosition.PosiGrossPrice = otcformat.trading.tradeSinglePrice(this.floatPosition.PosiGrossPrice);
this.floatPosition.TradingAmountAvg = swapPricePrecision.roundForSubmit(
this.floatPosition.TradingAmountAvg,
this.floatPosition.UnderlyingInstrumentType,
'grossPrice');
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);
this.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(this.floatPosition.MarkClosePnl);
this.floatPosition.TradingFee = formatSwapAmount(this.floatPosition.TradingFee);
this.floatPosition.TradingFeePending = formatSwapAmount(this.floatPosition.TradingFeePending);
this.floatPosition.DividendIn = formatSwapAmount(this.floatPosition.DividendIn);
this.floatPosition.MarkClosePnl = formatSwapAmount(this.floatPosition.MarkClosePnl);
this.interestList.forEach(x => {
//x.Principal = otcformat.trading.StockEqvNotional(x.Principal);
//x.Principal = formatSwapAmount(x.Principal);
//x.Rate = otcformat.fixed6(x.Rate);
x.InterestAmount = otcformat.trading.StockEqvNotional(x.InterestAmount);
x.InterestClosePnL = otcformat.trading.StockEqvNotional(x.InterestClosePnL);
x.InterestAmount = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
//x.InterestStartDate = x.InterestStartDate ? x.InterestStartDate.substr(0, 10) : "";
//x.InterestEndDate = x.InterestEndDate ? x.InterestEndDate.substr(0, 10) : "";
});
this.marginList.forEach(x => {
x.InterestAmount = otcformat.trading.StockEqvNotional(x.InterestAmount);
x.InterestClosePnL = otcformat.trading.StockEqvNotional(x.InterestClosePnL);
x.InterestAmount = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
});
},
setValueDate(e) {//修改平仓日期
@@ -183,7 +194,7 @@ const vue = new Vue({
changeCloseMethod() {//修改平仓类型
if (this.deal.CloseMethod == 1) {
this.deal.ClosePercent = this.oriClosePercent;
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.PosiNotionalValue));
this.deal.CloseNotionalValue = formatSwapAmount(parseFloat(this.deal.PosiNotionalValue));
this.deal.CloseQty = this.deal.PositionQty;
} else {
// ClosePercent 是占期初口径(A),需除以 oriClosePercent 转占剩余(B) 再乘剩余数量
@@ -213,15 +224,7 @@ const vue = new Vue({
this.deal.ClosePercent);
},
refreshTradingFeeByUnit() {
const allocatedFee = swapPosiFeeCalc.calcAllocatedTradingFee(
this.floatPosition.BeforeCloseFee,
this.floatPosition.PosiFeeType,
this.floatPosition.PosiTradingFeeUnit,
this.deal.CloseNotionalValue,
this.deal.CloseQty,
this.deal.NotionalValue,
this.deal.NotionalQty);
this.floatPosition.TradingFee = allocatedFee !== null ? allocatedFee : swapPosiFeeCalc.calcTradingFee(
this.floatPosition.TradingFee = swapPosiFeeCalc.calcTradingFee(
this.floatPosition.PosiFeeType,
this.floatPosition.PosiTradingFeeUnit,
this.deal.CloseNotionalValue,
@@ -241,7 +244,7 @@ const vue = new Vue({
this.deal.CloseMethod = 2;
}
// 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue)
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
this.deal.CloseNotionalValue = formatSwapAmount(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
this.calcTradingFeePending();
this.refreshTradingFeeByUnit();
this.getInterestList();
@@ -255,7 +258,7 @@ const vue = new Vue({
}
this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent);
// 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue)
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
this.deal.CloseNotionalValue = formatSwapAmount(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
if (parseFloat(this.deal.ClosePercent) == parseFloat(this.oriClosePercent)) {
this.deal.CloseMethod = 1;
} else {
@@ -309,7 +312,7 @@ const vue = new Vue({
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.MarkClosePnl = formatSwapAmount(thisObj.floatPosition.MarkClosePnl);
thisObj.floatPosition.FloatPnlSum = (parseFloat(thisObj.floatPosition.MarkClosePnl) + TradingFee + TradingFeePending + parseFloat(thisObj.floatPosition.DividendIn)).toFixed(2);
thisObj.calcCloseAmount();
@@ -319,12 +322,12 @@ const vue = new Vue({
},
changeInterestAmount(item) {//修改利息金额
let interestRatio = item.InterestDirection == 1 ? 1 : -1;
item.InterestClosePnL = otcformat.trading.StockEqvNotional(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee) * interestRatio);
item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee) * interestRatio);
this.calcCloseAmount();
},
//calcClosePnL() {//计算浮动端平仓盈亏
// let pnl = parseFloat(this.floatPosition.ClosePnL) - parseFloat(this.floatPosition.TradingFee);
// this.floatPosition.ClosePnL = otcformat.trading.StockEqvNotional(pnl);
// this.floatPosition.ClosePnL = formatSwapAmount(pnl);
// this.calcCloseAmount();
//},
calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付
@@ -361,10 +364,10 @@ const vue = new Vue({
});
thisObj.deal.SwapRealizedPnL = Number(thisObj.deal.SwapRealizedPnL.toFixed(2));
thisObj.deal.SwapCloseAmount = Number(thisObj.deal.SwapCloseAmount.toFixed(2));
thisObj.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(thisObj.deal.SwapCloseAmount);
thisObj.deal.SwapRealizedPnL = otcformat.trading.StockEqvNotional(thisObj.deal.SwapRealizedPnL);
thisObj.deal.SwapMarginRebatePnl = otcformat.trading.StockEqvNotional(thisObj.deal.SwapMarginRebatePnl);
thisObj.deal.SwapMarginAmount = otcformat.trading.StockEqvNotional(thisObj.deal.SwapMarginAmount);
thisObj.deal.SwapCloseAmount = formatSwapAmount(thisObj.deal.SwapCloseAmount);
thisObj.deal.SwapRealizedPnL = formatSwapAmount(thisObj.deal.SwapRealizedPnL);
thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl);
thisObj.deal.SwapMarginAmount = formatSwapAmount(thisObj.deal.SwapMarginAmount);
},
getInterestList() {//根据平仓日期获取利息腿信息
var thisObj = this;
@@ -384,27 +387,15 @@ const vue = new Vue({
},
getDivindIn() {
var thisObj = this;
let ratio = this.floatPosition.PositionType == 1 ? 1 : -1;
let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1;
var postData = { startDate: thisObj.TradeStartDate, endDate: thisObj.deal.UnwindDate, underlyingCode: thisObj.floatPosition.UnderlyingCode, tradeId: thisObj.deal.SwapTradeId, unwindDate: thisObj.deal.UnwindDate }
main.post("/BondPayment/GetBondPayMentInterest", postData, { async: false }).done(function (resp) {
let consumedDividend = Math.abs(parseFloat(resp.obj.consumedDividend ?? 0)) * ratio * floatRatio;//已实现的
let totalDividend = parseFloat(thisObj.deal.NotionalQty) * resp.obj.totalInterest * ratio * floatRatio;
let remainDividend = totalDividend - consumedDividend;
let dividendIn = 0;
if (remainDividend != 0) {
dividendIn = parseFloat(thisObj.deal.CloseQty) / parseFloat(thisObj.floatPosition.Quantity) * remainDividend
}
// 这里计算已经分红的利息,从totalInterest里扣除
// 互换是全量消费,consumedDividend>0 表示分红已被当天互换消费,归0
// 现在可能做了纯分红的互换结算,所以不能直接归0
thisObj.floatPosition.DividendIn = parseFloat(dividendIn.toFixed(2));
var posiQty = parseFloat(thisObj.floatPosition.Quantity) - parseFloat(thisObj.deal.CloseQty);
thisObj.floatPosition.DividendPending = parseFloat((posiQty * resp.obj.totalInterest * ratio * floatRatio).toFixed(2));
thisObj.calcFloatClosePnl();
thisObj.dataFormat();
});
// 方案C:分红改由后端 InitUnwind 读 EOD PosiDividendSum 填入 floatPosition.DividendIn 与 DividendPending
// (单一可信源)。前端不再调用 GetBondPayMentInterest 自算——消除"期初持仓×totalInterest"对已平仓
// 部分的重复计入(GLMS-20260105-0004 平仓前部分平仓40%后,再平仓时分红误显 -36,160,应为 0)。
// ⚠ floatPosition.DividendIn / DividendPending 均保持后端返回值不动,前端不得覆盖:
// - DividendIn(本次落袋)、DividendPending(待结算存量=PosiDividendSum 全量口径)。
// - 互换页 DividendPending 保持 0(互换=全量结清,结清后待结算归0),见 incomeSwapTrade.js。
// - 历史:曾硬编码 DividendPending=0,对 PosiDividendSum≠0 的部分平仓会落库错误的 0(回归)。
thisObj.calcFloatClosePnl();
thisObj.dataFormat();
},
closeTrade() {//平仓
var thisObj = this;
@@ -297,6 +297,8 @@
!options.append && (options.append = '');
let precision = parseInt(options.precision) || 0;
options.precision = precision < 0 ? 0 : precision;
let integerDigits = parseInt(options.integerDigits) || 0;
options.integerDigits = integerDigits > 0 ? integerDigits : 0;
options.negative = !!options.negative;
_options = options;
setValue(getValue());
@@ -336,6 +338,35 @@
return this.value.substring(0, this.selectionStart).indexOf('.') < 0 && this.value.substring(this.selectionEnd, this.value.length).indexOf('.') < 0;
}
function checkIntegerDigitsInput() {
if (!_options.integerDigits) return true;
let valueEnd = this.value.length;
if (_options.append && this.value.endsWith(_options.append)) valueEnd -= _options.append.length;
const dotIndex = this.value.indexOf('.');
const integerEnd = dotIndex < 0 ? valueEnd : dotIndex;
if (this.selectionStart > integerEnd) return true;
const selectionEnd = Math.min(this.selectionEnd, integerEnd);
const integerText = this.value.substring(0, this.selectionStart) + this.value.substring(selectionEnd, integerEnd);
return integerText.replace(/\D/g, '').length < _options.integerDigits;
}
function limitIntegerDigits(value) {
if (!_options.integerDigits) return value;
let digits = 0;
let hasDot = false;
let result = '';
for (let index = 0; index < value.length; index++) {
const ch = value.charAt(index);
if (ch === '.' || ch === '。') hasDot = true;
if (ch >= '0' && ch <= '9' && !hasDot) {
if (digits >= _options.integerDigits) continue;
digits++;
}
result += ch;
}
return result;
}
function __keyHandle(event) {
if (!event) return false;
@@ -385,6 +416,7 @@
if (charCode < 48 || charCode > 57 && (charCode < 96 || charCode > 105)) {
return false;
}
if (!checkIntegerDigitsInput.call(this)) return false;
if (this.selectionStart === 0) {
return !this.value || this.value.charAt(this.selectionEnd) !== '-';
}
@@ -445,6 +477,7 @@
// 粘贴进来的是显示值(可能带 %/‱ 后缀),先按 __change 同款口径解析为模型值再回显,
// 避免 setValue 把显示值再乘以 100/10000#EQD-5914 债券价格粘贴 ×100
let f = '';
this.value = limitIntegerDigits(this.value);
if (this.value && this.value !== _options.append) {
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
@@ -455,6 +488,7 @@
if (_chnInput >= 0) {
__onChineseInput.call(this, _chnInput);
}
this.value = limitIntegerDigits(this.value);
if (!_options.append || !this.value) return;
let appended = true;
if (this.value !== _options.append) {
@@ -14354,6 +14354,8 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
!options.append && (options.append = '');
let precision = parseInt(options.precision) || 0;
options.precision = precision < 0 ? 0 : precision;
let integerDigits = parseInt(options.integerDigits) || 0;
options.integerDigits = integerDigits > 0 ? integerDigits : 0;
options.negative = !!options.negative;
_options = options;
setValue(getValue());
@@ -14393,6 +14395,35 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
return this.value.substring(0, this.selectionStart).indexOf('.') < 0 && this.value.substring(this.selectionEnd, this.value.length).indexOf('.') < 0;
}
function checkIntegerDigitsInput() {
if (!_options.integerDigits) return true;
let valueEnd = this.value.length;
if (_options.append && this.value.endsWith(_options.append)) valueEnd -= _options.append.length;
const dotIndex = this.value.indexOf('.');
const integerEnd = dotIndex < 0 ? valueEnd : dotIndex;
if (this.selectionStart > integerEnd) return true;
const selectionEnd = Math.min(this.selectionEnd, integerEnd);
const integerText = this.value.substring(0, this.selectionStart) + this.value.substring(selectionEnd, integerEnd);
return integerText.replace(/\D/g, '').length < _options.integerDigits;
}
function limitIntegerDigits(value) {
if (!_options.integerDigits) return value;
let digits = 0;
let hasDot = false;
let result = '';
for (let index = 0; index < value.length; index++) {
const ch = value.charAt(index);
if (ch === '.' || ch === '。') hasDot = true;
if (ch >= '0' && ch <= '9' && !hasDot) {
if (digits >= _options.integerDigits) continue;
digits++;
}
result += ch;
}
return result;
}
function __keyHandle(event) {
if (!event) return false;
@@ -14442,6 +14473,7 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
if (charCode < 48 || charCode > 57 && (charCode < 96 || charCode > 105)) {
return false;
}
if (!checkIntegerDigitsInput.call(this)) return false;
if (this.selectionStart === 0) {
return !this.value || this.value.charAt(this.selectionEnd) !== '-';
}
@@ -14502,6 +14534,7 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
// 粘贴进来的是显示值(可能带 %/‱ 后缀),先按 __change 同款口径解析为模型值再回显,
// 避免 setValue 把显示值再乘以 100/10000#EQD-5914 债券价格粘贴 ×100
let f = '';
this.value = limitIntegerDigits(this.value);
if (this.value && this.value !== _options.append) {
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
@@ -14512,6 +14545,7 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
if (_chnInput >= 0) {
__onChineseInput.call(this, _chnInput);
}
this.value = limitIntegerDigits(this.value);
if (!_options.append || !this.value) return;
let appended = true;
if (this.value !== _options.append) {
@@ -0,0 +1,230 @@
# 互换(TRS)计算口径一致性:残留问题与治理路线
> 配套文档:`互换分红损益字段语义与重复计算分析.md`2026-06,根因分析)
> 本次分析目的:在最新提交 `e3c473ba`(分红收益改由 EOD 单一可信源)之后,
> 复核"分散计算 / 前后端重复算 / 精度口径不统一"这类 BUG 是否仍在其它地方存在,
> 并给出"如何避免 + 后续逐渐解决"的路线。
> 分析日期:2026-08-06
---
## 一、这类 BUG 的本质(统一定义)
最新修复的"分红收益误显 -36,160",根因不是某一个 if 写错,而是一种**结构性缺陷**:
> **金融计算缺乏「单一可信源 / 统一口径」** —— 同一个业务量在
> **前端 JS / 后端 C# / EOD 批处理** 三条路径里被**各自重算**
> 且价格基准、方向符号、精度位数都零散硬编码,导致:
> 1. 前后端(或同一功能的两页)对同一量算出**不同值**;
> 2. 分红等分量被**重复计入**MarkClosePnl 含分红 → 汇总翻倍);
> 3. 金额/价格**精度不统一**,相邻环节差 1~2 位小数。
它表现为 3 个子模式:
| 子模式 | 例子 | 状态 |
|--------|------|------|
| ① 分散计算、无单一可信源 | 分红前端自算(`getDivindIn`vs EOD `PosiDividendSum` | **最新提交已根治(方案C** |
| ② 字段语义重叠→重复累加 | `MarkClosePnl` 含分红,汇总 `RealizedPnl` 翻倍 | 自动路径已按方向A改造;**前端两页仍有口径差** |
| ③ 硬编码精度/魔法数字 | `Math.Round(...,4/10)``ToString("F10")` vs `ConsGlobal.*` | **大量残留** |
---
## 二、最新提交(e3c473ba)做了什么(已修复)
- 把"浮动端平仓盈亏·分红 `DividendIn`"从**前端 `getDivindIn` 自算**`期初持仓×totalInterest`
改为**后端 `SwapDealService.GetPreEodDividendSum` 读 EOD `PosiDividendSum`**(单一可信源)。
- 前端 `unwindSwapTrade.js` / `incomeSwapTrade.js``getDivindIn` 删除自算逻辑,只保留后端值。
- `GetBondPayMentInterest` 标记废弃(保留接口供历史调用)。
- 配套绿灯验收测试 `GLMS20260105PartialCloseDividendBugTest.cs`
**结论**:子模式①(分红分散计算)在"预览页展示"这条链路已根治。但**②④③ 仍在**。
---
## 三、同类问题在别处是否还存在(带 file:line 证据)
### A. 前端两页 `MarkClosePnl` 口径不一致 —— 最危险、最具体的残留 ⚠️
> **2026-08-06 修正**:初版称"平仓页用净价、互换页用全价"——经核对代码**不准确**。
> 实际两页都用**全价**`unwindSwapTrade.js:111``initPosiNetPrice` 绑到 `PosiGrossPrice`(变量名有误导性),
> `incomeSwapTrade.js:103/207``initPosiGrossPrice`(也是全价)。真正差异见下表与 A.1。
同一字段 `MarkClosePnl`(盯市盈亏),实现对照(来源:团队金标准 `YLErpDAL/Helpers/FrontendCalcReference.cs`):
| 实现 | 位置 | 公式 | 价格基准 | 方向 | 数量基准 |
|------|------|------|----------|------|----------|
| 平仓页 | `unwindSwapTrade.js:313` | `CloseQty × (价 期初全价) × floatRatio × longRatio` | 全价(变量名骗人) | 含 `longRatio` | `CloseQty` |
| 互换页 | `incomeSwapTrade.js:207` | `positionAmount × (价 期初全价) × floatRatio` | 全价 | **无 `longRatio`** | `positionAmount=PositionQty×ContractSize` |
| 后端 | `SwapDealService.cs:1613` | `(价 PosiGrossPrice) × unwindQty × floatRatio × longRatio` | 全价 | 含 `longRatio` | `unwindQty` |
| 金标准 | `FrontendCalcReference.CalcUnwind:44` / `CalcIncome:107` | 同上(`CalcIncome` 注释明示"无 longRatio" | 全价 | unwind 有 / income 无 | — |
**真正的风险点(已用红测试证明,见 A.1):**
1. **互换页缺 `longRatio`(多空方向)** —— 这是实打实的 bug。一旦 `PositionType=2`(空头),
unwind 页与后端会乘 `1`,而 income 页不乘 → **同一笔空头两页算出相反符号**
债券 TRS(国联民生等)普遍支持空头,并非边缘场景。
2. **数量基准不同**(设计使然,非 bug):平仓页用 `CloseQty`(本次平仓量),互换页用 `PositionQty×ContractSize`
(剩余持仓量,因互换是全额置换剩余持仓)。语义不同但各自自洽,需业务确认是否期望一致。
3. **中间取整**income 无 `×10000/10000` 步骤(`FrontendCalcReference` 注释明示),但末端都保留 2 位,
干净输入下等价(parity 已证明),属低风险。
**共享的 `swapCalc.calcMarkClosePnl``swapCalc.js:113`,全价+`longRatio`+`scale`)已存在,
但两个生产页面都没调用**——仍是"单一可信源已有却不采纳"。
**会进库吗?** 会。手动平仓/互换的 `MarkClosePnl` 由前端算好传入,后端**直接存库不重算**
`SwapDealService:1500/1991``ValidateFrontendPnL` 仅"只读告警、不阻断")。
因此 income 页空头符号错误会直接落到 `swap_flow_event.MarkClosePnl`,并带偏 `FloatPnlSum`/`SwapRealizedPnL`
### A.1 为什么一直没暴露?—— 红测试证据(2026-08-06 补)
三层叠加导致这个 bug 长期潜伏:
1. **校验同源、永远自洽**`ValidateFrontendPnL``FrontendCalcReference.CalcIncome` 重算比对,
`CalcIncome` 本身就没 `longRatio`,所以"被校验的前端"和"校验用的公式"完全一致,永远不告警。
2. **特征化测试全是多头**`FrontendCalcCharacterizationTest` 的 income 场景 `FC_006~009`
**全部 `PositionType=1`(多头)**;唯一空头场景 `FC_005` 是 unwind。income 的空头分支从未被触发。
3. **告警不阻断** + 生产数据里"空头做互换"相对少见,进一步降低暴露概率。
**已落地红测试(证明 bug 真实存在 + 证明改动可修复):**
- `YLErpWeb/fe-tests/markClosePnlShortConsistency.test.js`jest,与 `parity.test.js` 同风格)
- `YLErpWeb/fe-tests/_proof_short_income.js`(纯 Node 可跑,无需依赖)
- 运行结果:多头场景两页一致(PASS,掩盖了问题);**空头场景 `unwind=300` / `income=+300`FAIL,符号相反)**
给 income 补 `longRatio``income=300` 与 unwind 一致(PASS)。
→ 既证明"当前代码对空头不一致(有问题)",也证明"给 income 补 `longRatio` 即可修复"。
### B. 后端硬编码精度魔法数字 —— 与集中常量冲突/不绑定的残留 ⚠️
集中常量(`Framework/YLErp.Core/ConsGlobal.cs`):`PriceRound=11``SwapDeliveryPriceRound=9`
`MoneyRound=2`;模块内 `InterestCalculationPrecision=12``EodInterestStoragePrecision=12`
残留的裸数字(不引用上述常量):
| 位置 | 写法 | 应参照 | 问题 |
|------|------|--------|------|
| `SwapFlowService.cs:116-118` | `Math.Round(price, 4)`ClosePrice/SettlePrice/ReferencePrice | `PriceRound=11` / `SwapDeliveryPriceRound=9` | 价格存储 4 位 vs 全系统 9~11 位,**口径不一致** |
| `SwapDealService.cs:1543` | `unwindPriceFee.ToString("F10")` | — | 硬编码 10 位 |
| `SwapTradeAutoService.cs:458/460` | `Math.Round(..., 10)`(净均价) | `PriceRound=11` | 差 1 位 |
| `SwapTradeAutoService.cs:788/1409` | `Math.Round(..., 4)`(费用/平仓费) | `MoneyRound=2` | **费用 4 位 vs 全系统金额 2 位,错配** |
| 分红链路多处 `Math.Round(...,2)``SwapEodPositionService.cs:1647/1711/1713/2003``SwapDealService.cs:1658/1882/1883` | 裸 `2` | `ConsGlobal.MoneyRound` | 目前恰等于 2,但属硬编码,`MoneyRound` 一旦配置化即失真 |
说明:第 4 行(费用 4 位 vs 金额 2 位)是**真实精度错配**,不是巧合一致;其余价格/净均价是"差 1 位"的隐患。
### C. 交叉校验基础设施已建,但未"落地到生产" —— 治理杠杆闲置
- `swapCalc.js``calcUnwind` / `calcIncome``:135`/`:170`)是**"参考规格,未接入生产代码"**——
注释明确写着生产 Vue 只调用 4 个叶子函数,聚合逻辑仍是各页内联。
- `fe-tests/parity.test.js` + `swapCalc.test.js` 已能冻结 8 个 FC 场景,但只覆盖已迁移的 4 个叶子函数。
- 这正是"避免反复打补丁"的关键设施,**却没把生产聚合逻辑迁过去**。
### D. 横向同类风险(建议扫描,本次未深入)
互换之外的 期货 / 期权 / 定价引擎(greeks)/ 估值报告 等模块,
很可能也存在"前端重算 + 后端重算""硬编码精度"的同构问题,需专项扫描。
---
## 四、如何避免(规范 / 治理层)
1. **单一可信源原则(最高优先级)**
- 每个金融量只允许**一个权威计算点**:分红→EOD `PosiDividendSum`;盯市→`swapCalc.calcMarkClosePnl`
金额聚合→`swapCalc.calcFloatPnlSum` / `calcUnwind` / `calcIncome`
- 前端**只展示、不重算**;手动平仓/互换落库时由后端**重算**而非信任前端传值。
2. **精度集中化(红线)**
- 任何 `Math.Round` / `ToString("F")` 必须引用 `ConsGlobal.*``GetStorageDeliveryPriceRound(underlyingInstrumentType, code)`
**禁止裸数字**。代码评审把"裸精度数字"列为 blocking 项。
3. **字段语义单一职责(已定义,需固化)**
- `MarkClosePnl`=纯价差盯市、`DividendIn`=纯分红、`RealizedPnl`=不重叠分量之和。
- 写进评审清单 + 用集成测试守护。
4. **自动守护测试(把已建设施用起来)**
- 扩展 `parity.test.js` 的"生产表达式逐字抄录 vs `SwapCalc`"模式到**所有关键公式**
- 后端补"分红守恒 / 持仓守恒"集成测试:`ΔSwapPositionValue + ΔRealizedPnl == 0`
(参考分析文档附录 A.2 的 SQL 思路,转成 `SwapEodPositionServiceIntegrationTest` 断言)。
5. **防回归**:将 `swapCalc.calcUnwind/calcIncome` 接入生产,删除两页内联实现。
---
## 五、后续逐渐解决的路线(分阶段、低风险)
- **阶段 0(已具备)**`parity.test.js` / `swapCalc.test.js` / `GLMS20260105PartialCloseDividendBugTest.cs` 框架。
- **阶段 1(低风险、先消除最危险口径)**
`incomeSwapTrade.js:207``unwindSwapTrade.js:313` 统一调用 `swapCalc.calcMarkClosePnl`
(全价 + `longRatio` + `scale`),并确认与后端 `PosiGrossPrice` 口径一致;
新增"净价≠全价""空头 longRatio"两个 parity 场景守护。**改动小、收益高。**
- **阶段 2(消除魔法数字)**
`SwapFlowService` / `SwapTradeAutoService` / `SwapDealService` 里的裸 `Math.Round(...,4/10)`
`ToString("F10")` 替换为 `ConsGlobal.*` / `GetStorageDeliveryPriceRound`;分红的裸 `2` 改为 `ConsGlobal.MoneyRound`
逐文件改,每改一处跑 parity + 现有单测。
- **阶段 3(后端收口)**
手动平仓/互换落库时,后端对 `MarkClosePnl` / `FloatPnlSum` / `SwapRealizedPnL` **重算**(与 `swapCalc` 金标准一致),
不再信任前端;同时跑"持仓守恒"SQL 校验历史数据是否已被两页口径差污染。
- **阶段 4(横向扫描)**
用脚本/Explore 扫描 期货、期权、定价引擎、估值报告 模块,查找"前端重算+后端重算""硬编码精度"同类结构,建立清单逐个治理。
- **阶段 5(文档固化)**
更新 `互换分红损益字段语义与重复计算分析.md`,把"已修复 / 待修复"状态机化,作为新人评审清单与回归基线。
---
## 六、一句话结论
> 最新修复根治了"分红预览"这一条链路的分散计算;但**同类结构依然存在**——
> 前端平仓页/互换页的 `MarkClosePnl` 都用全价,真正的差异是**互换页(income)漏乘 `longRatio`(多空方向)**
> 对空头会算出相反符号并直接落库(后端对 income 不重算、原样存前端值);共享的 `swapCalc.calcMarkClosePnl` 两个页面都没用;
> 后端仍有**费用 4 位 vs 金额 2 位**等硬编码精度错配。
> 治理的关键不是再打补丁,而是**把已建好的 `swapCalc` 单一可信源 + parity 守护真正接入生产**,
> 并按上述 5 个阶段低风险推进。
---
## 七、如何确认"income 错 / unwind 对"(而非相反)+ 改动安全性
> 这一章回答一个关键质疑:两页口径不同,凭什么断定是 income 漏了 `longRatio`、而不是 unwind 多算了?
> 以及:给 income 补 `longRatio` 会不会把正确逻辑改坏、或造成"双重翻转"?
### 7.1 两个方向乘子是**独立轴**(这是避免误判的前提)
- `floatRatio = PayDirection==1(收取) ? +1 : -1` —— 跟随**收付方向**`FrontendCalcReference.cs:35`
- `longRatio = PositionType==1(多头) ? +1 : -1` —— 跟随**多空方向**`FrontendCalcReference.cs:36`
二者在本系统里**互不决定**:一个"空头"完全可以 `PayDirection=收取`
### 7.2 裁决性证据:FC_005(空头平仓)冻结基线
`UnitTestProject/.../FrontendCalcCharacterizationTest.cs``FC_005`
```
输入:PositionType=2(空头) + PayDirection=1(收取)
注释:floatRatio=1(收取), longRatio=-1(空头)
期望:MarkClosePnl = 1000×(105100)×1×(1) = 5000 (空头涨价=亏损,经济正确)
```
**它证明两件事**
1. 空头下 `floatRatio` 仍是 **+1**(方向不靠 `floatRatio` 编码)→ 所以 income 只乘 `floatRatio(+1)` 而漏 `longRatio(-1)`
对同一个空头会算出 **+5000**,与权威 5000 相反 → **income 错、unwind 对**
2. 因为两轴独立,**给 income 补 `longRatio` 是纠正、不是双重翻转**"floatRatio 已编码方向"的担忧不成立)。
### 7.3 三条互相独立的证据链(任一都足以定罪)
| # | 证据 | 来源 | 结论 |
|---|------|------|------|
| 1 | 盯市盈亏空头必须翻转符号(空头跌价才盈利)——会计不变式 | 业务数学,独立于代码 | income 漏方向乘子→空头符号必错 |
| 2 | 后端平仓结算 `:1620` 重算并**覆写** `MarkClosePnl=…*floatRatio*longRatio`(入库存后端口径);`SwapIncome:1980-2013` **不重算**、原样存前端值 | `SwapDealService.cs` | 系统自身的权威定义含 longRatioincome 偏离它 |
| 3 | `SwapIncome:1994` 用前端 `SwapRealizedPnl`(由 `MarkClosePnl` 派生)做 `AddClientCash(-SwapRealizedPnl)` | `SwapDealService.cs` | 空头 income 不仅显示错,**实际现金流方向也错**(非纯展示) |
### 7.4 下游是否会"双重翻转 / 补偿性 hack"
- `SwapFlowEventService.cs:589` 对所有流水事件的 `MarkClosePnl/DividendIn/CloseFee/...` **统一取反**(全局视角翻转,firm book→client view),
**不针对 income 或空头**。修复后 income 与 unwind 走同一套取反,对称性不变 → 安全。
- 全仓检索仅此一处 `MarkClosePnl=-MarkClosePnl`,无针对 income/空头的补偿性符号翻转 → 不存在"加了 longRatio 反而翻错"的 hack。
### 7.5 改动安全性论证(如何确保不影响现有正确逻辑)
修复 = 给 income 的 `MarkClosePnl`(含 `FrontendCalcReference.CalcIncome:107`)补 `× longRatio`,使其与权威 unwind 公式口径一致。
1. **静态保证(零回归)**:对所有**多头**(现有 `FC_006~009` 及全部生产多头 income`longRatio=+1`
乘积不变 → 数值**逐位相同**,现有正确行为完全不动。改动是"对多头的恒等变换 + 对空头的纠错",是严格的超集。
2. **回归护栏**
- 跑现有 `FC_001~009` + `SwapFrontendPnlValidateTest` + `SwapIncomeScenarioTest` → 全绿(均为多头,不受影响)。
- 新增 `FC_010`(空头 income`PayDirection=1, PositionType=2`)镜像 `FC_005`,锁定纠正后行为,并断言 `income(空头)==unwind(空头)`parity)。
- jest 红测试 `markClosePnlShortConsistency.test.js` 的"空头"用例由 FAIL 转 PASS,多头用例保持 PASS。
3. **剩余风险(非逻辑正确性,需业务决策)**
- **历史脏数据**:过去"空头+互换"事件已用错符号落库;修复后新事件正确,跨时间对比会出现不连续。需决定:回溯校正(改 `MarkClosePnl`+重算 `SwapRealizedPnl`+对账 `AddClientCash` 历史)还是标注留痕。
- **改动范围**:严格限定在 income 页 `:207``CalcIncome:107``longRatio`,切忌顺手改 `floatRatio` 或其他页面。
4. **前置确认**:建议先让业务/量化签字"income 的 `MarkClosePnl` 应含多空方向(与平仓一致)",再动手——因为结论虽由代码+基线铁证支撑,但涉及客户现金流,需业务背书。