Merge remote-tracking branch 'origin/glms/feature/1.4.2' into glms/feature/1.4.2-margin

This commit is contained in:
锦麟 王
2026-08-13 14:02:16 +08:00
61 changed files with 1702 additions and 1981 deletions
@@ -65,6 +65,13 @@ namespace YLErp.DBModels
[DisplayName("实际付息(兑付)日")]
[Column("pay_date_act")]
public DateTime? payment_date { get; set; }
/// <summary>
/// 债权登记日(除息/归属截止日)——票息归属按此判定,而非支付日
/// </summary>
[DisplayName("债权登记日")]
[Column("reg_date")]
public DateTime? reg_date { get; set; }
/// <summary>
/// 每张兑付利息额
/// </summary>
@@ -205,7 +205,7 @@ namespace YLErp.DBModels
[DataChange]
public int InterestDirection { get; set; }
/// <summary>
/// 计息基本类型 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值,5:初始预付金,6:追加预付金
/// 计息基本类型 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金9:标的期初全价
/// </summary>
[DisplayName("计息基本类型")]
[DataChange]
@@ -280,7 +280,7 @@ namespace YLErp.DBModels
}
}
/// <summary>
/// 计息方式 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值
/// 计息方式 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金,9:标的期初全价
/// </summary>
[DisplayName("计息方式")]
[DataChange]
@@ -163,7 +163,7 @@ namespace YLErp.DBModels
/// </summary>
public string FloatRateUnderlyingCode { get; set; }
/// <summary>
/// 计息基本类型 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值,5:初始预付金,6:追加预付金
/// 计息基本类型 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金9:标的期初全价
/// </summary>
[DisplayName("计息基本类型")]
[DataChange]
@@ -18,7 +18,7 @@ public readonly struct AccrualContext
/// <summary>年化天数(365 / 360)。</summary>
public int AnnualDays { get; }
/// <summary>舍入精度位数。默认 11(保证金腿);资金腿调用方应显式 FundingLegPrecision=12。</summary>
/// <summary>舍入精度位数。默认 11(仅未接线的 MarginAccount.AccrueInterest 走此默认;生产融资腿/保证金腿均显式 FundingLegPrecision=12。</summary>
public int Precision { get; }
/// <summary>可选 trace 收集器;为 null 时不记录(纯计算场景直接传 null,与开关无关)。</summary>
@@ -53,7 +53,7 @@ public readonly struct InterestRate
/// <list type="bullet">
/// <item><description><see cref="Compounding.Simple"/>decimal 精确运算。</description></item>
/// <item><description><see cref="Compounding.Compounded"/> / <see cref="Compounding.Continuous"/>:闭式(double 计算后回 decimal),
/// 满足通用定价 / 保证金场景;若要 decimal 精度的离散重置日复利,请用 Simple 按段计息并滚动本金。</description></item>
/// 满足通用定价;若要 decimal 精度的离散重置日复利,请用 Simple 按段计息并滚动本金。</description></item>
/// </list>
/// </summary>
public decimal CompoundFactor(decimal t)
@@ -106,11 +106,11 @@ public readonly struct InterestResult
/// </summary>
public static class SwapInterest
{
/// <summary>系统统一价格精度位数(保证金腿)。</summary>
/// <summary>默认舍入精度位数(历史值;生产融资腿与保证金腿均用 FundingLegPrecision=12)。</summary>
public const int Precision = 11;
/// <summary>资金腿计息精度(生产口径)。资金腿所有落库/对账均以 12 位为准
/// 与保证金腿的 Precision=11 不同。提升至公共常量,消除 SwapDealService 与 FundingLegAccrual 的重复定义。</summary>
/// <summary>资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。
/// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。</summary>
public const int FundingLegPrecision = 12;
/// <summary>年化天数常量(合约字段存的是 int,故不用 enum)。</summary>
@@ -13,7 +13,7 @@ using YLErp.Core.Interest;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// 影子测试:CalcDailyCompoundInterestByEod(旧逐日循环)vs FundingLegAccrual.AccrueCompoundEod(新纯函数)。
/// 影子测试:CalcDailyCompoundInterestByEod(旧逐日循环)vs CompoundInterestAccrual.AccrueEod(新纯函数)。
/// 构造同一组参数,两套实现并行跑,断言结果一致(到分)。
/// </summary>
[TestClass]
@@ -92,14 +92,14 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldInterest = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterestByEod(preEod, EodDate, TradeDate, position,
Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m, Notional,
Notional, Notional, flowEvent, AnnualDays, 0m, 1m,
ref oldInterest, ref oldTd);
// 新方法
var rate = FundingLegRate.Fixed(FixedRate);
var policy = new AccrualPolicy(AccrualBoundary.Both, true, 7, AnnualDays, true);
var remainingPercent = Math.Max(0m, Math.Min(1m, Notional / Notional));
var result = FundingLegAccrual.AccrueCompoundEod(
var result = CompoundInterestAccrual.AccrueEod(
50_000m, Notional, Notional, 1m, rate, policy,
isResetDay: true, remainingPercent, EodDate);
@@ -125,13 +125,13 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldInterest = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterestByEod(preEod, nonResetDate, TradeDate, position,
Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m, Notional,
Notional, Notional, flowEvent, AnnualDays, 0m, 1m,
ref oldInterest, ref oldTd);
// 新方法
var rate = FundingLegRate.Fixed(FixedRate);
var policy = new AccrualPolicy(AccrualBoundary.Both, true, 7, AnnualDays, true);
var result = FundingLegAccrual.AccrueCompoundEod(
var result = CompoundInterestAccrual.AccrueEod(
30_000m, Notional, Notional, 1m, rate, policy,
isResetDay: false, 0m, nonResetDate);
@@ -7,7 +7,7 @@ using YLErp.Modules.SwapModule.Accrual;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// 影子测试:CalcDailyCompoundInterest(旧逐日循环)vs FundingLegAccrual.AccrueCompoundPeriod(新分段纯函数)。
/// 影子测试:CalcDailyCompoundInterest(旧逐日循环)vs CompoundInterestAccrual.AccruePeriod(新分段纯函数)。
/// </summary>
[TestClass]
public class CompoundPeriodShadowTest
@@ -74,7 +74,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent,
AnnualDays, false, 0m, 1m, Notional, true, false,
AnnualDays, 0m, 1m, true, false,
ref oldI, ref oldTd);
// 新方法:固定利率全段相同,分段点 = PosiStartDate + k×7
@@ -86,7 +86,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
(StartDate.AddDays(7), allInRate),
(StartDate.AddDays(14), allInRate),
};
var result = FundingLegAccrual.AccrueCompoundPeriod(
var result = CompoundInterestAccrual.AccruePeriod(
notional: Notional,
segmentRates: segRates,
startDate: StartDate,
@@ -122,7 +122,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterest(EndDate, position, Notional * closePct, flowEvent,
AnnualDays, false, 0m, closePct, Notional, true, false,
AnnualDays, 0m, closePct, true, false,
ref oldI, ref oldTd, consumedInterest: consumed, resetCarryInterest: carry);
// 新方法
@@ -133,7 +133,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
(StartDate.AddDays(7), allInRate),
(StartDate.AddDays(14), allInRate),
};
var result = FundingLegAccrual.AccrueCompoundPeriod(
var result = CompoundInterestAccrual.AccruePeriod(
notional: Notional * closePct,
segmentRates: segRates,
startDate: StartDate,
@@ -166,7 +166,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent,
AnnualDays, false, 0m, 1m, Notional, true, true,
AnnualDays, 0m, 1m, true, true,
ref oldI, ref oldTd);
// 新方法
@@ -177,7 +177,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
(StartDate.AddDays(7), allInRate),
(StartDate.AddDays(14), allInRate),
};
var result = FundingLegAccrual.AccrueCompoundPeriod(
var result = CompoundInterestAccrual.AccruePeriod(
notional: Notional,
segmentRates: segRates,
startDate: StartDate,
@@ -12,7 +12,7 @@ using YLErp.Derivatives.Interest;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// 影子测试:CalcDailySimpleInterest(旧逐日循环)vs FundingLegAccrual.AccrueSimplePeriod(新分段纯函数)。
/// 影子测试:CalcDailySimpleInterest(旧逐日循环)vs SimpleInterestAccrual.AccruePeriod(新分段纯函数)。
/// </summary>
[TestClass]
public class SimplePeriodShadowTest
@@ -100,13 +100,13 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent,
AnnualDays, false, 0m, 1m, Notional, true, false, ref oldI, ref oldTd);
AnnualDays, 0m, 1m, Notional, true, false, ref oldI, ref oldTd);
// 新方法:固定利率全段相同
// 旧代码差分: dynomicPrincipal = preEod.TdInterestPrincipal(=0) + posiPrincipal - orginPv = 0
var segRates = new List<(DateTime, decimal)> { (StartDate, Spread) };
var accrualPrincipal = 0m + Notional - Notional; // 差分 = 0
var result = FundingLegAccrual.AccrueSimplePeriod(
var result = SimpleInterestAccrual.AccruePeriod(
priorAccrued: 0m,
notional: 0m,
unwindFraction: 1m,
@@ -146,13 +146,13 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent,
AnnualDays, false, 0m, 0.5m, Notional, true, false, ref oldI, ref oldTd);
AnnualDays, 0m, 0.5m, Notional, true, false, ref oldI, ref oldTd);
// 新方法
// 差分本金 = preEod.TdInterestPrincipal + posiPrincipal - orginPv
var accrualPrincipal = Notional + Notional - Notional;
var segRates = new List<(DateTime, decimal)> { (StartDate, Spread) };
var result = FundingLegAccrual.AccrueSimplePeriod(
var result = SimpleInterestAccrual.AccruePeriod(
priorAccrued: 200_000m * 0.5m,
notional: accrualPrincipal,
unwindFraction: 0.5m,
@@ -200,7 +200,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new FloatStubSvc(new StubIndexFixer(fixingAtReset));
svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent,
AnnualDays, false, floatRateIn, closePct, Notional, true, false, ref oldI, ref oldTd);
AnnualDays, floatRateIn, closePct, Notional, true, false, ref oldI, ref oldTd);
// 新方法:手算 segmentRates(对齐旧代码取价循环的逻辑)
// 4/21 <= preEodDate(4/30) → 跳过取价,currentFloat 保持入参 floatRateIn
@@ -215,7 +215,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
// 差分本金 = preEod.TdInterestPrincipal + posiPrincipal - orginPv
var accrualPrincipal = Notional + Notional - Notional;
var result = FundingLegAccrual.AccrueSimplePeriod(
var result = SimpleInterestAccrual.AccruePeriod(
priorAccrued: 200_000m * closePct,
notional: accrualPrincipal,
unwindFraction: closePct,
@@ -124,6 +124,17 @@ namespace YLErp.Modules.SwapModule
return new swap_event { id = SwapEvents.Count };
}
/// <summary>
/// 捕获 SaveAutoSwapDeal 落库的 flow_event(生产写 DbContext.swap_flow_event)。
/// 同步到 PersistedFlowEvents 供 AS_009/010/011 断言;基类 FlowEvents 仍由它填充,
/// 供 GetConsumedInterest 真实计算已结利息。
/// </summary>
protected override void PersistFlowEvent(swap_flow_event flowEvent)
{
base.PersistFlowEvent(flowEvent);
PersistedFlowEvents.Add(flowEvent);
}
/// <summary>
/// 利息腿金额直接给定(付息金额),避免把 GetInterests 的计息细节混入本用例——
/// 本文件关注的是「自动互换是否触发 / 几条 / 资金发生日 / 金额量级」,
@@ -509,7 +509,7 @@ namespace YLErp.Modules.SwapModule
decimal tdInterestAmount = 0m;
service.CalcDailyCompoundInterestByEod(preEod, resetDate, startDate, position,
principal, principal, flowEvent, AnnualDays, false, 0.013502m, 1m, principal,
principal, principal, flowEvent, AnnualDays, 0.013502m, 1m,
ref interestAmount, ref tdInterestAmount);
AssertDecimal(principal + pendingInterest, flowEvent.InterestPrincipal,
@@ -1724,15 +1724,15 @@ namespace YLErp.Modules.SwapModule
decimal expectedAmountAtEnd = 0m;
decimal expectedTdAmountAtEnd = 0m;
dealService.CalcDailyCompoundInterest(
finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays, false,
intermediateEod.FloatRate, 1m, originalNotional, true, false,
finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays,
intermediateEod.FloatRate, 1m, true, false,
ref expectedAmountAtEnd, ref expectedTdAmountAtEnd);
var expectedPreviousFlow = new swap_flow_event { InterestRate = spread };
decimal expectedAmountAtPreviousEod = 0m;
decimal expectedTdAmountAtPreviousEod = 0m;
dealService.CalcDailyCompoundInterest(
intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays, false,
intermediateEod.FloatRate, 1m, originalNotional, true, true,
intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays,
intermediateEod.FloatRate, 1m, true, true,
ref expectedAmountAtPreviousEod, ref expectedTdAmountAtPreviousEod);
var expectedFinalInterest = intermediateEod.InterestIncomeSum
+ expectedAmountAtEnd - expectedAmountAtPreviousEod;
@@ -6,16 +6,14 @@ using YLErp.Modules.SwapModule.FundingLegs;
namespace UnitTestProject.Modules.SwapModule.FundingLegs
{
/// <summary>
/// 融资腿策略单测。验证每个策略的 CalcNotional 与现有 CalcNotionalByMode switch 完全一致
/// 这组测试是后续"迁移调用点"的安全网——迁移前后行为必须不变
/// 融资腿策略单测。验证每个 IFundingLegStrategy 实现的 CalcNotional 计息基数公式正确
/// 原 CalcNotionalByMode switch 已重构为策略类(见 FundingLegStrategyFactory
/// </summary>
[TestClass]
public class FundingLegStrategyTest
{
private const decimal Fix = 2_000_000m;
private const decimal Notional = 100_000_000m;
private const decimal LongNotional = 60_000_000m;
private const decimal ShortNotional = 40_000_000m;
#region (mode 1)
@@ -23,7 +21,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _部分平仓_计息基数恒等于Fix()
{
var leg = new FixedAmountLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m);
var r = leg.CalcNotional(Fix, Notional, 0.5m);
Assert.AreEqual(Fix, r.ClosePrincipal, "平仓本金恒=Fix");
Assert.AreEqual(Fix, r.PosiPrincipal, "持仓本金恒=Fix");
@@ -34,7 +32,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _全平_计息基数仍等于Fix()
{
var leg = new FixedAmountLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m);
var r = leg.CalcNotional(Fix, Notional, 1m);
Assert.AreEqual(Fix, r.ClosePrincipal);
}
@@ -46,7 +44,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _部分平仓_本金按比例缩放()
{
var leg = new ContractNotionalLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m);
var r = leg.CalcNotional(Fix, Notional, 0.5m);
Assert.AreEqual(50_000_000m, r.ClosePrincipal);
Assert.AreEqual(Notional, r.PosiPrincipal);
@@ -57,7 +55,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _全平_本金等于全额()
{
var leg = new ContractNotionalLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m);
var r = leg.CalcNotional(Fix, Notional, 1m);
Assert.AreEqual(Notional, r.ClosePrincipal);
}
@@ -65,7 +63,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _零平仓_本金为零()
{
var leg = new ContractNotionalLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0m);
var r = leg.CalcNotional(Fix, Notional, 0m);
Assert.AreEqual(0m, r.ClosePrincipal);
Assert.AreEqual(Notional, r.PosiPrincipal);
@@ -79,7 +77,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _部分平仓_主路径公式同mode2()
{
var leg = new UnderlyingEntryFullPriceLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m);
var r = leg.CalcNotional(Fix, Notional, 0.5m);
Assert.AreEqual(50_000_000m, r.ClosePrincipal);
Assert.AreEqual(Notional, r.PosiPrincipal);
@@ -90,7 +88,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _全平_本金等于全额()
{
var leg = new UnderlyingEntryFullPriceLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m);
var r = leg.CalcNotional(Fix, Notional, 1m);
Assert.AreEqual(Notional, r.ClosePrincipal);
}
@@ -0,0 +1,112 @@
using YLErp.Modules.EodModule;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GLMS-20260105-0006 回归:债券 TRS 登记日当天手动平仓/互换,分红收益应为 36160 而非 0。
/// 根因双成因:
/// A. BondPaymentService.GetBondPayments 用支付日(pay_date_PL/pay_date_act)而非债权登记日(reg_date)判定谁享有票息
/// -> 登记日(4/3)当日 EOD 不计提,跨过支付日(4/6)才计提(巧合:4/4-4/5周末,下一交易日恰=支付日,掩盖缺陷)
/// B. SwapDealService.GetPreEodDividendSum 用 ValueDate 严格小于 dealDate 读 T-1 EOD 快照
/// -> 登记日当天手动平仓读不到当日 EOD,拿到 0
/// 本文件用手工合成内存数据(不连 96 库),通过 virtual seam 注入,真实跑生产日期逻辑。
/// </summary>
[TestClass]
public class GLMS20260105_0006_RegisterDateDividendTest
{
private const string BondCode = "230004.IB";
private const int TradeId = 6006;
private const long PositionId = 60061;
private const decimal Qty = 20_000_000m;
private const decimal PaymentPer100 = 0.1808m;
private const decimal ExpectedDividend = 36_160m; // 20,000,000 × 0.1808 / 100
// 付息日历(截图):登记日 4/3,支付日 4/6
private static readonly DateTime RegDate = new(2026, 4, 3);
private static readonly DateTime PayDate = new(2026, 4, 6);
private static readonly DateTime PreRegDate = new(2026, 4, 2);
#region A seam
private sealed class TestableBondPaymentService : BondPaymentService
{
private readonly List<BondPayment> _data;
public TestableBondPaymentService(List<BondPayment> data) : base(OptUserInfo.UnitTestUser) { _data = data; }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
[TestMethod]
public void CauseA_登记日当日EOD_应按登记日口径选中付息记录()
{
var record = new BondPayment
{
underlyingCode = BondCode,
reg_date = RegDate, // 债权登记日 4/3(关键:分红归属按此判定)
payment_date_pl = PayDate, // 理论付息日 4/6
payment_date = PayDate, // 实际付息日 4/6
payment_interest = PaymentPer100
};
var svc = new TestableBondPaymentService(new List<BondPayment> { record });
// 登记日当日的 EOD 计提区间 (4/2, 4/3]
var payments = svc.GetBondPayments(BondCode, PreRegDate, RegDate);
// 修复前:用支付日(pay_date_PL=4/6)过滤 -> 4/6 不在 (4/2,4/3] -> 0 条(漏计分红)
// 修复后:用债权登记日(reg_date=4/3)过滤 -> 4/3 落在区间 -> 1 条(GLMS-20260105-0006 已修复)
Assert.AreEqual(1, payments.Count,
"登记日(4/3)当日 EOD 应按债权登记日(reg_date)选中该笔付息;" +
"当前按支付日(pay_date_PL=4/6)过滤会漏选->0条,导致分红不计提。");
}
#endregion
#region BT-1 seam
private sealed class TestableSwapDealService : SwapDealService
{
private readonly List<eod_swap> _eodSwaps;
private readonly List<eod_swap_position> _eodPositions;
public TestableSwapDealService(List<eod_swap> eodSwaps, List<eod_swap_position> eodPositions)
: base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; }
public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
=> GetPreEodDividendSum(tradeId, positionId, dealDate);
protected override IQueryable<eod_swap> QueryPreEodSwaps(int tradeId)
=> _eodSwaps.Where(x => x.SwapTradeId == tradeId).AsQueryable();
protected override eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate)
=> _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate);
}
[TestMethod]
public void CauseB_登记日当天手动平仓_应读到当日EOD分红36160()
{
// 4/2 EOD:累计分红 0;4/3 EOD(登记日):累计分红 36160(即登记日应有的状态)
var eodSwaps = new List<eod_swap>
{
new eod_swap { SwapTradeId = TradeId, ValueDate = PreRegDate },
new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate }
};
var eodPositions = new List<eod_swap_position>
{
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = PreRegDate, PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = RegDate, PosiDividendSum = ExpectedDividend, PosiQuantity = Qty }
};
var svc = new TestableSwapDealService(eodSwaps, eodPositions);
// 登记日(4/3)当天手动平仓
var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, RegDate);
// 修复前:ValueDate 严格小于 dealDate 读 T-1(4/2) -> 0(漏读当日分红)
// 修复后:ValueDate 小于等于 dealDate 读当日(4/3) -> 36160GLMS-20260105-0006 已修复)
Assert.AreEqual(ExpectedDividend, dividend, 0.01m,
"登记日(4/3)当天手动平仓应读到当日 EOD 累计分红 36,160" +
"当前 GetPreEodDividendSum 用 ValueDate < dealDate 读 T-1 快照->0。");
}
#endregion
}
}
@@ -246,8 +246,19 @@ namespace YLErp.Modules.SwapModule
var valueDate = DateTime.Today;
var unwindDate = DateTime.Today;
var interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.);
var interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.);
// FR007 fixing 是外部数据依赖:非交易日/数据未发布时取价会抛 Exception。
// 与"连不上库自动 Inconclusive"同语义——外部数据不可用不应判为测试失败。
List<swap_flow_event> interests100, interests40;
try
{
interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.);
interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.);
}
catch (Exception ex) when (ex.Message.Contains("获取不到") && ex.Message.Contains("价格"))
{
Assert.Inconclusive($"FR007 fixing 数据不可用({valueDate:yyyy-MM-dd} 非交易日或数据未发布):{ex.Message}");
return;
}
PrintInterestComparison(interests100, interests40, cp100_B, cp40_B);
}
@@ -1,4 +1,3 @@
using System.Reflection;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
@@ -7,26 +6,10 @@ namespace YLErp.Modules.SwapModule
public class InitUnwindTradingFeeTest
{
private static decimal InvokeCalcInitTradingFee(swap_position position, UnwindData unwindData)
{
var method = typeof(SwapDealService).GetMethod(
"CalcInitTradingFee",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method, "未找到 CalcInitTradingFee 私有静态方法");
return (decimal)method.Invoke(null, new object[] { position, unwindData });
}
=> TradingFeeCalc.CalcInitTradingFee(position, unwindData);
private static decimal InvokeCalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData)
{
var method = typeof(SwapDealService).GetMethod(
"CalcInitTradingFeePending",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method, "CalcInitTradingFeePending was not found");
return (decimal)method.Invoke(null, new object[] { oriPosition, position, unwindData });
}
=> TradingFeeCalc.CalcInitTradingFeePending(oriPosition, position, unwindData);
[TestMethod]
public void _按平仓名义本金计算并四舍五入到两位()
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Margin;
namespace UnitTestProject.Modules.SwapModule.Margin
{
/// <summary>
/// 黄金回放验证(连真实测试库 192.168.2.96):对真实保证金交易逐日 EOD 比对
/// GetInterests(settment=true,保证金分支现走 CalcMarginInterest) vs 直接调 CalcMarginInterest
/// 验证 GetInterests→CalcMarginInterest 接线的参数对齐(rate/posiPrincipal/preEod 等)正确。
///
/// 数据来自 96 库的真实保证金交易,覆盖追加预付金多行、多次部分平仓(InterestPrincipalFix 下台阶)、
/// 跨 EOD 续接等单元测试够不到的边界。作为保证金计息迁移后的真实库回归守护。
/// </summary>
[TestClass]
public class MarginInterestGoldenReplayTest
{
// 96 库里已确认含真实保证金腿的交易
private static readonly string[] TradeNumbers =
{
"GLMS-20260701-0008",
"GLMS-20260701-0013",
"GLMS-20260701-0006",
};
private sealed class StubSvc : SwapDealService
{
public StubSvc() : base(new OptUserInfo(0, nameof(MarginInterestGoldenReplayTest), OptUserFrom.UnitTest)) { }
}
/// <summary>
/// 逐交易、逐 EOD 日,比对保证金腿新旧计息 InterestAmount/TdInterestAmount。
/// 入参对齐口径(与 GetInterests 内部一致):
/// posiPrincipal = InterestPrincipalFixclosePrincipal = Fix×closePercent(EOD=1)
/// rate = oldEvt.InterestRate(严格取旧管线算出的 rate,消除 GetFixedRate 差异);
/// 方向 = FlipDirection(position.InterestDirection)GetInterests:742 对保证金翻转);
/// preEod = 该 PositionId 上一日终 eod_swap_positionannualDays/calcFirst/calcLast 来自 trade_extend。
/// </summary>
[TestMethod]
[TestCategory("DbDiagnose")]
public void _真实库_EOD逐日新旧比对()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(192.168.2.96){ex.Message}"); return; }
var svc = new StubSvc();
int totalCompared = 0, mismatches = 0, skipped = 0;
var diffLog = new StringBuilder();
foreach (var tradeNumber in TradeNumbers)
{
var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber);
if (td == null) { Console.WriteLine($"跳过:库无 {tradeNumber}"); skipped++; continue; }
var extend = db.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
int annualDays = extend?.ExtendObj.AnnualDays ?? 365;
bool calcFirst = extend?.ExtendObj.InterestCalcMode?.StartsWith("1") ?? true;
bool calcLast = extend?.ExtendObj.InterestCalcMode?.EndsWith("1") ?? true;
var marginPositions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid
&& (p.InterestMode == (int)InterestModeEnum.
|| p.InterestMode == (int)InterestModeEnum.))
.ToList();
if (marginPositions.Count == 0) { Console.WriteLine($"跳过:{tradeNumber} 无保证金腿"); skipped++; continue; }
// 该交易保证金腿的 EOD 日期序列
var eodDates = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && (e.InterestMode == 5 || e.InterestMode == 6))
.Select(e => e.ValueDate).Distinct().OrderBy(d => d).ToList();
Console.WriteLine($"===== {tradeNumber} (id={td.id}){marginPositions.Count} 条保证金腿,{eodDates.Count} 个 EOD 日 =====");
foreach (var valueDate in eodDates)
{
// 上一日终 preEod(取 eod_swap 最近 < valueDate 的日期)
var preDate = db.eod_swap
.Where(e => e.SwapTradeId == td.id && e.ValueDate < valueDate)
.OrderByDescending(e => e.ValueDate)
.Select(e => (DateTime?)e.ValueDate).FirstOrDefault();
var preEods = preDate == null
? new List<eod_swap_position>()
: db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && e.ValueDate == preDate.Value
&& (e.InterestMode == 5 || e.InterestMode == 6))
.ToList();
// 旧管线:GetInterests(settment=true)。保证金分支不用 posiNotionalValue/closePosiNotionalValue/grossPrice/orginPv,传 0。
List<swap_flow_event> oldList;
try
{
oldList = svc.GetInterests(td, extend, valueDate, valueDate,
preEods, marginPositions,
0m, 0m, 0m, 0m, 1.0m,
(int)SwapEventTypeEnum., tdClose: false, needPrice: false,
grossPrice: 0m, orginPv: 0m,
add: false, settment: true, newCalcLast: false, closeList: null);
}
catch (Exception ex)
{
Console.WriteLine($" {tradeNumber} @ {valueDate:yyyy-MM-dd} 旧管线异常:{ex.GetType().Name} {ex.Message}");
continue;
}
// 新方法:逐保证金腿
foreach (var pos in marginPositions)
{
var oldEvt = oldList.FirstOrDefault(i => i.PositionId == pos.id);
if (oldEvt == null) continue;
var preEod = preEods.FirstOrDefault(e => e.PositionId == pos.id) ?? new eod_swap_position { id = 0 };
var posClone = pos.Clone();
posClone.InterestDirection = MarginCalc.FlipDirection(pos.InterestDirection);
decimal rate = oldEvt.InterestRate; // 严格对齐旧管线 rate(含 GetFixedRate + Round(12)
swap_flow_event newEvt;
try
{
newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, posClone, rate,
pos.InterestPrincipalFix, pos.InterestPrincipalFix, 1.0m,
annualDays, calcFirst, calcLast, preEod,
(int)SwapEventTypeEnum., add: false, settment: true, swap: false);
}
catch (Exception ex)
{
mismatches++;
diffLog.AppendLine($"✗ {tradeNumber} PosId={pos.id} @ {valueDate:yyyy-MM-dd} 新方法异常:{ex.GetType().Name} {ex.Message}");
continue;
}
totalCompared++;
decimal diffI = Math.Abs(newEvt.InterestAmount - oldEvt.InterestAmount);
decimal diffTd = Math.Abs(newEvt.TdInterestAmount - oldEvt.TdInterestAmount);
const decimal tol = 0.000001m;
if (diffI > tol || diffTd > tol)
{
mismatches++;
diffLog.AppendLine($"✗ {tradeNumber} PosId={pos.id} Mode={pos.InterestMode} @ {valueDate:yyyy-MM-dd}: " +
$"旧 I={oldEvt.InterestAmount} Td={oldEvt.TdInterestAmount} | " +
$"新 I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount} | " +
$"diffI={diffI} diffTd={diffTd} | " +
$"preEod.id={preEod.id} TdIntPrin={preEod.TdInterestPrincipal} ProfitSum={preEod.InterestProfitSum} | " +
$"Fix={pos.InterestPrincipalFix} rate={rate} IntType={pos.InterestType} IsAnnualized={pos.IsAnnualized}");
}
}
}
}
Console.WriteLine($"\n===== 比对汇总:共 {totalCompared} 条,不一致 {mismatches} 条,跳过 {skipped} 个交易 =====");
if (diffLog.Length > 0) Console.WriteLine(diffLog.ToString());
Assert.IsTrue(mismatches == 0,
$"保证金新旧管线 EOD 真实库比对有 {mismatches}/{totalCompared} 条不一致——提交2 前必须解决(详见输出)");
}
}
}
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.Margin;
using YLErp.Derivatives.Interest;
namespace UnitTestProject.Modules.SwapModule.Margin
{
/// <summary>
/// 影子对账:保证金腿方法 CalcMarginInterestEOD 用昨日终本金、盘中用 accrualBasis 差分)vs
/// 旧通用管线 CalcDailySimpleInterestByEod/CalcDailySimpleInterest。
///
/// 保证金是纯固定利率单利(FloatRateUnderlyingCode 恒空、InterestType 恒单利、SwapIntervalList 单段)。
/// 本测试在生产切到 CalcMarginInterest 后作为回归守护,确认其 InterestAmount/TdInterestAmount
/// 与旧纯函数(SimpleInterestAccrual)数值一致。覆盖 EOD 续接/首日、盘中全平/部分平仓/互换。
/// </summary>
[TestClass]
public class MarginInterestShadowTest
{
private const decimal Principal = 2_000_000m; // 保证金本金(InterestPrincipalFix
private const decimal Rate = 0.03m; // 3% 年化固定利率
private const int AnnualDays = 365;
private static readonly DateTime StartDate = new(2026, 7, 1);
private static readonly DateTime ExerciseDate = new(2027, 6, 30);
private sealed class StubSvc : SwapDealService
{
public StubSvc() : base(new OptUserInfo(0, nameof(MarginInterestShadowTest), OptUserFrom.UnitTest)) { }
}
private static trade CreateTrade() => new trade
{
id = 1, TradeNumber = "UT-MARGIN-SHADOW", ClientId = 999998,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
trade_extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{ AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0 })
}
};
/// <summary>保证金腿(初始预付金 mode 5):固定利率、单利、年化、无浮动标的。</summary>
private static swap_position CreateMarginPosition() => new swap_position
{
id = 2001, SwapTradeId = 1, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = Rate, InterestPrincipalFix = Principal,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = 1, interest_rule = 0,
FloatRateUnderlyingCode = null, InterestSwapInterval = "[]"
};
/// <summary>构造昨日终 eod_swap_position(已含累计利息 InterestProfitSum 与昨日终本金)。</summary>
private static eod_swap_position CreatePreEod(DateTime valueDate, decimal profitSum) => new eod_swap_position
{
id = 1, SwapTradeId = 1, PositionId = 2001,
ValueDate = valueDate,
TdInterestPrincipal = Principal, InterestPrincipalFix = Principal,
InterestProfitSum = profitSum, PosiNotionalValue = Principal, FloatRate = 0m
};
// ──────────────────────────── EOD 路径 ────────────────────────────
/// <summary>EOD 续接单日:有历史归档,notional=昨日终本金。</summary>
[TestMethod]
public void _EOD续接单日_新旧一致()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate.AddDays(5);
const decimal profitSum = 820m;
// 旧方法
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailySimpleInterestByEod(CreatePreEod(StartDate.AddDays(4), profitSum),
valueDate, td.StartDate.Value, position, Principal, Principal,
new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, ref oldI, ref oldTd);
// 新方法(独立 preEod,相同初始值)
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m,
AnnualDays, calcFirst: true, calcLast: true,
CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: true, swap: false);
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount} ClosePnL={newEvt.InterestClosePnL}");
Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致");
Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致");
}
/// <summary>EOD 首日(preEod.id==0):首日初始化 notional=posiPrincipal。</summary>
[TestMethod]
public void _EOD首日_新旧一致()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate;
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailySimpleInterestByEod(new eod_swap_position { id = 0 },
valueDate, td.StartDate.Value, position, Principal, Principal,
new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, ref oldI, ref oldTd);
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m,
AnnualDays, calcFirst: true, calcLast: true,
new eod_swap_position { id = 0 }, 0, add: false, settment: true, swap: false);
Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致");
Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致");
}
// ──────────────────────────── 盘中路径 ────────────────────────────
/// <summary>盘中全平(closePercent=1):新方法 notional=posiPrincipal,旧方法差分 accrualBasis 恒=posiPrincipal。</summary>
[TestMethod]
public void _盘中全平_新旧一致()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate.AddDays(5);
const decimal profitSum = 820m;
// 旧方法:orginPv 经 PreviousBalance 对齐到昨日终保证金余额 → accrualBasis 恒= Principal
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
var preEodOld = CreatePreEod(StartDate.AddDays(4), profitSum);
decimal orginPv = MarginCalc.PreviousBalance(preEodOld, Principal);
svc.CalcDailySimpleInterest(preEodOld, valueDate, position, Principal,
new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, orginPv,
calcFirst: true, calcLast: false, ref oldI, ref oldTd);
// 新方法:notional = posiPrincipal(无差分、无 orginPv
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m,
AnnualDays, calcFirst: true, calcLast: false,
CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: false, swap: false);
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount}");
Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致");
Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致");
}
/// <summary>盘中部分平仓(closePercent=0.5):缩放累计,新旧线性等价。</summary>
[TestMethod]
public void _盘中部分平仓_新旧一致()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate.AddDays(5);
const decimal profitSum = 820m;
const decimal closePct = 0.5m;
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
var preEodOld = CreatePreEod(StartDate.AddDays(4), profitSum);
decimal orginPv = MarginCalc.PreviousBalance(preEodOld, Principal);
svc.CalcDailySimpleInterest(preEodOld, valueDate, position, Principal,
new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, closePct, orginPv,
calcFirst: true, calcLast: false, ref oldI, ref oldTd);
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate,
Principal * closePct, Principal, closePct,
AnnualDays, calcFirst: true, calcLast: false,
CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: false, swap: false);
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount}");
Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致");
Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致");
}
/// <summary>互换事件(swap=true,盘中):利息应归零。</summary>
[TestMethod]
public void _盘中互换_利息归零()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate.AddDays(5);
var svc = new StubSvc();
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m,
AnnualDays, calcFirst: true, calcLast: false,
CreatePreEod(StartDate.AddDays(4), 820m), 0, add: false, settment: false, swap: true);
Assert.AreEqual(0m, newEvt.InterestAmount, "互换利息归零");
Assert.AreEqual(0m, newEvt.TdInterestAmount, "互换 TdInterestAmount 归零");
Assert.AreEqual(0m, newEvt.InterestClosePnL, "互换 InterestClosePnL 归零");
}
}
}
@@ -5,12 +5,12 @@ using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 诊断测试:验证「浮动腿 fpositions 仍用 origPositions(orig 100M)」对本 deal 的
/// 预付金/返回预付金结果是否产生影响。结论预期:本 deal 利息腿只有 mode 9(标的期初全价)
/// 与 mode 5(初始预付金)CalcNotionalByMode 中 posiLong/posiShort 仅在「多头/空头存续名义本金」
/// 分支被消费(L709-716),故本 deal 即便 fpositions 用 orig 100M,预付金腿结果也不受其影响。
/// 本测试仅做诊断/验证,不改动任何生产代码;用反射调用 private CalcNotionalByMode 以直接证明
/// “mode 9 / mode 5 的 closePrincipal 不依赖 posiLong/posiShort”
/// 诊断测试骨架:针对 GLMS 双轨持仓(orig/real)构造预付金腿(mode 5)与标的期初全价腿(mode 9)
/// 用于验证“浮动腿 fpositions 用 origPositions 对预付金/标的端计息基数的影响”。
/// 计息基数现由 FundingLegStrategyFactory + 各 IFundingLegStrategy 策略类计算
/// (原 private CalcNotionalByMode 已重构移除);多空存续腿的 posiLong/posiShort 因界面禁用
/// 已从策略接口删除,故预付金/标的端计息基数不依赖多空头寸。
/// 注:当前仅含数据构造,反射诊断方法尚未实现(无 [TestMethod]
/// </summary>
[TestClass]
public class SwapUnwindFloatingLegDiagnosticTdd
@@ -8,7 +8,7 @@ namespace YLErp.Modules.SwapModule
/// 【同日多次部分平仓 · unwind 基数滚动表征测试】
/// ============================================================================
/// 背景:unwind 计息基数公式 basis = priorNotional + notional - baseNotional
/// (FundingLegAccrual / CalcDailyCompoundInterestByEod 同源),其中
/// (CompoundInterestAccrual / CalcDailyCompoundInterestByEod 同源),其中
/// - priorNotional = 上一日终归档 eod_swap_position.TdInterestPrincipal
/// - baseNotional = orginPv = ResolveUnwindPreviousNotional(lastEod)(上一日终浮动端名义本金)
/// - notional = 当前持仓名义本金(posiNotionalValue,来自实时持仓)
@@ -62,7 +62,7 @@ namespace YLErp.Modules.SwapModule
/// 捕获真实收盘产生的 swap_flow_event(生产写 DbContext.swap_flow_event)。
/// 与 PersistEodSwapPosition 同理,这里只收集不写库,供 GetConsumedInterest 真实计算。
/// </summary>
protected void PersistFlowEvent(swap_flow_event flowEvent)
protected override void PersistFlowEvent(swap_flow_event flowEvent)
{
if (flowEvent.id == 0) flowEvent.id = _nextId++;
FlowEvents.Add(flowEvent);
@@ -83,5 +83,16 @@ namespace YLErp.Modules.SwapModule
ClientCashCalls.Add((amount, action));
return _nextId++;
}
/// <summary>
/// AddClientCashInCashOut 生产实现会查 DataCacheProvider.GetClientDataSource().GetData(ClientId)
/// 纯内存测试无客户缓存会抛"客户信息未找到"。与 AddClientCash 同构 no-op
/// 仅捕获调用记录,供断言使用。
/// </summary>
public override int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate)
{
ClientCashCalls.Add((amount, action));
return _nextId++;
}
}
}
@@ -98,10 +98,21 @@ namespace YLErp.Modules.EodModule
/// <returns></returns>
public List<BondPayment> GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate)
{
var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode && x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList();
// GLMS-20260105-0006:票息归属按债权登记日(reg_date)判定,而非支付日(pay_date_PL/pay_date_act)。
// 登记日当天 EOD 即应计提;原按支付日口径会让"登记日≠支付日"的债券漏计(二者恰差一工作日时缺陷被掩盖)。
var result = QueryBondPayments(underlyingCode)
.Where(x => x.reg_date > startDate && x.reg_date <= endDate)
.AsNoTracking().ToList();
return result;
}
/// <summary>
/// 可测性 seam:返回某债券的全部付息记录(未做日期过滤)。测试可 override 注入内存数据,
/// 以验证日期口径(GLMS-20260105-0006:应按债权登记日 reg_date 而非支付日 pay_date_PL/pay_date_act 判定)。
/// </summary>
protected virtual IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode);
public List<BondPayment> GetTargetDatePayments(string underlyingCode, DateTime targetDate)
{
@@ -29,4 +29,8 @@ public sealed class AccrualPolicy
public AccrualPolicy(AccrualBoundary convention, bool isCompound, int resetPeriodDays, int annualDays, bool isAnnualized = false)
=> (Convention, IsCompound, ResetPeriodDays, AnnualDays, IsAnnualized) = (convention, isCompound, resetPeriodDays, annualDays, isAnnualized);
/// <summary>从 swap_position 构造 EOD 计息政策(算头算尾,重置周期取 interest_rest_days)。</summary>
public static AccrualPolicy BuildEod(DBModels.swap_position position, int annualDays, bool isCompound)
=> new(AccrualBoundary.Both, isCompound, position.interest_rest_days ?? 1, annualDays, position.IsAnnualized);
}
@@ -0,0 +1,132 @@
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
/// 复利计息纯函数——EOD 单日 + intraday 多日。
/// 复利特征:每个重置日把累计利息并入本金(basis = notional + accrued)。
/// </summary>
public static class CompoundInterestAccrual
{
private const int Precision = SwapInterest.FundingLegPrecision;
/// <summary>复利日终计息基数(单一真相源,纯函数与调用方共用):
/// 重置日 = notional + 累计利息×剩余比例(利息并入本金);非重置日 = priorNotional(昨日滚动基数)。
/// remainingFraction 对齐 legacy 钳制到 [0,1]。</summary>
public static decimal EodBasis(
bool isResetDay, decimal notional, decimal priorAccrued, decimal remainingFraction, decimal priorNotional)
=> isResetDay
? notional + priorAccrued * Math.Max(0m, Math.Min(1m, remainingFraction))
: priorNotional;
/// <summary>
/// 复利日终计息(替换 CalcDailyCompoundInterestByEod 的纯数学部分)。
/// 重置日:basis = notional + priorAccrued × remainingFraction(利息并入本金)。
/// 非重置日:basis = priorNotional(昨日终滚动计息基数)。
/// </summary>
public static InterestResult AccrueEod(
decimal priorAccrued,
decimal priorNotional,
decimal notional,
decimal unwindFraction,
FundingLegRate rate,
AccrualPolicy policy,
bool isResetDay,
decimal remainingFraction,
DateTime eodDate,
AccrualTrace? trace = null)
{
var basis = EodBasis(isResetDay, notional, priorAccrued, remainingFraction, priorNotional);
var displayBasis = basis * unwindFraction;
var allInRate = rate.AllInRate;
trace?.EodContext(eodDate, isResetDay, unwindFraction, priorAccrued, priorNotional, notional, remainingFraction);
var dayInterest = displayBasis * allInRate;
var tdInterest = basis * allInRate;
if (policy.IsAnnualized)
{
dayInterest /= policy.AnnualDays;
tdInterest /= policy.AnnualDays;
}
var totalAccrued = priorAccrued * unwindFraction + dayInterest;
var result = new InterestResult(
SwapInterest.Round(totalAccrued, Precision),
SwapInterest.Round(tdInterest, Precision));
trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
/// <summary>
/// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。
/// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。
/// </summary>
public static InterestResult AccruePeriod(
decimal notional,
IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary,
int annualDays,
bool isAnnualized,
decimal resetCarryInterest,
decimal realizedInterest,
decimal unwindFraction,
out decimal finalBasis,
AccrualTrace? trace = null)
{
decimal accrualBasis = notional;
decimal accrued = 0m;
trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized);
for (int si = 0; si < segmentRates.Count; si++)
{
var isLastSegment = si == segmentRates.Count - 1;
var segEnd = isLastSegment
? endDate
: segmentRates[si + 1].StartDate;
// 重置日并本金
accrualBasis = si == 0 ? notional : notional + accrued;
// 末日恰好是重置日且 carry 非零:用存量替代(旧代码 i%interestPeriod==0 && accrueDate==endDate)。
var usedCarry = false;
if (isLastSegment && si > 0 && resetCarryInterest != 0m
&& segmentRates[si].StartDate == endDate)
{
accrualBasis = notional + resetCarryInterest;
usedCarry = true;
}
if (si > 0)
trace?.Rollover(segmentRates[si].StartDate, usedCarry ? resetCarryInterest : accrued, accrualBasis);
var segIncludeStart = (si == 0) ? boundary.IncludeStart : true;
var segIncludeEnd = isLastSegment ? boundary.IncludeEnd : false;
var days = SwapInterest.AccrualDays(segmentRates[si].StartDate, segEnd,
AccrualBoundary.Of(segIncludeStart, segIncludeEnd));
if (days <= 0) continue;
var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate;
var segInterest = accrualBasis * dailyRate * days;
accrued += segInterest;
trace?.Segment(si, segmentRates[si].StartDate, segEnd, days, segmentRates[si].Rate, accrualBasis, segInterest, accrued);
}
finalBasis = accrualBasis;
if (realizedInterest != 0m)
trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction);
accrued -= realizedInterest * unwindFraction;
var result = new InterestResult(
SwapInterest.Round(accrued, Precision),
SwapInterest.Round(accrued, Precision));
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
}
@@ -1,239 +0,0 @@
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
/// 融资腿计息编排层——纯数学部分(替换 SwapDealService 内 CalcDaily* 家族的纯计算)。
///
/// 命名规范(对齐 QuantLib / Strata):
/// - notional → 计息名义本金(不用 principalswap leg 用 notional 是业界标准)
/// - accrued → 累计应计利息
/// - unwindFraction → 平仓比例(0~1
/// - realizedInterest → 历史已结利息(legacy: consumedInterest
/// - priorNotional → 昨日终滚动计息基数(legacy: TdInterestPrincipal / dynomicPrincipal
/// </summary>
public static class FundingLegAccrual
{
private const int Precision = SwapInterest.FundingLegPrecision;
/// <summary>复利日终计息基数(单一真相源,纯函数与调用方共用):
/// 重置日 = notional + 累计利息×剩余比例(利息并入本金);非重置日 = priorNotional(昨日滚动基数)。
/// remainingFraction 对齐 legacy 钳制到 [0,1]。</summary>
public static decimal CompoundEodBasis(
bool isResetDay, decimal notional, decimal priorAccrued, decimal remainingFraction, decimal priorNotional)
=> isResetDay
? notional + priorAccrued * Math.Max(0m, Math.Min(1m, remainingFraction))
: priorNotional;
/// <summary>
/// 单利日终计息(替换 CalcDailySimpleInterestByEod 的纯数学部分)。
/// EOD 无差分:basis = priorNotional(昨日终滚动计息基数)。
/// </summary>
public static InterestResult AccrueSimpleEod(
decimal priorAccrued,
decimal priorNotional,
decimal unwindFraction,
FundingLegRate rate,
AccrualPolicy policy,
DateTime eodDate,
AccrualTrace? trace = null)
{
var basis = priorNotional;
var displayBasis = basis * unwindFraction;
var allInRate = rate.AllInRate;
var dayInterest = displayBasis * allInRate;
var tdInterest = basis * allInRate;
if (policy.IsAnnualized)
{
dayInterest /= policy.AnnualDays;
tdInterest /= policy.AnnualDays;
}
var totalAccrued = priorAccrued + dayInterest;
var result = new InterestResult(
SwapInterest.Round(totalAccrued, Precision),
SwapInterest.Round(tdInterest, Precision));
trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
/// <summary>
/// 复利日终计息(替换 CalcDailyCompoundInterestByEod 的纯数学部分)。
/// 重置日:basis = notional + priorAccrued × remainingFraction(利息并入本金)。
/// 非重置日:basis = priorNotional(昨日终滚动计息基数)。
/// </summary>
public static InterestResult AccrueCompoundEod(
decimal priorAccrued,
decimal priorNotional,
decimal notional,
decimal unwindFraction,
FundingLegRate rate,
AccrualPolicy policy,
bool isResetDay,
decimal remainingFraction,
DateTime eodDate,
AccrualTrace? trace = null)
{
var basis = CompoundEodBasis(isResetDay, notional, priorAccrued, remainingFraction, priorNotional);
var displayBasis = basis * unwindFraction;
var allInRate = rate.AllInRate;
trace?.EodContext(eodDate, isResetDay, unwindFraction, priorAccrued, priorNotional, notional, remainingFraction);
var dayInterest = displayBasis * allInRate;
var tdInterest = basis * allInRate;
if (policy.IsAnnualized)
{
dayInterest /= policy.AnnualDays;
tdInterest /= policy.AnnualDays;
}
var totalAccrued = priorAccrued * unwindFraction + dayInterest;
var result = new InterestResult(
SwapInterest.Round(totalAccrued, Precision),
SwapInterest.Round(tdInterest, Precision));
trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
/// <summary>
/// 单利多日计息(替换 CalcDailySimpleInterest 的纯数学部分)。
/// 本金全程恒定,按重置日分段取利率。
/// Accrued = 缩放累计(InterestAmount)AccruedToday = 未缩放累计(TdInterestAmount)。
/// </summary>
public static InterestResult AccrueSimplePeriod(
decimal priorAccrued,
decimal notional,
decimal unwindFraction,
IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates,
DateTime startDate,
DateTime endDate,
DateTime priorValueDate,
AccrualBoundary boundary,
int annualDays,
bool isAnnualized,
AccrualTrace? trace = null)
{
var displayBasis = notional * unwindFraction;
decimal accrued = priorAccrued; // 缩放累计 → InterestAmount
decimal accruedUnscaled = priorAccrued; // 未缩放累计 → TdInterestAmount
trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized);
var segStart = startDate;
for (int si = 0; si < segmentRates.Count; si++)
{
var segEnd = si < segmentRates.Count - 1
? segmentRates[si + 1].StartDate
: endDate;
var effectiveStart = segStart > priorValueDate ? segStart : priorValueDate.AddDays(1);
if (effectiveStart > segEnd) { segStart = segEnd; continue; }
// calcFirst 只跳过 startDate 本身;其余天(含重置日、ValueDate+1)只要 > ValueDate 恒纳入。
// 与旧逐日循环一致:if (!calcFirst && accrueDate == startDate) continue 是唯一的首日跳过。
// 中间段的 segIncludeStart 被 days<=0 跳过后误置 false,此处按 startDate 判定而非继承标记。
var includeStart = effectiveStart == startDate ? boundary.IncludeStart : true;
// calcLast 只影响 endDate 本身——只有真正的末段(si==Count-1)才算尾,
// 不能用 segEnd==endDate 判断(interestPeriod=1 时中间段 segEnd 也可能==endDate)。
var isLastSegment = si == segmentRates.Count - 1;
var segBoundary = AccrualBoundary.Of(includeStart, isLastSegment && boundary.IncludeEnd);
var days = SwapInterest.AccrualDays(effectiveStart, segEnd, segBoundary);
if (days <= 0) { segStart = segEnd; continue; }
var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate;
var segInterest = displayBasis * dailyRate * days;
accrued += segInterest;
accruedUnscaled += notional * dailyRate * days;
trace?.Segment(si, effectiveStart, segEnd, days, segmentRates[si].Rate, displayBasis, segInterest, accrued);
segStart = segEnd;
}
var result = new InterestResult(
SwapInterest.Round(accrued, Precision),
SwapInterest.Round(accruedUnscaled, Precision));
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
/// <summary>
/// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。
/// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。
/// </summary>
public static InterestResult AccrueCompoundPeriod(
decimal notional,
IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary,
int annualDays,
bool isAnnualized,
decimal resetCarryInterest,
decimal realizedInterest,
decimal unwindFraction,
out decimal finalBasis,
AccrualTrace? trace = null)
{
decimal accrualBasis = notional;
decimal accrued = 0m;
trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized);
for (int si = 0; si < segmentRates.Count; si++)
{
var isLastSegment = si == segmentRates.Count - 1;
var segEnd = isLastSegment
? endDate
: segmentRates[si + 1].StartDate;
// 重置日并本金
accrualBasis = si == 0 ? notional : notional + accrued;
// 末日恰好是重置日且 carry 非零:用存量替代(旧代码 i%interestPeriod==0 && accrueDate==endDate)。
// 注意:必须同时判断 startDate==endDate——endDate 非重置日时最后一段起点 < endDate,不应触发。
var usedCarry = false;
if (isLastSegment && si > 0 && resetCarryInterest != 0m
&& segmentRates[si].StartDate == endDate)
{
accrualBasis = notional + resetCarryInterest;
usedCarry = true;
}
// 复利每段起点:记录并本金瞬间(非首段 = 利息滚入计息基数)
if (si > 0)
trace?.Rollover(segmentRates[si].StartDate, usedCarry ? resetCarryInterest : accrued, accrualBasis);
// 半开区间:重置日归下一段(旧代码逐日循环中重置日先更新本金再算息)
var segIncludeStart = (si == 0) ? boundary.IncludeStart : true;
var segIncludeEnd = isLastSegment ? boundary.IncludeEnd : false;
var days = SwapInterest.AccrualDays(segmentRates[si].StartDate, segEnd,
AccrualBoundary.Of(segIncludeStart, segIncludeEnd));
if (days <= 0) continue;
var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate;
var segInterest = accrualBasis * dailyRate * days;
accrued += segInterest;
trace?.Segment(si, segmentRates[si].StartDate, segEnd, days, segmentRates[si].Rate, accrualBasis, segInterest, accrued);
}
finalBasis = accrualBasis;
// 扣除历史已结利息
if (realizedInterest != 0m)
trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction);
accrued -= realizedInterest * unwindFraction;
var result = new InterestResult(
SwapInterest.Round(accrued, Precision),
SwapInterest.Round(accrued, Precision));
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
}
@@ -1,3 +1,5 @@
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
@@ -26,4 +28,10 @@ public readonly struct FundingLegRate
/// <summary>构造浮动腿利率(all-in = 加点利差 + 指数定盘)。</summary>
public static FundingLegRate Floating(decimal spread, decimal indexFixing)
=> new(spread + indexFixing);
/// <summary>从 swap_position 构造:固定腿→Fixed(spread),浮动腿→Floating(spread+fixing)。</summary>
public static FundingLegRate Build(swap_position position, decimal spread, decimal effectiveFloat)
=> string.IsNullOrEmpty(position.FloatRateUnderlyingCode)
? Fixed(spread)
: Floating(spread, effectiveFloat);
}
@@ -0,0 +1,106 @@
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
/// 单利计息纯函数——EOD 单日 + intraday 多日。
/// 单利特征:本金全程恒定(无并本金),按重置日分段取利率。
/// </summary>
public static class SimpleInterestAccrual
{
private const int Precision = SwapInterest.FundingLegPrecision;
/// <summary>
/// 单利日终计息(替换 CalcDailySimpleInterestByEod 的纯数学部分)。
/// EOD 无差分:basis = priorNotional(昨日终滚动计息基数)。
/// </summary>
public static InterestResult AccrueEod(
decimal priorAccrued,
decimal priorNotional,
decimal unwindFraction,
FundingLegRate rate,
AccrualPolicy policy,
DateTime eodDate,
AccrualTrace? trace = null)
{
var basis = priorNotional;
var displayBasis = basis * unwindFraction;
var allInRate = rate.AllInRate;
var dayInterest = displayBasis * allInRate;
var tdInterest = basis * allInRate;
if (policy.IsAnnualized)
{
dayInterest /= policy.AnnualDays;
tdInterest /= policy.AnnualDays;
}
var totalAccrued = priorAccrued + dayInterest;
var result = new InterestResult(
SwapInterest.Round(totalAccrued, Precision),
SwapInterest.Round(tdInterest, Precision));
trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
/// <summary>
/// 单利多日计息(替换 CalcDailySimpleInterest 的纯数学部分)。
/// 本金全程恒定,按重置日分段取利率。
/// Accrued = 缩放累计(InterestAmount)AccruedToday = 未缩放累计(TdInterestAmount)。
/// </summary>
public static InterestResult AccruePeriod(
decimal priorAccrued,
decimal notional,
decimal unwindFraction,
IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates,
DateTime startDate,
DateTime endDate,
DateTime priorValueDate,
AccrualBoundary boundary,
int annualDays,
bool isAnnualized,
AccrualTrace? trace = null)
{
var displayBasis = notional * unwindFraction;
decimal accrued = priorAccrued; // 缩放累计 → InterestAmount
decimal accruedUnscaled = priorAccrued; // 未缩放累计 → TdInterestAmount
trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized);
var segStart = startDate;
for (int si = 0; si < segmentRates.Count; si++)
{
var segEnd = si < segmentRates.Count - 1
? segmentRates[si + 1].StartDate
: endDate;
var effectiveStart = segStart > priorValueDate ? segStart : priorValueDate.AddDays(1);
if (effectiveStart > segEnd) { segStart = segEnd; continue; }
// calcFirst 只跳过 startDate 本身;其余天(含重置日、ValueDate+1)只要 > ValueDate 恒纳入。
var includeStart = effectiveStart == startDate ? boundary.IncludeStart : true;
var isLastSegment = si == segmentRates.Count - 1;
var segBoundary = AccrualBoundary.Of(includeStart, isLastSegment && boundary.IncludeEnd);
var days = SwapInterest.AccrualDays(effectiveStart, segEnd, segBoundary);
if (days <= 0) { segStart = segEnd; continue; }
var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate;
var segInterest = displayBasis * dailyRate * days;
accrued += segInterest;
accruedUnscaled += notional * dailyRate * days;
trace?.Segment(si, effectiveStart, segEnd, days, segmentRates[si].Rate, displayBasis, segInterest, accrued);
segStart = segEnd;
}
var result = new InterestResult(
SwapInterest.Round(accrued, Precision),
SwapInterest.Round(accruedUnscaled, Precision));
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
}
@@ -0,0 +1,65 @@
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule;
/// <summary>
/// 平仓比例(ClosePercent) 数学——占期初(A) / 占剩余(B) 两种口径的转换。
/// 从 SwapDealService 提取为共享模块,两个 service 均可引用。
/// </summary>
public static class ClosePercentMath
{
/// <summary>
/// 取上一日终的浮动端名义本金(orginPv 的来源)。
/// 优先取浮动腿 PosiNotionalValue 之和,取不到用 eod_swap 多空绝对值之和,都没有用 currentNotional 兜底。
/// </summary>
public static decimal ResolveUnwindPreviousNotional(
eod_swap lastEod,
IEnumerable<eod_swap_position> lastEodPositions,
decimal currentNotional)
{
var floatingPositions = lastEodPositions?.Where(x => x.PosiDirection > 0).ToList();
decimal previousNotional;
if (floatingPositions?.Count > 0)
{
previousNotional = floatingPositions.Sum(x => x.PosiNotionalValue);
}
else
{
previousNotional = lastEod == null
? currentNotional
: Math.Abs(lastEod.NotionalValueLong) + Math.Abs(lastEod.NotionalValueShort);
}
return previousNotional == 0m && currentNotional != 0m
? currentNotional
: previousNotional;
}
/// <summary>
/// A(占期初) → B(占剩余),用于把前端传入的占期初比例换算成后端计算用的占剩余比例。
/// </summary>
public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue)
{
if (posiNotionalValue <= 0) return originalClosePercent;
var remaining = originalClosePercent * notionalValue / posiNotionalValue;
return remaining > 1 ? 1 : remaining;
}
/// <summary>
/// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。
/// </summary>
public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue)
{
if (notionalValue <= 0) return remainingClosePercent;
return remainingClosePercent * posiNotionalValue / notionalValue;
}
/// <summary>
/// 计算 InitUnwind 默认占期初(A)平仓比例 = PosiNotionalValue / NotionalValue。
/// 未平仓时 =1(平100%);部分平仓后自动变为剩余比例。
/// </summary>
public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue)
{
return notionalValue > 0 ? posiNotionalValue / notionalValue : 1;
}
}
@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换日终盈亏/精度计算纯函数集合。
/// 自 SwapEodPositionService 抽出,支持无库单测;同类内部调用无需前缀。
/// </summary>
public static class EodPnlCalculator
{
// 日终利息待实现需跨日累计,按表设计保留 12 位;已实现结算仍按金额两位处理。
private const int EodInterestStoragePrecision = 12;
internal static decimal RoundMoney(decimal value)
{
return Math.Round(value, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
internal static decimal RoundEodInterest(decimal value)
{
return Math.Round(value, EodInterestStoragePrecision, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 仅在写入 eod_swap_position 前统一快照精度。
/// 浮动腿收益最终以金额两位展示和存储;利息腿的待实现、计息基数及利率保留 12 位,
/// 使部分结算后的尾差可继续参与后续计息。
/// </summary>
internal static void NormalizeEodPositionForStorage(eod_swap_position position)
{
if (string.IsNullOrEmpty(position.UnderlyingCode))
{
// 利息腿没有标的代码:待实现字段保留高精度,已实现结算字段收敛到金额两位。
position.InterestPrincipalFix = RoundEodInterest(position.InterestPrincipalFix);
position.InterestRateDefault = RoundEodInterest(position.InterestRateDefault);
position.InterestFeePending = RoundEodInterest(position.InterestFeePending);
position.TdInterestPrincipal = RoundEodInterest(position.TdInterestPrincipal);
position.TdInterestRate = RoundEodInterest(position.TdInterestRate);
position.TdInterestIncome = RoundEodInterest(position.TdInterestIncome);
position.TdInterestFee = RoundEodInterest(position.TdInterestFee);
position.InterestIncomeSum = RoundEodInterest(position.InterestIncomeSum);
position.InterestFeeSum = RoundEodInterest(position.InterestFeeSum);
position.InterestProfitSum = RoundEodInterest(position.InterestProfitSum);
position.FloatRate = RoundEodInterest(position.FloatRate);
position.SwapPositionValue = RoundEodInterest(position.SwapPositionValue);
position.TdCloseInterest = RoundMoney(position.TdCloseInterest);
position.TdCloseInterestFee = RoundMoney(position.TdCloseInterestFee);
position.RealizedInterest = RoundMoney(position.RealizedInterest);
position.RealizedInterestFee = RoundMoney(position.RealizedInterestFee);
}
else
{
// 浮动腿有标的代码:其损益作为金额结果落库,统一按两位四舍五入。
position.TdPosiDividend = RoundMoney(position.TdPosiDividend);
position.PosiMtmPnL = RoundMoney(position.PosiMtmPnL);
position.PosiDividendSum = RoundMoney(position.PosiDividendSum);
position.PosiFeePending = RoundMoney(position.PosiFeePending);
position.PosiProfitSum = RoundMoney(position.PosiProfitSum);
position.TdCloseMtmPnl = RoundMoney(position.TdCloseMtmPnl);
position.TdCloseDividend = RoundMoney(position.TdCloseDividend);
position.TdCloseFee = RoundMoney(position.TdCloseFee);
position.RealizedMtmPnL = RoundMoney(position.RealizedMtmPnL);
position.RealizedDividend = RoundMoney(position.RealizedDividend);
position.RealizedFee = RoundMoney(position.RealizedFee);
position.SwapPositionValue = RoundMoney(position.SwapPositionValue);
}
position.RealizedPnl = RoundMoney(position.RealizedPnl);
}
/// <summary>
/// 浮动腿累计已实现盈亏由盯市、分红和费用三个已实现组成项汇总。
/// 各组成项已经按本方视角落库,此处不再额外转换方向。
/// </summary>
internal static void SetFloatingRealizedPnl(eod_swap_position position)
{
position.RealizedPnl = position.RealizedMtmPnL
+ position.RealizedDividend
+ position.RealizedFee;
}
/// <summary>
/// 汇总单条日终腿的我方已实现收益。
/// 浮动腿及普通利息腿维持数据库记录的方向;初始/追加预付金腿的利息
/// 则与保证金本金方向相反。这样“收取对手方保证金”产生的利息会作为
/// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。
/// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。
/// </summary>
public static decimal CalculateSwapRealizedPnl(eod_swap_position position)
{
var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
return position.RealizedMtmPnL
+ position.RealizedDividend
+ position.RealizedFee
+ position.RealizedInterest * interestRatio
+ position.RealizedInterestFee;
}
/// <summary>填充框架合约的持仓腿汇总字段(多空名义本金/市值/浮动盈亏/dv01/平仓量)。
/// SaveEodSwap 与 UpdateEodSwap 共用,消除 ~10 行重复。</summary>
internal static void FillPositionLegSummary(eod_swap eod_Swap, List<eod_swap_position> positions)
{
eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue);
eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue);
eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum);
eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0);
eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty);
}
/// <summary>利息腿 PnL 汇总(按方向比例 + 保证金翻转)。原 SaveEodSwap/UpdateEodSwap 各一段 ForEach。</summary>
internal static decimal SumInterestPnL(List<eod_swap_position> interestPositions)
{
decimal interestPnL = 0;
foreach (var x in interestPositions)
interestPnL += x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode);
return interestPnL;
}
/// <summary>
/// 风险报表符号归一化:把历史两种符号口径的 TdCloseInterest/RealizedInterest
/// 统一按"绝对金额 × 业务方向"重写。普通利息腿收取为正、支付为负;
/// 预付金腿利息方向与保证金本金方向相反。随后重算 RealizedPnl。
/// 抽为 public static 纯函数以支持无库单测(见 SwapReportInterestSignNormalizeTest)。
/// 仅当 InterestDirection > 0 时执行(与原内联逻辑等价)。
/// </summary>
public static void NormalizeInterestSignForReport(eod_swap_position position)
{
if (position.InterestDirection <= 0) return;
if (position.InterestMode == (int)InterestModeEnum.)
{
return;
}
var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio;
position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio;
// 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。
position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee;
}
/// <summary>
/// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权,
/// 不按收付方向轧差,避免相反方向本金抵消后放大利率。
/// </summary>
internal static decimal CalculateWeightedMarginRate(IEnumerable<swap_position> margins)
{
var marginList = margins.ToList();
var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix));
return totalWeight == 0
? 0
: marginList.Sum(x => x.InterestRateDefault * Math.Abs(x.InterestPrincipalFix)) / totalWeight;
}
/// <summary>
/// 计算预付金利息金额。InterestIncomeSum 已是各腿利息金额,
/// 按收取为正、支付为负直接轧差求和,不做本金加权。
/// 抽为 public static 纯函数以支持无库单测(见 SwapWeightedMarginInterestTest)。
/// </summary>
public static decimal CalculateWeightedMarginInterest(IEnumerable<eod_swap_position> margins)
{
return margins.Sum(x =>
x.InterestIncomeSum * DirectionRatio.ReceivePay(x.InterestDirection));
}
/// <summary>
/// 固定利息腿的累计已实现盈亏 = 累计已实现利息 + 累计已实现利息费用。
/// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径口径一致,
/// 抽为 public static 纯函数以支持无库单测(见 SwapFixedLegRealizedPnlTest),
/// 并消除复制粘贴带来的笔误风险(如 L1296 历史双分号)。
/// </summary>
public static void SetFixedLegRealizedPnl(eod_swap_position position)
{
position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee;
}
}
}
@@ -11,6 +11,6 @@ public sealed class ContractNotionalLeg : IFundingLegStrategy
{
public InterestModeEnum Mode => InterestModeEnum.;
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent)
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent)
=> new(posiNotional * closePercent, posiNotional, closePercent);
}
@@ -12,6 +12,6 @@ public sealed class FixedAmountLeg : IFundingLegStrategy
{
public InterestModeEnum Mode => InterestModeEnum.;
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent)
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent)
=> new(fix, fix, 1m);
}
@@ -22,10 +22,8 @@ public interface IFundingLegStrategy
/// </summary>
/// <param name="fix">合约固定本金(固定值/预付金腿用;其余腿忽略)。</param>
/// <param name="posiNotional">当前剩余名义本金(数量 × 全价)。</param>
/// <param name="posiLong">多头剩余名义本金(多空存续腿用,当前界面已禁用)。</param>
/// <param name="posiShort">空头剩余名义本金。</param>
/// <param name="closePercent">平仓比例(占剩余,0~1)。</param>
NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent);
NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent);
}
/// <summary>
@@ -14,6 +14,6 @@ public sealed class UnderlyingEntryFullPriceLeg : IFundingLegStrategy
{
public InterestModeEnum Mode => InterestModeEnum.;
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent)
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent)
=> new(posiNotional * closePercent, posiNotional, closePercent);
}
@@ -4,14 +4,12 @@ using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Margin;
/// <summary>
/// 保证金账户。管理保证金余额的变动(追加/释放/返还),并提供计息入口。
/// 保证金账户。管理保证金余额的变动(追加/释放/返还),并提供计息入口(预留抽象,尚未接线)
///
/// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保),
/// 与融资腿(funding leg)完全无关。现有代码把保证金塞进 InterestMode==5/6
/// 当计息腿处理是错误的,本类是正确建模的起点
///
/// 利息计算委托 SwapInterest 纯函数(余额×利率×天数/年化),
/// 保证金账户只提供余额和计息入口,不自己实现计息算法。
/// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保),与融资腿(funding leg)无关。
/// 生产保证金计息入口为 SwapDealService.CalcMarginInterest(仍以 InterestMode 5/6 标识):
/// EOD 用昨日终本金 preEod.TdInterestPrincipal(无差分);盘中用 accrualBasis 差分(orginPv 经 PreviousBalance
/// 本类尚未被生产代码实例化——其扁平"余额×利率×天数"模型无法表达盘中差分与多行分段,留作未来简化抽象。
/// </summary>
public sealed class MarginAccount
{
@@ -31,7 +29,8 @@ public sealed class MarginAccount
/// <summary>
/// 按当前余额计算保证金利息。委托 SwapInterest.AccrueSimple。
/// 保证金利息是券商对客户保证金存款付息(方向与融资腿相反)。
/// 注意:当前未被生产代码调用——生产保证金计息入口为 SwapDealService.CalcMarginInterest
/// (处理 EOD 昨日终本金与盘中差分;本方法的扁平余额模型不覆盖盘中差分口径)。
/// </summary>
/// <param name="rate">保证金利率(年化,如 0.03 = 3%)。</param>
/// <param name="startDate">计息开始日。</param>
@@ -4,7 +4,7 @@ namespace YLErp.Modules.SwapModule.Margin;
/// 保证金余额。现金、授信、担保等多种保证金形态的统一表达。
///
/// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。
/// 余额随追加/释放/盈亏变动,利息由 SwapInterest 纯函数按 余额×利率×天数/年化 计算。
/// 余额随追加/释放/盈亏变动,利息由计息层(SwapDealService.CalcMarginInterest)按 EOD 昨日终本金 / 盘中差分口径计算。
/// </summary>
public readonly struct MarginBalance
{
@@ -41,4 +41,17 @@ public static class MarginModes
/// <summary>判断 mode 是否属于保证金(非 LINQ 场景用)。</summary>
public static bool Contains(int interestMode) => All.Contains(interestMode);
/// <summary>固定值 + 保证金 mode 集合(固定值/初始预付金/追加预付金)。
/// 用于 EOD 场景判断"计息基数取 InterestPrincipalFix 而非持仓名义本金"的腿。
/// 替代 SwapEodPositionService 中 3 处内联 new List{固定值, 初始预付金, 追加预付金}。</summary>
public static readonly IReadOnlyCollection<int> FixedAmountAndMargin = new HashSet<int>
{
(int)InterestModeEnum.,
(int)InterestModeEnum.,
(int)InterestModeEnum.,
};
/// <summary>判断 mode 是否为固定值或保证金。</summary>
public static bool IsFixedAmountOrMargin(int interestMode) => FixedAmountAndMargin.Contains(interestMode);
}
@@ -1,5 +1,6 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule.Margin;
namespace YLErp.Modules.SwapModule.ReturnLegs;
@@ -19,4 +20,17 @@ public static class DirectionRatio
/// <summary>收付方向因子。收取=+1, 支付=-1。</summary>
public static int ReceivePay(int direction)
=> direction == (int)SwapDirectionEnum. ? 1 : -1;
/// <summary>利息腿 PnL 方向因子。收取=+1/支付=-1;保证金腿翻转(利息现金流与本金方向相反)。
/// 原 7 处内联 `收取?1:-1; if(MarginModes) ratio=-ratio` 收口到此。</summary>
public static int InterestLegPnl(int interestDirection, int interestMode)
{
var ratio = ReceivePay(interestDirection);
return MarginModes.Contains(interestMode) ? -ratio : ratio;
}
/// <summary>按收付方向选汇率类型。收取→Buy, 支付→Sell。
/// 原 7 处内联 `收取 ? Buy : Sell` 收口到此。</summary>
public static CurrencyRateType RateType(int direction)
=> direction == (int)SwapDirectionEnum. ? CurrencyRateType.Buy : CurrencyRateType.Sell;
}
@@ -0,0 +1,21 @@
namespace YLErp.Modules.SwapModule.ReturnLegs;
/// <summary>
/// EOD 利息腿展示值计算——轻量单日公式(非 SwapDealService 的分段计息引擎)。
/// </summary>
public static class InterestIncomeCalc
{
/// <summary>日应计利息 = 本金 × (固定利率 + 浮动利差) ÷ 年化天数(若年化)。
/// 原 3 处内联 `principal*(rate+float); if(annualized) /=annualDays` 收口到此。</summary>
public static decimal DailyAccrual(decimal principal, decimal rate, decimal floatRate, bool isAnnualized, int annualDays)
{
var amount = principal * (rate + floatRate);
return isAnnualized ? amount / annualDays : amount;
}
/// <summary>已实现利息滚存。(prev + today×ratio, prevFee + todayFee)。
/// 原 4 处内联 2 行赋值收口到此。</summary>
public static (decimal Interest, decimal Fee) RollRealized(
decimal prevInterest, decimal prevFee, decimal closeInterest, decimal closeInterestFee, int ratio)
=> (prevInterest + closeInterest * ratio, prevFee + closeInterestFee);
}
@@ -19,4 +19,12 @@ public static class MtmCalc
/// <param name="ratio">收取=1, 支付=-1。</param>
public static decimal UnrealizedPnl(decimal price, decimal costGrossPrice, decimal qty, decimal contractSize, int shortRatio, int ratio)
=> (price - costGrossPrice) * qty * contractSize * shortRatio * ratio;
/// <summary>浮动端总未实现盈亏 = 盯市盈亏 + 分红 + 待结费用。原 4 处内联收口到此。</summary>
public static decimal ReturnLegProfitSum(decimal mtmPnl, decimal dividendSum, decimal feePending)
=> mtmPnl + dividendSum + feePending;
/// <summary>加权均价混合:(昨日均价×昨日量 + 今日∑(量×均额)) / 总量。原 4 处内联收口到此。</summary>
public static decimal BlendPrice(decimal prevPrice, decimal prevQty, decimal sumQtyTimesPrice, decimal totalQty)
=> (prevPrice * prevQty + sumQtyTimesPrice) / totalQty;
}
+233 -417
View File
@@ -46,106 +46,17 @@ namespace YLErp.Modules.SwapModule
/// 原 private 改 protected virtual,使测试 stub 可整体 override,规避内部 new SwapEventService 连库。</summary>
protected virtual long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false)
{
NormalizeNotionalValues(unwindData);
UnwindNormalizer.NormalizeNotionalValues(unwindData);
return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve);
}
private static void NormalizeNotionalValues(UnwindData unwindData)
{
unwindData.NotionalValue = Math.Round(unwindData.NotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
unwindData.PosiNotionalValue = Math.Round(unwindData.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
private static 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 = MarginModes.Contains(leg.InterestMode)
? 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) 日终快照;精度常量统一引用 SwapInterest.FundingLegPrecision,消除重复定义。
private const int InterestCalculationPrecision = SwapInterest.FundingLegPrecision;
/// <summary>
/// 手工平仓、手工互换及收益结算的利息事件按金额两位落库。
/// 自动平仓保留原有计算与落库口径,不适用本阶段的手工结算规则。
/// </summary>
private static bool NormalizeSettledInterestAmounts(IEnumerable<swap_flow_event> flowEvents, int eventType, string eventReason)
{
if ((eventType != (int)SwapEventTypeEnum. && eventType != (int)SwapEventTypeEnum.)
|| eventReason == "系统操作_自动平仓")
{
return false;
}
foreach (var flowEvent in flowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)))
{
// 只处理利息腿;浮动腿损益在日终快照入口统一按两位落库。
flowEvent.InterestPrincipal = Math.Round(flowEvent.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.InterestAmount = Math.Round(flowEvent.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.TdInterestAmount = Math.Round(flowEvent.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.InterestClosePnL = Math.Round(flowEvent.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.InterestFee = Math.Round(flowEvent.InterestFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
return true;
}
// 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。
private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason)
{
if (!NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason))
if (!UnwindNormalizer.NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason))
{
return;
}
@@ -372,7 +283,7 @@ namespace YLErp.Modules.SwapModule
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
floatEvent.BeforeCloseFee = oriPosition.PosiTradingFeePending;
floatEvent.TradingFee = CalcInitTradingFee(oriPosition, unwindData);
floatEvent.TradingFee = TradingFeeCalc.CalcInitTradingFee(oriPosition, unwindData);
floatEvent.PosiTradingFeeUnit = oriPosition?.PosiTradingFeeUnit ?? 0;
floatEvent.PosiFeeType = oriPosition?.PosiFeeType ?? 0;
floatEvent.MarkClosePnl = 0;
@@ -386,8 +297,8 @@ namespace YLErp.Modules.SwapModule
floatEvent.PositionQty = 0;
floatEvent.ContractSize = position.ContractSize;
floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize;
var ratio = position.PosiDirection == (int)SwapDirectionEnum. ? -1m : 1m;
floatEvent.TradingFeePending = CalcInitTradingFeePending(oriPosition, position, unwindData);
var ratio = -DirectionRatio.ReceivePay(position.PosiDirection);
floatEvent.TradingFeePending = TradingFeeCalc.CalcInitTradingFeePending(oriPosition, position, unwindData);
floatEvent.DataState = (int)SwapFlowDateStateEnum.;
floatEvent.InterestMode = position.InterestMode;
floatEvent.ClientId = td.ClientId;
@@ -397,37 +308,6 @@ namespace YLErp.Modules.SwapModule
}
return unwindData;
}
private static decimal CalcInitTradingFee(swap_position oriPosition, UnwindData unwindData)
{
if (oriPosition == null || unwindData == null)
{
return 0;
}
if (oriPosition.PosiFeeType == 1)
{
return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, 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)
{
if (oriPosition == null || unwindData == null || oriPosition.PosiTradingFeeUnit == 0)
{
return position?.PosiTradingFeePending ?? 0;
}
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>
/// 校验上日是否收盘
/// </summary>
@@ -465,56 +345,6 @@ namespace YLErp.Modules.SwapModule
/// <param name="tradeId"></param>
/// <returns></returns>
/// <exception cref="ServiceException"></exception>
public UnwindData InitLongShortUnwind(int tradeId, SwapEventTypeEnum eventTypeEnum)
{
var td = DbContext.trade.Find(tradeId);
if (td == null)
{
throw new ServiceException("未找到交易信息");
}
var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && x.IsInitial && !x.Invalid);
List<int> eventTyps = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
var dealDate = valuedateBLL.ValueDate <= td.ExerciseDate.Value ? valuedateBLL.ValueDate : td.ExerciseDate.Value;
//CheckLastEod(dealDate, td.TradeDate.Value, tradeId); //去掉平仓收盘限制
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
td.trade_extend = tradeExtend;
var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps);
double stockEqvNotional = td.StockEqvNotional;//剩余名义本金
var hasProcess = HasTradeProcess();
swap_flow_event floatEvent = new swap_flow_event();
UnwindData unwindData = new UnwindData();
if (((valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess)) && (td.TradeStatus == ConsTrade. || td.TradeStatus == ConsTrade.))
{
var swapEvent = GetSwapEvent(tradeId, (int)eventTypeEnum);
if (swapEvent == null)
{
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
unwindData = swapEvent.unwindData;
}
else
{
unwindData.StartDate = td.TradeDate.Value;
if (preDealDate.HasValue)
{
unwindData.StartDate = preDealDate.Value;
}
unwindData.ValueDate = dealDate;
unwindData.UnwindDate = dealDate;
unwindData.PayDate = QdpCalendarHelper.GetNonHoliday(dealDate.AddDays(td.trade_extend.ExtendObj.SettlementRules));
unwindData.SwapTradeId = tradeId;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = Convert.ToDecimal(stockEqvNotional);
unwindData.PositionQty = 0;//平仓只做了结为0,互换用不上
unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
if (eventTypeEnum == SwapEventTypeEnum.)
{
unwindData.FlowEvents = GetUnwindInterests(dealDate, unwindData.UnwindDate.Value, tradeId, 1, (int)SwapEventTypeEnum.);
}
}
return unwindData;
}
/// <summary>
/// 平仓初始化
/// </summary>
@@ -663,8 +493,8 @@ namespace YLErp.Modules.SwapModule
/// <para>根因(多次部分平仓预付金返还错误):预付金腿(初始/追加)的"当前剩余本金"存于实时持仓
/// realPositions.InterestPrincipalFix,每次平仓由 UpdateInitalPosition 递减;而原始腿
/// origPositions(IsInitial=1)的 InterestPrincipalFix 恒为初始值。GetInterests 算
/// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv(InitSwapDealInterest) 时都读
/// position.InterestPrincipalFix,若沿用原始腿,会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。</para>
/// closePrincipal = Fix × closePercent 时读 position.InterestPrincipalFix,若沿用原始腿,
/// 会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。</para>
/// <para>修复:迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配,
/// 全库实测 eod 均按 orig.id 归档;若换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿
/// Clone 覆盖其本金值为实时腿的剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。
@@ -745,24 +575,7 @@ namespace YLErp.Modules.SwapModule
eod_swap lastEod,
IEnumerable<eod_swap_position> lastEodPositions,
decimal currentNotional)
{
var floatingPositions = lastEodPositions?.Where(x => x.PosiDirection > 0).ToList();
decimal previousNotional;
if (floatingPositions?.Count > 0)
{
previousNotional = floatingPositions.Sum(x => x.PosiNotionalValue);
}
else
{
previousNotional = lastEod == null
? currentNotional
: Math.Abs(lastEod.NotionalValueLong) + Math.Abs(lastEod.NotionalValueShort);
}
return previousNotional == 0m && currentNotional != 0m
? currentNotional
: previousNotional;
}
=> ClosePercentMath.ResolveUnwindPreviousNotional(lastEod, lastEodPositions, currentNotional);
/// <summary>
/// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。
@@ -855,14 +668,22 @@ namespace YLErp.Modules.SwapModule
{
// 融资腿(1/2/9): 走策略工厂
var r = FundingLegStrategyFactory.Get(mode)
.CalcNotional(position.InterestPrincipalFix, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, closePrecent);
.CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePrecent);
closePrincipal = r.ClosePrincipal;
posiPrincipal = r.PosiPrincipal;
newClosePercent = r.ClosePercent;
}
// 根因位置:SwapEodPositionService.SaveAutoEodWithCloseInterestPosition 在平仓后收盘时传入
// “收盘后剩余本金 + closePercent=1”,与盘中“平仓前本金 + 实际关闭比例”不是同一语义。
// GetInterests 同时被盘中试算和 EOD 平仓后收盘调用:后者传入的
// posiNotionalValue 是收盘后的剩余本金,closePosiNotionalValue 才是本次实际平掉的本金。
// 例如平仓前 100、平掉 30、收盘后剩余 70 时,EOD 传入 posi=70、close=30、closePercent=1。
// 模式2(合约名义本金规模)的本次结息本金必须始终是实际平仓额,因此无条件覆盖,
// 否则会错误地用剩余 70 结算本次平掉的 30。模式9(标的期初全价)的部分平仓
// 仍保留既有的剩余/复利动态本金承接逻辑;仅最终全平时 posi=0,才覆盖以避免结息本金为 0。
if ((InterestModeEnum)position.InterestMode == InterestModeEnum.
|| (InterestModeEnum)position.InterestMode == InterestModeEnum.
&& posiNotionalValue == 0m)
|| ((InterestModeEnum)position.InterestMode == InterestModeEnum.
&& posiNotionalValue == 0m))
{
closePrincipal = closePosiNotionalValue;
}
@@ -876,7 +697,13 @@ namespace YLErp.Modules.SwapModule
decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone);
// 根据场景计算利息
if (settment)
if (MarginModes.Contains(position.InterestMode))
{
// 保证金腿(5/6):专属计息,notional 直接取保证金余额,无融资腿差分公式与 orginPv 维度 hack
interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate, closePrincipal, posiPrincipal,
newClosePercent, annualDays, calcFirst, calcLast||newCalcLast, preEodPosition, eventType, add, settment, swap));
}
else if (settment)
{
// 收盘归档场景,使用 CalcEodInterest
interests.Add(CalcEodInterest(td, valueDate, positionClone, rate, floatRate, closePrincipal, posiPrincipal, annualDays, calcFirst, calcLast, preEodPosition, eventType, add));
@@ -934,32 +761,19 @@ namespace YLErp.Modules.SwapModule
/// 分母为 0(无持仓等异常场景)时原样返回,避免除零。
/// </summary>
public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue)
{
if (posiNotionalValue <= 0) return originalClosePercent;
var remaining = originalClosePercent * notionalValue / posiNotionalValue;
return remaining > 1 ? 1 : remaining;
}
=> ClosePercentMath.ToRemainingClosePercent(originalClosePercent, notionalValue, posiNotionalValue);
/// <summary>
/// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。
/// </summary>
public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue)
{
if (notionalValue <= 0) return remainingClosePercent;
return remainingClosePercent * posiNotionalValue / notionalValue;
}
=> ClosePercentMath.ToOriginalClosePercent(remainingClosePercent, notionalValue, posiNotionalValue);
/// <summary>
/// 计算 InitUnwind 默认占期初(A)平仓比例 = "平掉剩余全部持仓"对应的占期初比例
/// 即:ClosePercent(A) = PosiNotionalValue / NotionalValue。
/// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);
/// 部分平仓后自动变为剩余比例(如已平 30% 则默认 0.7)。
/// 与互换/提前终止 InitIncome 保持一致。抽出为纯函数以支持无库单测。
/// 计算 InitUnwind 默认占期初(A)平仓比例 = PosiNotionalValue / NotionalValue
/// </summary>
public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue)
{
return notionalValue > 0 ? posiNotionalValue / notionalValue : 1;
}
=> ClosePercentMath.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue);
/// <summary>
/// 读取"上一收盘日"浮动腿的待实现分红(eod_swap_position.PosiDividendSum),
@@ -992,16 +806,34 @@ namespace YLErp.Modules.SwapModule
/// </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);
var preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate);
return preEod == null ? 0m : preEod.PosiDividendSum;
}
/// <summary>
/// 取 dealDate 对应"上一收盘日"持仓的累计分红快照。
/// GLMS-20260105-0006:登记日当天手动平仓/互换时,当日 EOD 快照已含分红,应取到当日而非 T-1。
/// 故由 ValueDate 严格小于 dealDate 改为 小于等于:当日 EOD 存在则读当日,否则回退上一收盘日(原口径不变)。
/// </summary>
protected virtual eod_swap_position GetPreEodPositionByDate(int tradeId, long positionId, DateTime dealDate)
{
var lastEod = QueryPreEodSwaps(tradeId)
.Where(x => x.ValueDate <= dealDate)
.OrderByDescending(o => o.ValueDate).FirstOrDefault();
var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate;
return QueryPreEodPosition(tradeId, positionId, preEodDate);
}
/// <summary>可测性 seam:返回某交易的全部 eod_swap 行(不做日期过滤)。测试可 override 注入内存数据。</summary>
protected virtual IQueryable<eod_swap> QueryPreEodSwaps(int tradeId)
=> DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId);
/// <summary>可测性 seam:取指定收盘日的持仓累计分红快照。测试可 override 注入内存数据。</summary>
protected virtual eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate)
=> new SwapEodPositionService(this)
.GetPreEodPositions(tradeId, valueDate)
.FirstOrDefault(x => x.PositionId == positionId);
/// <summary>
/// 获取固定利率
/// </summary>
@@ -1088,12 +920,12 @@ namespace YLErp.Modules.SwapModule
if (position.InterestType == (int)InterestTypeEnum.)
{
// 复利计算
CalcDailyCompoundInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, false, eodFloatRate, 1m, posiPrincipal, ref interestAmount, ref tdInterestAmount);
CalcDailyCompoundInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount);
}
else
{
// 单利计算
CalcDailySimpleInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, false, eodFloatRate, 1m, posiPrincipal, ref interestAmount, ref tdInterestAmount);
CalcDailySimpleInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount);
}
}
@@ -1101,7 +933,125 @@ namespace YLErp.Modules.SwapModule
interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
// 计算InterestClosePnL(方向:收取=1为正,支付=-1为负)
var interestRatio = position.InterestDirection == 1 ? 1m : -1m;
var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection);
interest.InterestClosePnL = interest.InterestAmount * interestRatio;
if (add) UpdateDbOption(interest);
return interest;
}
/// <summary>
/// 保证金腿(InterestMode 5/6)专属计息——替代 CalcEodInterest/CalcUnwindInterest 对保证金的处理。
///
/// 保证金是纯固定利率单利:浮动利率(FR007)/分段利率/复利对其均为死分支(前端无入口、
/// 确认书不含、FundingLegRate.Build 对空 FloatRateUnderlyingCode 恒返回 Fixed)。故本方法直接用
/// SimpleInterestAccrual 纯函数计息,本金取保证金余额:
/// EOD = 昨日终本金 preEod.TdInterestPrincipal(与旧 CalcDailySimpleInterestByEod 同源,无差分)
/// 盘中 = accrualBasispreEod.TdInterestPrincipal + posiPrincipal - orginPv
/// 盘中保留差分是必要的:posiPrincipal 是否经 ResolveInterestLegPositions 对齐到实时剩余是路径相关的
/// (生产对齐 / 诊断测试用原始腿),单一本金变量无法覆盖两种状态,差分经 orginPv 自适应。orginPv 在
/// 本方法内部按保证金维度计算(PreviousBalance),消除原 InitSwapDealInterest 的外部维度 hack
/// (融资腿 orginPv=浮动端名义本金)。保留累计语义(priorAccrued + 增量),满足下游字段契约。
/// </summary>
/// <param name="settment">true=收盘归档(EOD)false=盘中平仓/互换。</param>
/// <param name="swap">互换事件(仅盘中生效,true 时利息归零,同 InitSwapDealInterest)。</param>
public swap_flow_event CalcMarginInterest(
trade td, DateTime valueDate, DateTime endDate, swap_position position, decimal rate,
decimal closePrincipal, decimal posiPrincipal, decimal closePercent,
int annualDays, bool calcFirst, bool calcLast,
eod_swap_position preEod, int eventType, bool add, bool settment, bool swap)
{
// 当日是否计息(算头算尾)——同 CalcEodInterest
bool calcToday = true;
if (!calcFirst && valueDate == td.StartDate.Value) calcToday = false;
if (!calcLast && valueDate == td.ExerciseDate.Value) calcToday = false;
if (valueDate < position.PosiStartDate) calcToday = false;
// 首日初始化 preEod——同 CalcEodInterest
if (preEod.id == 0)
{
preEod.FloatRate = 0m;
preEod.TdInterestPrincipal = posiPrincipal;
preEod.PosiNotionalValue = posiPrincipal;
}
// 字段映射(保证金 FloatRate 恒 0;方向 position.InterestDirection 已由 GetInterests 翻转)
var interest = new swap_flow_event
{
SwapTradeId = td.id,
SwapTradeNo = td.TradeNumber,
EventType = eventType,
EventReason = "交易",
EventDate = valueDate,
PositionId = position.id,
InterestDirection = position.InterestDirection,
InterestRate = rate,
InterestPrincipal = closePrincipal,
InterestSwapInterval = position.InterestSwapInterval,
InterestMode = position.InterestMode,
FloatRate = 0m,
DataState = (int)SwapFlowDateStateEnum.,
ClientId = td.ClientId,
UnwindDate = settment ? valueDate : endDate
};
// 互换事件:利息归零(同 InitSwapDealInterest
if (swap && !settment)
{
interest.InterestAmount = 0m;
interest.TdInterestAmount = 0m;
interest.InterestClosePnL = 0m;
if (add) UpdateDbOption(interest);
return interest;
}
decimal interestAmount = 0m;
decimal tdInterestAmount = 0m;
var legRate = FundingLegRate.Fixed(rate); // 保证金纯固定(无浮动)
if (calcToday)
{
if (settment)
{
// EOD:单日增量,累计 = 昨日累计 + 今日增量;notional = 昨日终本金(无差分)
var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound: false);
var r = SimpleInterestAccrual.AccrueEod(
priorAccrued: preEod.InterestProfitSum,
priorNotional: preEod.TdInterestPrincipal,
unwindFraction: 1m,
rate: legRate, policy: policy, eodDate: valueDate);
interestAmount = r.Accrued;
tdInterestAmount = r.AccruedToday;
}
else
{
// 盘中:accrualBasis 自适应"实时剩余本金"——posiPrincipal 已对齐(ResolveInterestLegPositions)
// 时 = posiPrincipal;未对齐的原始腿经 orginPv(=PreviousBalance 昨日终) 修正回昨日终剩余。
// 单一本金变量无法覆盖两种 position 状态,故保留差分(与 EOD 直接用 preEod.TdInterestPrincipal 不同)。
// orginPv 在此内部按保证金维度计算,消除原 InitSwapDealInterest 的外部维度 hack。
var orginPv = MarginCalc.PreviousBalance(preEod, posiPrincipal);
var accrualBasis = preEod.TdInterestPrincipal + posiPrincipal - orginPv;
var segmentRates = new List<(DateTime, decimal)> { (position.PosiStartDate, rate) };
var r = SimpleInterestAccrual.AccruePeriod(
priorAccrued: preEod.InterestProfitSum * closePercent,
notional: accrualBasis,
unwindFraction: closePercent,
segmentRates: segmentRates,
startDate: position.PosiStartDate,
endDate: endDate,
priorValueDate: preEod.ValueDate,
boundary: AccrualBoundary.Of(calcFirst, calcLast),
annualDays: annualDays,
isAnnualized: position.IsAnnualized);
interestAmount = r.Accrued;
tdInterestAmount = r.AccruedToday;
interest.InterestPrincipal = accrualBasis * closePercent; // 同 CalcDailySimpleInterest:1304
}
}
interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection);
interest.InterestClosePnL = interest.InterestAmount * interestRatio;
if (add) UpdateDbOption(interest);
@@ -1130,15 +1080,6 @@ namespace YLErp.Modules.SwapModule
orginPv, calcFirst, calcLast, consumedInterest);
}
/// <summary>
/// 保证金腿的 orginPv 维度重映射。
///
/// 保证金腿被迫走融资腿的差分公式(dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv)
/// 但 orginPv 对融资腿是"交易名义本金(千万~亿级)",对保证金腿必须是"保证金本金"——
/// 否则维度不匹配会算出巨负值。本方法把保证金场景的 orginPv 对齐到"上一日保证金本金"。
///
/// 待迁入 Margin 模块:保证金独立计息入口建好后,此方法移入 MarginAccount/MarginService。
/// </summary>
/// <summary>
/// 写入保证金的资金记录:应付预付金(SwapMarginAmount)和预付金返息(SwapMarginRebatePnl)。
/// 依赖实例方法 AddClientCash/AddClientCashInCashOut,暂留此处。
/// </summary>
@@ -1215,11 +1156,8 @@ namespace YLErp.Modules.SwapModule
interest.ClientId = td.ClientId;
interest.UnwindDate = endDate;
// 保证金腿的 orginPv 对齐到保证金本金维度,避免差分公式维度不匹配算出巨负值
if (MarginModes.Contains(position.InterestMode))
{
orginPv = MarginCalc.PreviousBalance(preEodPosition, position.InterestPrincipalFix);
}
// 保证金腿已走 CalcMarginInterest(不经过本方法),orginPv 维度重映射不再需要;
// orginPv 此处仅对融资腿生效(差分公式 accrualBasis = TdInterestPrincipal + posiPrincipal - orginPv)。
if (swap)
{
@@ -1232,7 +1170,7 @@ namespace YLErp.Modules.SwapModule
{
decimal InterestAmount = 0;
decimal TdInterestAmount = 0;
var interestRatio = position.InterestDirection == 1 ? 1m : -1m;
var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection);
var floateRate = preEodPosition.FloatRate;
if (position.InterestType == (int)InterestTypeEnum.)
{
@@ -1263,8 +1201,8 @@ namespace YLErp.Modules.SwapModule
// 把上日尚未实现的的利息 按本次平掉的这部分计息基数分给本次平仓 并在重置日并入计息基数
// 它只在当前 endDate 恰好为重置日时使用,避免把同一笔历史利息重复资本化。
var resetCarryInterest = preEodPosition.InterestIncomeSum * remainingPercent;
CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, needPrice,
floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount,
CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays,
floateRate, closePrecent, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount,
consumedInterest, resetCarryInterest);
if (preEodPosition.id != 0 && closePrecent == 1m)
{
@@ -1291,7 +1229,7 @@ namespace YLErp.Modules.SwapModule
}
// 计算截至本次平仓日的累计利息 amountAtEnd
CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue,
interestAtEnd, annualDays, needPrice, floateRate, closePrecent, orginPv,
interestAtEnd, annualDays, floateRate, closePrecent,
calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest);
var interestAtPreviousEod = new swap_flow_event { InterestRate = rate };
decimal amountAtPreviousEod = 0m;
@@ -1300,7 +1238,7 @@ namespace YLErp.Modules.SwapModule
// 因此此处按闭区间包含上一日终当天,避免算头不算尾时重复加入该日利息。
// 计算截至上一日终累积的利息 amountAtPreviousEod
CalcDailyCompoundInterest(preEodPosition.ValueDate, position, closePosiNotionalValue,
interestAtPreviousEod, annualDays, needPrice, floateRate, closePrecent, orginPv,
interestAtPreviousEod, annualDays, floateRate, closePrecent,
calcFirst, true, ref amountAtPreviousEod, ref tdAmountAtPreviousEod, consumedInterest);
// 例如 00045/18 待实现 -118631.261797,加 5/19 新增约 -4648.912760
// 得到最终应结 -123280.174557,按金额两位落为 Excel BN 的 -123280.17。
@@ -1312,7 +1250,7 @@ namespace YLErp.Modules.SwapModule
}
else
{
CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount);
CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount);
}
interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
@@ -1326,6 +1264,19 @@ namespace YLErp.Modules.SwapModule
return interest;
}
/// <summary>
/// 按 interest_rule 取 FR007 定盘价。无浮动标的时返回 fallback;取不到抛异常。
/// EOD 单日取率 + BuildSegmentRates 多日取率共用此方法,FR007 定盘逻辑收口到一处。
/// </summary>
private decimal ResolveFloatRate(swap_position position, DateTime date, decimal fallback)
{
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return fallback;
var fixingDate = IndexFixerBase.GetFixingDate(date, position.interest_rule);
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing))
return fixing != 0m ? fixing : fallback;
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格");
}
/// <summary>
/// 按重置周期切分利率段,每段记录 all-in 利率(spread+fixing)。返回 (分段列表, 末段浮动利率)。
/// fetchAfterDate: 仅该日期之后的重置日才取 FR007(单利传 ValueDate,复利传 null 全程取)。
@@ -1341,19 +1292,8 @@ namespace YLErp.Modules.SwapModule
for (int i = 0; i <= calcDays; i += interestPeriod)
{
var resetDate = startDate.AddDays(i);
if ((fetchAfterDate == null || resetDate > fetchAfterDate.Value)
&& !string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
{
var fixingDate = IndexFixerBase.GetFixingDate(resetDate, position.interest_rule);
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing))
{
if (fixing != 0m) currentFloat = fixing;
}
else
{
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格");
}
}
if (fetchAfterDate == null || resetDate > fetchAfterDate.Value)
currentFloat = ResolveFloatRate(position, resetDate, currentFloat);
rates.Add((resetDate, spread + currentFloat));
}
return (rates, currentFloat);
@@ -1371,7 +1311,7 @@ namespace YLErp.Modules.SwapModule
/// <param name="annualDays">年化天数</param>
/// <returns></returns>
public void CalcDailyCompoundInterest(DateTime endDate, swap_position position, decimal principal, swap_flow_event flowEvent,
int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast,
int annualDays, decimal floateRate, decimal closePercent, bool calcFirst, bool calcLast,
ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m)
{
var startDate = position.PosiStartDate;
@@ -1384,7 +1324,7 @@ namespace YLErp.Modules.SwapModule
// 纯函数复利计息:分段重置日并本金 + resetCarryInterest + 扣 consumedInterest
var interestTrace = new AccrualTrace();
var result = FundingLegAccrual.AccrueCompoundPeriod(
var result = CompoundInterestAccrual.AccruePeriod(
notional: principal,
segmentRates: segmentRates,
startDate: startDate,
@@ -1410,12 +1350,12 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// 计算单利 盘中(按重置天数分段,每段使用对应浮动利率)
/// </summary>
public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m)
public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m)
{
var startDate = position.PosiStartDate;
int interestPeriod = position.interest_rest_days ?? 1;
// orginPv 是路径相关参考本金(资金腿=上一日终浮动端名义本金保证金腿=上一日终保证金余额)
// orginPv 是路径相关参考本金(资金腿=上一日终浮动端名义本金)。保证金腿已走 CalcMarginInterest,不经此方法
// 单利差分:accrualBasis 全程恒定 = 昨日终滚动基数 + 当日名义本金 - 参考本金。
var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv;
@@ -1426,7 +1366,7 @@ namespace YLErp.Modules.SwapModule
// 纯函数计息:Accrued=缩放累计(InterestAmount)AccruedToday=未缩放累计(TdInterestAmount)
var interestTrace = new AccrualTrace();
var result = FundingLegAccrual.AccrueSimplePeriod(
var result = SimpleInterestAccrual.AccruePeriod(
priorAccrued: preEodPosition.InterestProfitSum * closePercent,
notional: accrualBasis,
unwindFraction: closePercent,
@@ -1460,26 +1400,14 @@ namespace YLErp.Modules.SwapModule
/// <param name="isAnnualized">是否年化</param>
/// <param name="annualDays">年化天数</param>
/// <returns></returns>
public void CalcDailyCompoundInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, ref decimal InterestAmount, ref decimal TdInterestAmount)
public void CalcDailyCompoundInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount)
{
int interestPeriod = position.interest_rest_days ?? 1;
var isResetDay = (endDate - tradeDate).Days % interestPeriod == 0;
// 重置日按 interest_rule 重新定盘浮动利率(GLMS-JIATT-20260805 根因——
// 重置日=平仓日必须用新利率,否则沿用旧周期利率并污染后续 EOD)。非重置日沿用 floateRate。
decimal effectiveFloat = floateRate;
if (isResetDay && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
{
var fixingDate = IndexFixerBase.GetFixingDate(endDate, position.interest_rule);
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing))
{
if (fixing != 0m) effectiveFloat = fixing;
}
else
{
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格");
}
}
var effectiveFloat = isResetDay ? ResolveFloatRate(position, endDate, floateRate) : floateRate;
flowEvent.FloatRate = effectiveFloat;
// remainingFraction:重置日把上一日终待实现利息按本次平仓基数分摊(EOD 全量为 1)。
@@ -1487,21 +1415,13 @@ namespace YLErp.Modules.SwapModule
? Math.Max(0m, Math.Min(1m, principal / posiPrincipal))
: 1m;
// 纯数学下沉至 FundingLegAccrual.AccrueCompoundEod(DDD 命名 + 末位生产精度 12 舍入)。
var isFixedLeg = string.IsNullOrEmpty(position.FloatRateUnderlyingCode);
var legRate = isFixedLeg
? FundingLegRate.Fixed(flowEvent.InterestRate)
: FundingLegRate.Floating(flowEvent.InterestRate, effectiveFloat);
var accrualPolicy = new AccrualPolicy(
convention: AccrualBoundary.Both,
isCompound: true,
resetPeriodDays: interestPeriod,
annualDays: annualDays,
isAnnualized: position.IsAnnualized);
// 纯数学下沉至 CompoundInterestAccrual.AccrueEodDDD 命名 + 末位生产精度 12 舍入)。
var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat);
var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: true);
// 完整计息 trace:前后日期/基数/利率/重置标志全过程,经 SwapCalcTrace 常驻落盘(关键路径日志)。
var interestTrace = new AccrualTrace();
var result = FundingLegAccrual.AccrueCompoundEod(
var result = CompoundInterestAccrual.AccrueEod(
priorAccrued: preEodPosition.InterestProfitSum,
priorNotional: preEodPosition.TdInterestPrincipal,
notional: posiPrincipal,
@@ -1516,7 +1436,7 @@ namespace YLErp.Modules.SwapModule
// flowEvent.InterestPrincipal:当日计息基数(已按平仓比例缩放)——下游 EOD 用它播种次日 TdInterestPrincipal。
// 复用 CompoundEodBasis 单一真相源(与 AccrueCompoundEod 内部同一公式)。
flowEvent.InterestPrincipal = FundingLegAccrual.CompoundEodBasis(
flowEvent.InterestPrincipal = CompoundInterestAccrual.EodBasis(
isResetDay, posiPrincipal, preEodPosition.InterestProfitSum, remainingFraction,
preEodPosition.TdInterestPrincipal) * closePercent;
@@ -1527,7 +1447,7 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// 计算单利 收盘(按重置天数分段,每段使用对应浮动利率)
/// </summary>
public void CalcDailySimpleInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, ref decimal InterestAmount, ref decimal TdInterestAmount)
public void CalcDailySimpleInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount)
{
// 首次操作(preEod.id == 0):计息基数按存量本金初始化——保留旧行为(含对 preEod 的就地修正)。
if (preEodPosition.id == 0)
@@ -1537,39 +1457,18 @@ namespace YLErp.Modules.SwapModule
// 取率:重置日按 interest_rule 重新定盘浮动利率(GLMS-JIATT-20260805 根因——
// 重置日=平仓日必须用新利率,否则沿用旧周期利率并污染后续 EOD)。
decimal effectiveFloat = floateRate;
int interestPeriod = position.interest_rest_days ?? 1;
if ((endDate - tradeDate).Days % interestPeriod == 0
&& !string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
{
var fixingDate = IndexFixerBase.GetFixingDate(endDate, position.interest_rule);
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing))
{
if (fixing != 0m) effectiveFloat = fixing;
}
else
{
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格");
}
}
var isResetDay = (endDate - tradeDate).Days % interestPeriod == 0;
var effectiveFloat = isResetDay ? ResolveFloatRate(position, endDate, floateRate) : floateRate;
flowEvent.FloatRate = effectiveFloat;
// 纯数学下沉至 FundingLegAccrualDDD 命名 + 末位生产精度 12 舍入),行为与上版逐字对齐
// 利率构成按腿型封装:固定腿 → FixedRate;浮动腿 → Spread + IndexFixing(沿用旧实现 InterestRate+浮动利率 的口径)。
var isFixedLeg = string.IsNullOrEmpty(position.FloatRateUnderlyingCode);
var legRate = isFixedLeg
? FundingLegRate.Fixed(flowEvent.InterestRate)
: FundingLegRate.Floating(flowEvent.InterestRate, effectiveFloat);
var accrualPolicy = new AccrualPolicy(
convention: AccrualBoundary.Both,
isCompound: false,
resetPeriodDays: position.interest_rest_days ?? 1,
annualDays: annualDays,
isAnnualized: position.IsAnnualized);
// 纯数学下沉至 SimpleInterestAccrual末位生产精度 12 舍入)。
var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat);
var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: false);
// 完整计息 trace:收集器由适配器创建,随后经 SwapCalcTrace 常驻落盘(关键路径日志,无条件)。
var interestTrace = new AccrualTrace();
var result = FundingLegAccrual.AccrueSimpleEod(
var result = SimpleInterestAccrual.AccrueEod(
priorAccrued: preEodPosition.InterestProfitSum,
priorNotional: preEodPosition.TdInterestPrincipal,
unwindFraction: closePercent,
@@ -1594,16 +1493,16 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
NormalizeNotionalValues(unwindData);
UnwindNormalizer.NormalizeEventUnwindDate(unwindData);
UnwindNormalizer.NormalizeNotionalValues(unwindData);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_平仓");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
// 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。
// 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
if (NormalizeFullCloseRequest(unwindData))
if (UnwindNormalizer.NormalizeFullCloseRequest(unwindData))
{
RecalculateNormalizedUnwindAmounts(unwindData);
UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData);
}
ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易
bool cofirm = false;
@@ -1616,7 +1515,7 @@ namespace YLErp.Modules.SwapModule
var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum., clientCashId, "系统操作_平仓");
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);
var isFullClose = UnwindNormalizer.IsFullCloseAfterDeduction(unwindData, remainingStockEqvNotional, remainingTradeAmount);
if (isFullClose)
{
td.TradeStatus = "已平仓";
@@ -1978,84 +1877,6 @@ namespace YLErp.Modules.SwapModule
unwindData.SwapRealizedPnL = Math.Round(unwindData.SwapRealizedPnL, 2, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 多空组合平仓
/// </summary>
/// <param name="unwindData"></param>
/// <exception cref="ServiceException"></exception>
public void SwapLongShortUnwind(UnwindData unwindData)
{
var td = DbContext.trade.Find(unwindData.SwapTradeId);
if (td == null)
{
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_平仓");
var trans = DbContext.Database.BeginTransaction();
try
{
int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(unwindData.SwapCloseAmount), ClientCashInCashOut._平仓费, unwindData.ValueDate);
RecordMarginCashFlow(td, unwindData);
SaveSwapDeal(unwindData, (int)SwapEventTypeEnum., clientCashId, "系统操作_平仓");
td.UnWindDate = unwindData.UnwindDate;
td.StockEqvNotional = 0;
td.TradeStatus = "已平仓";
DbContext.SaveChanges();
trans.Commit();
}
catch (Exception ex)
{
trans.Rollback();
throw;
}
finally
{
trans.Dispose();
}
}
/// <summary>
/// 多空组合互换
/// </summary>
/// <param name="swap_Deal"></param>
/// <exception cref="ServiceException"></exception>
public void SwapLongShort(UnwindData unwindData)
{
var td = DbContext.trade.Find(unwindData.SwapTradeId);
if (td == null)
{
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_互换");
var trans = DbContext.Database.BeginTransaction();
try
{
int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(unwindData.SwapCloseAmount), ClientCashInCashOut._互换, unwindData.ValueDate);
SaveSwapDeal(unwindData, (int)SwapEventTypeEnum., clientCashId, "系统操作_互换");
td.UnWindDate = unwindData.UnwindDate;
if (td.ExerciseDate <= unwindData.ValueDate)
{
td.Notional = 0;
td.StockEqvNotional = 0;
td.TradeStatus = "已到期";
}
DbContext.SaveChanges();
trans.Commit();
}
catch (Exception ex)
{
trans.Rollback();
throw;
}
finally
{
trans.Dispose();
}
}
/// <summary>
/// 互换
/// </summary>
/// <param name="swap_Deal"></param>
@@ -2067,7 +1888,7 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
UnwindNormalizer.NormalizeEventUnwindDate(unwindData);
ValidateIncomeValueDate(unwindData, td);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_互换");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
@@ -2105,8 +1926,8 @@ namespace YLErp.Modules.SwapModule
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
NormalizeEventUnwindDate(swapEvent.unwindData);
NormalizeNotionalValues(swapEvent.unwindData);
UnwindNormalizer.NormalizeEventUnwindDate(swapEvent.unwindData);
UnwindNormalizer.NormalizeNotionalValues(swapEvent.unwindData);
// Stored events keep display ratio A; approval calculations consume remaining ratio B.
swapEvent.unwindData.ClosePercent = ToRemainingClosePercent(
swapEvent.unwindData.ClosePercent,
@@ -2121,9 +1942,9 @@ namespace YLErp.Modules.SwapModule
swapEvent.unwindData.FlowEvents = flowList;
if (eventType == (int)SwapEventTypeEnum.)
{
if (NormalizeFullCloseRequest(swapEvent.unwindData))
if (UnwindNormalizer.NormalizeFullCloseRequest(swapEvent.unwindData))
{
RecalculateNormalizedUnwindAmounts(swapEvent.unwindData);
UnwindNormalizer.RecalculateNormalizedUnwindAmounts(swapEvent.unwindData);
}
}
if (eventType == (int)SwapEventTypeEnum.)
@@ -2152,7 +1973,7 @@ namespace YLErp.Modules.SwapModule
{
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);
var isFullClose = UnwindNormalizer.IsFullCloseAfterDeduction(swapEvent.unwindData, remainingStockEqvNotional, remainingTradeAmount);
if (isFullClose)
{
td.TradeStatus = "已平仓";
@@ -2197,7 +2018,7 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
UnwindNormalizer.NormalizeEventUnwindDate(unwindData);
if (eventType == (int)SwapEventTypeEnum.)
{
ValidateIncomeValueDate(unwindData, td);
@@ -2211,9 +2032,9 @@ namespace YLErp.Modules.SwapModule
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
if (eventType == (int)SwapEventTypeEnum.)
{
if (NormalizeFullCloseRequest(unwindData))
if (UnwindNormalizer.NormalizeFullCloseRequest(unwindData))
{
RecalculateNormalizedUnwindAmounts(unwindData);
UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData);
}
}
string action = eventType == (int)SwapEventTypeEnum. ? ClientCashInCashOut._互换 : ClientCashInCashOut._平仓费;
@@ -2246,11 +2067,6 @@ namespace YLErp.Modules.SwapModule
}
}
private static void NormalizeEventUnwindDate(UnwindData unwindData)
{
unwindData.UnwindDate = unwindData.ValueDate;
}
/// <summary>
/// 保存平仓/互换事件
/// </summary>
@@ -2264,7 +2080,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
var flowList = new List<swap_flow_event>(unwindData.FlowEvents);
NormalizeSettledInterestAmounts(flowList, eventType, eventResason);
UnwindNormalizer.NormalizeSettledInterestAmounts(flowList, eventType, eventResason);
unwindData.FlowEvents.Clear();
// 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。
// 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。
@@ -48,72 +48,13 @@ namespace YLErp.Modules.SwapModule
: ConsGlobal.SwapDeliveryPriceRound;
}
// 日终利息待实现需跨日累计,按表设计保留 12 位;已实现结算仍按金额两位处理。
private const int EodInterestStoragePrecision = 12;
private static decimal RoundMoney(decimal value)
{
return Math.Round(value, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
private static decimal RoundEodInterest(decimal value)
{
return Math.Round(value, EodInterestStoragePrecision, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 仅在写入 eod_swap_position 前统一快照精度。
/// 浮动腿收益最终以金额两位展示和存储;利息腿的待实现、计息基数及利率保留 12 位,
/// 使部分结算后的尾差可继续参与后续计息。
/// </summary>
private static void NormalizeEodPositionForStorage(eod_swap_position position)
{
if (string.IsNullOrEmpty(position.UnderlyingCode))
{
// 利息腿没有标的代码:待实现字段保留高精度,已实现结算字段收敛到金额两位。
position.InterestPrincipalFix = RoundEodInterest(position.InterestPrincipalFix);
position.InterestRateDefault = RoundEodInterest(position.InterestRateDefault);
position.InterestFeePending = RoundEodInterest(position.InterestFeePending);
position.TdInterestPrincipal = RoundEodInterest(position.TdInterestPrincipal);
position.TdInterestRate = RoundEodInterest(position.TdInterestRate);
position.TdInterestIncome = RoundEodInterest(position.TdInterestIncome);
position.TdInterestFee = RoundEodInterest(position.TdInterestFee);
position.InterestIncomeSum = RoundEodInterest(position.InterestIncomeSum);
position.InterestFeeSum = RoundEodInterest(position.InterestFeeSum);
position.InterestProfitSum = RoundEodInterest(position.InterestProfitSum);
position.FloatRate = RoundEodInterest(position.FloatRate);
position.SwapPositionValue = RoundEodInterest(position.SwapPositionValue);
position.TdCloseInterest = RoundMoney(position.TdCloseInterest);
position.TdCloseInterestFee = RoundMoney(position.TdCloseInterestFee);
position.RealizedInterest = RoundMoney(position.RealizedInterest);
position.RealizedInterestFee = RoundMoney(position.RealizedInterestFee);
}
else
{
// 浮动腿有标的代码:其损益作为金额结果落库,统一按两位四舍五入。
position.TdPosiDividend = RoundMoney(position.TdPosiDividend);
position.PosiMtmPnL = RoundMoney(position.PosiMtmPnL);
position.PosiDividendSum = RoundMoney(position.PosiDividendSum);
position.PosiFeePending = RoundMoney(position.PosiFeePending);
position.PosiProfitSum = RoundMoney(position.PosiProfitSum);
position.TdCloseMtmPnl = RoundMoney(position.TdCloseMtmPnl);
position.TdCloseDividend = RoundMoney(position.TdCloseDividend);
position.TdCloseFee = RoundMoney(position.TdCloseFee);
position.RealizedMtmPnL = RoundMoney(position.RealizedMtmPnL);
position.RealizedDividend = RoundMoney(position.RealizedDividend);
position.RealizedFee = RoundMoney(position.RealizedFee);
position.SwapPositionValue = RoundMoney(position.SwapPositionValue);
}
position.RealizedPnl = RoundMoney(position.RealizedPnl);
}
#region Seamsoverride DB/
/// <summary>持久化 eod 持仓记录(生产: DbContext.Add;测试: 收集到列表)</summary>
protected virtual void PersistEodSwapPosition(eod_swap_position position)
{
// 所有新增或更新的日终持仓都经过此入口,避免不同日终分支出现精度差异。
NormalizeEodPositionForStorage(position);
EodPnlCalculator.NormalizeEodPositionForStorage(position);
var storagePriceRound = GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode);
position.PosiGrossPrice = Math.Round(position.PosiGrossPrice, storagePriceRound, MidpointRounding.AwayFromZero);
position.UnderlyingPrice = Math.Round(position.UnderlyingPrice, storagePriceRound, MidpointRounding.AwayFromZero);
@@ -193,6 +134,12 @@ namespace YLErp.Modules.SwapModule
return new SwapEventService(this).AddSwapEventDate(tradeDate, swapTradeId, eventType, data, clientCashId, save, reason);
}
/// <summary>持久化互换流水事件(生产: DbContext.swap_flow_event.Add;测试: 收集到列表)</summary>
protected virtual void PersistFlowEvent(swap_flow_event flowEvent)
{
DbContext.swap_flow_event.Add(flowEvent);
}
/// <summary>在事务中执行(生产: BeginTransaction/Commit/Rollback;测试: 直接执行不包事务)</summary>
protected virtual void ExecuteInTransaction(Action action)
{
@@ -662,7 +609,7 @@ namespace YLErp.Modules.SwapModule
decimal premiumTotal = 0;
premiumInterests.ForEach(x =>
{
var ratio = x.InterestDirection == (int)SwapDirectionEnum. ? -1 : 1;
var ratio = -DirectionRatio.ReceivePay(x.InterestDirection);
premiumTotal += x.InterestClosePnL * ratio;
});
unwindData.SwapMarginRebatePnl = premiumTotal;
@@ -670,7 +617,7 @@ namespace YLErp.Modules.SwapModule
decimal interestTotal = 0;
interestLegs.ForEach(x =>
{
var ratio = x.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1;
var ratio = DirectionRatio.ReceivePay(x.InterestDirection);
interestTotal += x.InterestClosePnL * ratio;
});
unwindData.SwapCloseAmount = interestTotal ;
@@ -797,24 +744,26 @@ namespace YLErp.Modules.SwapModule
unwindData.ClientCashIds = clientCashIds;
string data = JsonConvert.SerializeObject(unwindData);
var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum., data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件
// 走虚方法 AddSwapEvent(与 ComposePage:800 一致),让测试可 override 捕获事件
// 默认实现仍是 new SwapEventService(this).AddSwapEventDate,生产行为不变。
var swapEvent = AddSwapEvent(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum., data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件
if (flowEvents!=null)
{
flowEvents.ForEach(x =>
{
x.EventId = swapEvent.id;
DbContext.swap_flow_event.Add(x);
PersistFlowEvent(x);
});
UpdateInitalPostion(flowEvents, td.id);
}
// 保存分红事件
if (dividendEvents != null)
{
dividendEvents.ForEach(x =>
{
x.EventId = swapEvent.id;
DbContext.swap_flow_event.Add(x);
PersistFlowEvent(x);
});
UpdateInitalPostion(dividendEvents, td.id);
}
@@ -1041,11 +990,7 @@ namespace YLErp.Modules.SwapModule
eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value;
}
var tradeExtend = td.trade_extend.ExtendObj;
decimal ratio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;//收取为正,支付为负
if (MarginModes.Contains(position.InterestMode))
{
ratio = -ratio;
}
var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
if (newEodPayPosition == null)
{
newEodPayPosition = new eod_swap_position();
@@ -1085,18 +1030,16 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.TdCloseInterest = flowEvents.Sum(x => x.InterestAmount);
newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee;
//持仓内容-利息腿-损益统计(本方视角)
var intersetAcmount = newEodPayPosition.TdInterestPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate);
if (position.IsAnnualized)
{
intersetAcmount /= tradeExtend.AnnualDays;
}
var intersetAcmount = InterestIncomeCalc.DailyAccrual(
newEodPayPosition.TdInterestPrincipal, newEodPayPosition.TdInterestRate, newEodPayPosition.FloatRate,
newEodPayPosition.IsAnnualized, tradeExtend.AnnualDays);
newEodPayPosition.TdInterestIncome = intersetAcmount;// 要算一下当天产生的利息
var interestIncomeBeforeSettlement = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome;
var interestFeeBeforeSettlement = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee;
var isMaturityFinalSettlement = valueDate.Date >= td.ExerciseDate.Value.Date
&& flowEvents.Any()
&& RoundMoney(interestIncomeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest)
&& RoundMoney(interestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee);
&& EodPnlCalculator.RoundMoney(interestIncomeBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterest)
&& EodPnlCalculator.RoundMoney(interestFeeBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterestFee);
if (isMaturityFinalSettlement)
{
@@ -1107,19 +1050,20 @@ namespace YLErp.Modules.SwapModule
}
else
{
newEodPayPosition.InterestIncomeSum = RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee);
newEodPayPosition.InterestIncomeSum = EodPnlCalculator.RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = EodPnlCalculator.RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee);
}
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值
newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum, (int)ratio);
//累计已实现
newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio;
newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee;
var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio);
newEodPayPosition.RealizedInterest = rolled.Interest;
newEodPayPosition.RealizedInterestFee = rolled.Fee;
SetFixedLegRealizedPnl(newEodPayPosition);
var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true,
position.InterestDirection == (int)SwapDirectionEnum. ? CurrencyRateType.Buy : CurrencyRateType.Sell);
DirectionRatio.RateType(position.InterestDirection));
newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate);
PersistEodSwapPosition(newEodPayPosition);
}
@@ -1176,11 +1120,7 @@ namespace YLErp.Modules.SwapModule
var tradeExtend = td.trade_extend.ExtendObj;
decimal posiNotionalValue = posiLongNotional + posiShortNational;
decimal closePercent = 1;
decimal ratio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;//收取为正,支付为负
if (MarginModes.Contains(position.InterestMode))
{
ratio = -ratio;
}
var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
if (eodPayPosition == null)
{
eodPayPosition = new eod_swap_position();
@@ -1198,7 +1138,7 @@ namespace YLErp.Modules.SwapModule
positions.Add(position);
List<eod_swap_position> preEodPositions = new List<eod_swap_position>();
preEodPositions.Add(eodPayPosition);
var interestModes = new List<int>() { (int)InterestModeEnum., (int)InterestModeEnum., (int)InterestModeEnum. };
var interestModes = MarginModes.FixedAmountAndMargin;
if (interestModes.Contains(position.InterestMode))
{
orginPv = eodPayPosition.InterestPrincipalFix;
@@ -1211,8 +1151,8 @@ namespace YLErp.Modules.SwapModule
// 日终快照仍使用上面的高精度应结金额计算待实现尾差,避免把舍入差提前丢掉。
interests.ForEach(x =>
{
x.InterestAmount = RoundMoney(x.InterestAmount);
x.InterestClosePnL = RoundMoney(x.InterestClosePnL);
x.InterestAmount = EodPnlCalculator.RoundMoney(x.InterestAmount);
x.InterestClosePnL = EodPnlCalculator.RoundMoney(x.InterestClosePnL);
});
decimal settledInterestAmount = interests.Sum(x => x.InterestAmount);
@@ -1249,20 +1189,21 @@ namespace YLErp.Modules.SwapModule
// 到期自动互换是最后一次自动结算:两位实际金额已落流水/资金,待实现不再滚入下一日。
newEodPayPosition.InterestIncomeSum = isMaturityFinalAutoSettlement
? 0
: RoundEodInterest(interestAmountBeforeSettlement - settledInterestAmount);
: EodPnlCalculator.RoundEodInterest(interestAmountBeforeSettlement - settledInterestAmount);
newEodPayPosition.InterestFeeSum = isMaturityFinalAutoSettlement
? 0
: RoundEodInterest(eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee);
: EodPnlCalculator.RoundEodInterest(eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee);
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值
newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum, (int)ratio);
//累计已实现
newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio;
newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee;
var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio);
newEodPayPosition.RealizedInterest = rolled.Interest;
newEodPayPosition.RealizedInterestFee = rolled.Fee;
SetFixedLegRealizedPnl(newEodPayPosition);
var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true,
position.InterestDirection == (int)SwapDirectionEnum. ? CurrencyRateType.Buy : CurrencyRateType.Sell);
DirectionRatio.RateType(position.InterestDirection));
newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate);
PersistEodSwapPosition(newEodPayPosition);
Log.Info($"the last newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
@@ -1293,14 +1234,15 @@ namespace YLErp.Modules.SwapModule
var tradeExtend = td.trade_extend.ExtendObj;
// oriPosiNotionalValue 是平仓前规模,posiNotionalValue 是收盘后剩余规模,closeNational 是本次关闭规模。
// 例如 30% 平仓:303139117.80 = 212197382.46 + 90941735.34。
// 注意:此处的 posiNotionalValue 与盘中 GetUnwindInterests 传给 GetInterests 的语义不同:
// 盘中传平仓前的当前剩余本金,EOD 此处传平仓后的剩余本金;后面又以 closePercent=1
// 调用共享计息器。因此策略的 "posiNotional × closePercent" 在本例会得到 212197382.46
// 而本次实际应结的平仓本金是 closeNational=90941735.34。该语义错位由
// SwapDealService.GetInterests 的模式2无条件修正、模式9全平零值兜底分流处理,不能删除。
decimal oriPosiNotionalValue = posiLongNotional + posiShortNational + closeNational;
decimal posiNotionalValue = posiLongNotional + posiShortNational;
// ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。
decimal ratio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;//收取为正,支付为负
if (MarginModes.Contains(position.InterestMode))
{
ratio = -ratio;
}
var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
// 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。
// 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum
// 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。
@@ -1334,7 +1276,7 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition = eodPayPosition.Clone();
newEodPayPosition.id = 0;
}
var interestModes = new List<int>() { (int)InterestModeEnum., (int)InterestModeEnum., (int)InterestModeEnum. };
var interestModes = MarginModes.FixedAmountAndMargin;
if (interestModes.Contains(position.InterestMode))
{
orginPv = eodPayPosition.InterestPrincipalFix;
@@ -1360,6 +1302,8 @@ namespace YLErp.Modules.SwapModule
List<eod_swap_position> preEodPositions = new List<eod_swap_position>();
preEodPositions.Add(eodPayPosition);
var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true;
// 此处 closePercent=1 表示 EOD 计算本次事件时走全额结息;它不是 closeNational / oriPosiNotionalValue。
// 与上方“收盘后剩余本金”同时传入会触发共享计息器的模式2/9本金修正,见 GetInterests。
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast);
// TdInterestAmount:计息器返回的全腿当日/累计参考值,用于拆出 EOD 的当日新增。
// interestAmountBeforeSettlement:本次事件发生前理论应结的高精度利息。
@@ -1370,11 +1314,11 @@ namespace YLErp.Modules.SwapModule
decimal autoSettledInterestAmount = 0m;
if (autoSwap && interests.Count > 0)
{
autoSettledInterestAmount = RoundMoney(interestAmountBeforeSettlement - manualSettledInterestAmount);
autoSettledInterestAmount = EodPnlCalculator.RoundMoney(interestAmountBeforeSettlement - manualSettledInterestAmount);
var autoInterest = interests[0];
autoInterest.InterestAmount = autoSettledInterestAmount;
autoInterest.InterestClosePnL = autoSettledInterestAmount
* (autoInterest.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m);
* DirectionRatio.ReceivePay(autoInterest.InterestDirection);
}
newEodPayPosition.ValueDate = valueDate;
newEodPayPosition.PositionId = position.id;
@@ -1420,11 +1364,9 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.TdCloseInterest = manualSettledInterestAmount + autoSettledInterestAmount;
// intersetAcmount 是收盘后本金的一天应计展示值。算尾部分平仓时,下面的复利分支会改用
// 平仓前全额本金重算当天新增,但跨日携带的 TdInterestPrincipal 仍只能是剩余本金。
var intersetAcmount = newEodPayPosition.TdInterestPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate);
if (position.IsAnnualized)
{
intersetAcmount /= tradeExtend.AnnualDays;
}
var intersetAcmount = InterestIncomeCalc.DailyAccrual(
newEodPayPosition.TdInterestPrincipal, newEodPayPosition.TdInterestRate, newEodPayPosition.FloatRate,
newEodPayPosition.IsAnnualized, tradeExtend.AnnualDays);
newEodPayPosition.TdInterestIncome = autoSwap
? intersetAcmount
: !hasPreviousEod
@@ -1474,12 +1416,9 @@ namespace YLErp.Modules.SwapModule
var accrualPrincipal = calcLast
? fullPrincipal
: newEodPayPosition.TdInterestPrincipal;
newEodPayPosition.TdInterestIncome = accrualPrincipal
* (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate);
if (position.IsAnnualized)
{
newEodPayPosition.TdInterestIncome /= tradeExtend.AnnualDays;
}
newEodPayPosition.TdInterestIncome = InterestIncomeCalc.DailyAccrual(
accrualPrincipal, newEodPayPosition.TdInterestRate, newEodPayPosition.FloatRate,
newEodPayPosition.IsAnnualized, tradeExtend.AnnualDays);
}
if (!autoSwap
&& closePercent > 0m && closePercent < 1m
@@ -1505,13 +1444,13 @@ namespace YLErp.Modules.SwapModule
// InterestIncomeSum 是收盘后仍未结算的尾差/剩余利息。
// 部分平仓:扣款前待实现 - TdCloseInterest;最终全平且两位金额已覆盖时直接清零。
newEodPayPosition.InterestIncomeSum = closePercent == 1
&& RoundMoney(pendingInterestBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest)
&& EodPnlCalculator.RoundMoney(pendingInterestBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterest)
? 0m
: RoundEodInterest(pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest);
: EodPnlCalculator.RoundEodInterest(pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = closePercent == 1
&& RoundMoney(pendingInterestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee)
&& EodPnlCalculator.RoundMoney(pendingInterestFeeBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterestFee)
? 0m
: RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee);
: EodPnlCalculator.RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee);
//持仓内容-利息腿-损益统计(本方视角)
// InterestProfitSum 是利息腿待实现总额,包含利息和费用;无费用时等于 InterestIncomeSum。
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
@@ -1525,11 +1464,12 @@ namespace YLErp.Modules.SwapModule
//累计已实现
// RealizedInterest 只增不回滚:上日累计已实现 + 当日结息按方向后的金额。
// 收取腿的 -37119.14 会把累计已实现更新为 -37119.14;后续普通 EOD 保持该值。
newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio;
newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee;
var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio);
newEodPayPosition.RealizedInterest = rolled.Interest;
newEodPayPosition.RealizedInterestFee = rolled.Fee;
SetFixedLegRealizedPnl(newEodPayPosition);
var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true,
position.InterestDirection == (int)SwapDirectionEnum. ? CurrencyRateType.Buy : CurrencyRateType.Sell);
DirectionRatio.RateType(position.InterestDirection));
newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate);
Log.Info($"即将插入数据库的 newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
PersistEodSwapPosition(newEodPayPosition);
@@ -1549,7 +1489,7 @@ namespace YLErp.Modules.SwapModule
Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
List<IntervalModel> intervals = position.SwapIntervalList;
var tradeExtend = td.trade_extend.ExtendObj;
var interestModes = new List<int>() { (int)InterestModeEnum., (int)InterestModeEnum., (int)InterestModeEnum. };
var interestModes = MarginModes.FixedAmountAndMargin;
if (eodPayPosition == null)
{
//if (position.PosiStartDate > valueDate)
@@ -1604,11 +1544,7 @@ namespace YLErp.Modules.SwapModule
{
closePercent = 1;
}
decimal ratio = eodPayPosition.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;//收取为正,支付为负
if (MarginModes.Contains(position.InterestMode))
{
ratio = -ratio;
}
var ratio = DirectionRatio.InterestLegPnl(eodPayPosition.InterestDirection, position.InterestMode);
List<swap_position> positions = new List<swap_position>
{
position
@@ -1647,11 +1583,12 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum, (int)ratio);
//累计已实现
newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio;
newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee;
var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio);
newEodPayPosition.RealizedInterest = rolled.Interest;
newEodPayPosition.RealizedInterestFee = rolled.Fee;
SetFixedLegRealizedPnl(newEodPayPosition);
var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true,
eodPayPosition.InterestDirection == (int)SwapDirectionEnum. ? CurrencyRateType.Buy : CurrencyRateType.Sell);
DirectionRatio.RateType(eodPayPosition.InterestDirection));
newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate);
Log.Info($"the last newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
PersistEodSwapPosition(newEodPayPosition);
@@ -1692,7 +1629,7 @@ namespace YLErp.Modules.SwapModule
bool open)
{
payQty = Math.Abs(payQty);
int ratio = eventFlow.PayDirection == (int)SwapDirectionEnum. ? 1 : -1;//收取为正,支付为负
int ratio = DirectionRatio.ReceivePay(eventFlow.PayDirection);//收取为正,支付为负
int shortRatio = DirectionRatio.LongShort(newEodPayPosition.PositionType);
newEodPayPosition.ValueDate = eventFlow.PayDate.Value;
newEodPayPosition.PositionId = eventFlow.PositionId;
@@ -1740,7 +1677,7 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.TdPosiDividend = Math.Round(dividendIn * ratio, 2);
newEodPayPosition.PosiMtmPnL = MtmCalc.UnrealizedPnl(newEodPayPosition.UnderlyingPrice, newEodPayPosition.PosiGrossPrice, newEodPayPosition.PosiQuantity, newEodPayPosition.ContractSize, shortRatio, (int)ratio);
newEodPayPosition.PosiDividendSum = Math.Round(newEodPayPosition.TdPosiDividend - newEodPayPosition.TdCloseDividend, 2);
newEodPayPosition.PosiProfitSum = newEodPayPosition.PosiMtmPnL + newEodPayPosition.PosiDividendSum + newEodPayPosition.PosiFeePending;
newEodPayPosition.PosiProfitSum = MtmCalc.ReturnLegProfitSum(newEodPayPosition.PosiMtmPnL, newEodPayPosition.PosiDividendSum, newEodPayPosition.PosiFeePending);
//持仓价值
newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum);
@@ -1748,7 +1685,7 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.RealizedFee = closeFee;
newEodPayPosition.RealizedMtmPnL = newEodPayPosition.TdCloseMtmPnl;
newEodPayPosition.RealizedDividend = newEodPayPosition.TdCloseDividend;
SetFloatingRealizedPnl(newEodPayPosition);
EodPnlCalculator.SetFloatingRealizedPnl(newEodPayPosition);
newEodPayPosition.PosiStatus = payQty == 0 ? 1 : 0;
UpdateDbOption(newEodPayPosition);
@@ -1792,7 +1729,7 @@ namespace YLErp.Modules.SwapModule
}
var dealDate = curretEod.ValueDate;
int shortRatio = DirectionRatio.LongShort(eod.PositionType);
int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum. ? 1 : -1;
int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection);
curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0;
var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp);
curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp);
@@ -1813,7 +1750,7 @@ namespace YLErp.Modules.SwapModule
curretEod.PosiMtmPnL = MtmCalc.UnrealizedPnl(curretEod.UnderlyingPrice, curretEod.PosiGrossPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio, directionRatio);
//curretEod.TdPosiDividend = 0;
//curretEod.PosiDividendSum = eod.PosiDividendSum + curretEod.TdPosiDividend;
curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending;
curretEod.PosiProfitSum = MtmCalc.ReturnLegProfitSum(curretEod.PosiMtmPnL, curretEod.PosiDividendSum, curretEod.PosiFeePending);
curretEod.TdCloseFee = 0;
curretEod.TdCloseQty = 0;
curretEod.TdCloseMtmPnl = 0;
@@ -1822,9 +1759,9 @@ namespace YLErp.Modules.SwapModule
curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl;
curretEod.RealizedDividend = eod.RealizedDividend + curretEod.TdCloseDividend;
curretEod.RealizedFee = eod.RealizedFee + curretEod.TdCloseFee;
SetFloatingRealizedPnl(curretEod);
EodPnlCalculator.SetFloatingRealizedPnl(curretEod);
var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, td.StartDate.Value
, seekPreday: true, currencyRateType: curretEod.PosiDirection == (int)SwapDirectionEnum. ? CurrencyRateType.Buy : CurrencyRateType.Sell);
, seekPreday: true, currencyRateType: DirectionRatio.RateType(curretEod.PosiDirection));
curretEod.TdCurrency = Convert.ToDecimal(currencyRate);
//持仓价值
curretEod.SwapPositionValue = PositionValueCalc.Calc(curretEod.InterestProfitSum, curretEod.PosiProfitSum);
@@ -1837,17 +1774,6 @@ namespace YLErp.Modules.SwapModule
return curretEod;
}
/// <summary>
/// 浮动腿累计已实现盈亏由盯市、分红和费用三个已实现组成项汇总。
/// 各组成项已经按本方视角落库,此处不再额外转换方向。
/// </summary>
private static void SetFloatingRealizedPnl(eod_swap_position position)
{
position.RealizedPnl = position.RealizedMtmPnL
+ position.RealizedDividend
+ position.RealizedFee;
}
/// <summary>
/// 更新虚拟交易费用
/// </summary>
@@ -1884,7 +1810,7 @@ namespace YLErp.Modules.SwapModule
}
var dealDate = curretEod.ValueDate;
int shortRatio = DirectionRatio.LongShort(eod.PositionType);
int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum. ? 1 : -1;
int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection);
var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp);
var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents);
var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice;
@@ -1923,16 +1849,16 @@ namespace YLErp.Modules.SwapModule
{
curretEod.PosiDividendSum = 0;
}
SetFloatingRealizedPnl(curretEod);
EodPnlCalculator.SetFloatingRealizedPnl(curretEod);
curretEod.SwapPositionValue -= curretEod.TdCloseDividend;
curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending;
curretEod.PosiProfitSum = MtmCalc.ReturnLegProfitSum(curretEod.PosiMtmPnL, curretEod.PosiDividendSum, curretEod.PosiFeePending);
if (curretEod.PosiStatus == 1)
{
curretEod.PosiNotionalValue = 0;
}
var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, td.StartDate.Value
, seekPreday: true, currencyRateType: curretEod.PosiDirection == (int)SwapDirectionEnum. ? CurrencyRateType.Buy : CurrencyRateType.Sell);
, seekPreday: true, currencyRateType: DirectionRatio.RateType(curretEod.PosiDirection));
curretEod.TdCurrency = Convert.ToDecimal(currencyRate);
//持仓价值
curretEod.SwapPositionValue = PositionValueCalc.Calc(curretEod.InterestProfitSum, curretEod.PosiProfitSum);
@@ -1972,7 +1898,7 @@ namespace YLErp.Modules.SwapModule
return;
}
int shortRatio = DirectionRatio.LongShort(eod.PositionType);
int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum. ? 1 : -1;
int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection);
var unwindFlowEvents = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.).ToList();
var openFlowEvents = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.).ToList();
decimal unwindQty = unwindFlowEvents.Sum(s => s.Quantity);
@@ -2005,16 +1931,16 @@ namespace YLErp.Modules.SwapModule
{
posiQty = 0;
}
curretEod.PosiGrossPrice = (eod.PosiGrossPrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountAvg)) / (eod.PosiQuantity + openQty);
curretEod.PosiGrossPrice = MtmCalc.BlendPrice(eod.PosiGrossPrice, eod.PosiQuantity, openFlowEvents.Sum(a => a.Quantity * a.TradingAmountAvg), eod.PosiQuantity + openQty);
curretEod.PosiGrossPrice = Math.Round(
curretEod.PosiGrossPrice,
GetStorageDeliveryPriceRound(curretEod.UnderlyingInstrumentType, curretEod.UnderlyingCode),
MidpointRounding.AwayFromZero);
curretEod.PosiNetPrice = (eod.PosiNetPrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountFeeAvg)) / (eod.PosiQuantity + openQty);
curretEod.PosiNetPrice = MtmCalc.BlendPrice(eod.PosiNetPrice, eod.PosiQuantity, openFlowEvents.Sum(a => a.Quantity * a.TradingAmountFeeAvg), eod.PosiQuantity + openQty);
curretEod.PosiNetPrice = Math.Round(curretEod.PosiNetPrice, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
curretEod.PosiNetNoFeePrice = (eod.PosiNetNoFeePrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountNetAvg)) / (eod.PosiQuantity + openQty);
curretEod.PosiNetNoFeePrice = MtmCalc.BlendPrice(eod.PosiNetNoFeePrice ?? 0m, eod.PosiQuantity, openFlowEvents.Sum(a => a.Quantity * (a.TradingAmountNetAvg ?? 0m)), eod.PosiQuantity + openQty);
curretEod.PosiNetNoFeePrice = Math.Round(curretEod.PosiNetNoFeePrice ?? 0, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
curretEod.PosiNetFeePrice = (eod.PosiNetFeePrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountNetFeeAvg)) / (eod.PosiQuantity + openQty);
curretEod.PosiNetFeePrice = MtmCalc.BlendPrice(eod.PosiNetFeePrice ?? 0m, eod.PosiQuantity, openFlowEvents.Sum(a => a.Quantity * (a.TradingAmountNetFeeAvg ?? 0m)), eod.PosiQuantity + openQty);
curretEod.PosiNetFeePrice = Math.Round(curretEod.PosiNetFeePrice ?? 0, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
}
curretEod.PosiNotionalValue = curretEod.PosiGrossPrice * curretEod.PosiQuantity * curretEod.ContractSize;
@@ -2053,7 +1979,7 @@ namespace YLErp.Modules.SwapModule
curretEod.PositionId = position.id;
curretEod.ClientId = td.ClientId;
int shortRatio = DirectionRatio.LongShort(position.PositionType);
int directionRatio = position.PosiDirection == (int)SwapDirectionEnum. ? 1 : -1;
int directionRatio = DirectionRatio.ReceivePay(position.PosiDirection);
curretEod.PositionType = position.PositionType;
var eod = new eod_swap_position()
{
@@ -2099,11 +2025,11 @@ namespace YLErp.Modules.SwapModule
curretEod.UnderlyingMarketValue = MtmCalc.MarketValue(curretEod.UnderlyingPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio);
curretEod.PosiMtmPnL = MtmCalc.UnrealizedPnl(curretEod.UnderlyingPrice, curretEod.PosiGrossPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio, directionRatio);
curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending;
curretEod.PosiProfitSum = MtmCalc.ReturnLegProfitSum(curretEod.PosiMtmPnL, curretEod.PosiDividendSum, curretEod.PosiFeePending);
curretEod.RealizedMtmPnL = curretEod.TdCloseMtmPnl;
curretEod.RealizedDividend = curretEod.TdCloseDividend;
curretEod.RealizedFee = curretEod.TdCloseFee;
SetFloatingRealizedPnl(curretEod);
EodPnlCalculator.SetFloatingRealizedPnl(curretEod);
curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0;
if (curretEod.PosiStatus == 1)
{
@@ -2112,7 +2038,7 @@ namespace YLErp.Modules.SwapModule
//持仓价值
curretEod.SwapPositionValue = PositionValueCalc.Calc(curretEod.InterestProfitSum, curretEod.PosiProfitSum);
var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, td.StartDate.Value
, seekPreday: true, currencyRateType: curretEod.PosiDirection == (int)SwapDirectionEnum. ? CurrencyRateType.Buy : CurrencyRateType.Sell);
, seekPreday: true, currencyRateType: DirectionRatio.RateType(curretEod.PosiDirection));
curretEod.TdCurrency = Convert.ToDecimal(currencyRate);
UpdateDbOption(curretEod);
curretEod.Invalid = false;
@@ -2187,8 +2113,6 @@ namespace YLErp.Modules.SwapModule
var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿
// 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模,
// 不能直接用多空腿相加,否则会把对冲方向误当成合约规模变化。
eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.NotionalValue = Math.Round(Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.SwapTradeId = td.id;
eod_Swap.SwapTradeNo = td.TradeNumber;
@@ -2196,23 +2120,8 @@ namespace YLErp.Modules.SwapModule
eod_Swap.BookId = td.AssetId;
eod_Swap.ValueDate = settleDate;
eod_Swap.StructureType = td.StructureType;
eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue);
eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue);
eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum);
eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0);
decimal interestPnL = 0;
// 利息腿按我方视角归集。保证金腿的利息现金流方向与普通利息腿相反,
// 因此保证金腿需要额外反转符号,确保 InterestPnL 表示我方的合约利率端收益。
interestPositions.ForEach(x =>
{
decimal ratio = x.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1;//收取为正,支付为负
if (MarginModes.Contains(x.InterestMode))
{
ratio = -ratio;
}
interestPnL += x.InterestProfitSum * ratio;
});
eod_Swap.InterestPnL = interestPnL;
EodPnlCalculator.FillPositionLegSummary(eod_Swap, positions);
eod_Swap.InterestPnL = EodPnlCalculator.SumInterestPnL(interestPositions);
eod_Swap.PostionValue = eodSwapPositions.Sum(s => s.SwapPositionValue);
// 保证金腿的利息现金流方向与保证金本金方向相反。
// 不能直接汇总 RealizedPnl,否则“收取客户保证金”的腿会把应支付给客户的
@@ -2265,33 +2174,15 @@ namespace YLErp.Modules.SwapModule
var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿
var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿
eod_Swap.NotionalValue = Math.Round(Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue);
eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue);
eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum);
eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0);
interestPositions.ForEach(x =>
{
decimal ratio = x.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1;//收取为正,支付为负
if (MarginModes.Contains(x.InterestMode))
{
ratio = -ratio;
}
eod_Swap.InterestPnL += x.InterestProfitSum * ratio;
});
EodPnlCalculator.FillPositionLegSummary(eod_Swap, positions);
eod_Swap.InterestPnL += EodPnlCalculator.SumInterestPnL(interestPositions);
eodSwapPositions.ForEach(x =>
{
decimal ratio = x.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1;//收取为正,支付为负
if (MarginModes.Contains(x.InterestMode))
{
ratio = -ratio;
}
var ratio = DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode);
eod_Swap.TdRealizedPnL += x.TdCloseMtmPnl + x.TdCloseDividend + x.TdCloseFee + x.TdCloseInterest * ratio + x.TdCloseInterestFee;
});
eod_Swap.PostionValue = eodSwapPositions.Sum(s => s.SwapPositionValue);
eod_Swap.RealizedPnL = eodSwapPositions.Sum(CalculateSwapRealizedPnl);
eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty);
var tradeInitMarginObj = DbContext.trade_initial_margin.FirstOrDefault(x => x.TradeId == td.id);
var initMarginList = interestPositions.Where(x => x.InterestMode == (int)InterestModeEnum. && x.HappenDate == settleDate).ToList();
var addMarginList = interestPositions.Where(x => x.InterestMode == (int)InterestModeEnum. && x.HappenDate == settleDate).ToList();
@@ -2310,20 +2201,7 @@ namespace YLErp.Modules.SwapModule
/// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。
/// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。
/// </summary>
public static decimal CalculateSwapRealizedPnl(eod_swap_position position)
{
var interestRatio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;
if (MarginModes.Contains(position.InterestMode))
{
interestRatio = -interestRatio;
}
return position.RealizedMtmPnL
+ position.RealizedDividend
+ position.RealizedFee
+ position.RealizedInterest * interestRatio
+ position.RealizedInterestFee;
}
public static decimal CalculateSwapRealizedPnl(eod_swap_position position) => EodPnlCalculator.CalculateSwapRealizedPnl(position);
/// <summary>
/// 风险报表符号归一化:把历史两种符号口径的 TdCloseInterest/RealizedInterest
@@ -2332,24 +2210,7 @@ namespace YLErp.Modules.SwapModule
/// 抽为 public static 纯函数以支持无库单测(见 SwapReportInterestSignNormalizeTest)。
/// 仅当 InterestDirection > 0 时执行(与原内联逻辑等价)。
/// </summary>
public static void NormalizeInterestSignForReport(eod_swap_position position)
{
if (position.InterestDirection <= 0) return;
var interestRatio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;
if (MarginModes.Contains(position.InterestMode))
{
interestRatio = -interestRatio;
}
else if (position.InterestMode == (int)InterestModeEnum.)
{
return;
}
position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio;
position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio;
// 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。
position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee;
}
public static void NormalizeInterestSignForReport(eod_swap_position position) => EodPnlCalculator.NormalizeInterestSignForReport(position);
/// <summary>
/// 获取多空组合 平仓详细
@@ -2838,11 +2699,11 @@ namespace YLErp.Modules.SwapModule
var floatRateInterest = eodInterests.Where(x => !string.IsNullOrEmpty(x.FloatRateUnderlyingCode)).FirstOrDefault();
item.position.FloatRateUnderlyingCode = floatRateInterest?.FloatRateUnderlyingCode;
item.position.FloatRate = floatRateInterest?.FloatRate ?? 0;
item.OpenMarginAmount = initialMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1));
item.OpenMarginRate = CalculateWeightedMarginRate(tradeMargins);
item.AdditionalMarginAmount = additionalMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1));
item.OpenMarginAmount = initialMargins.Sum(s => s.InterestPrincipalFix * DirectionRatio.ReceivePay(s.InterestDirection));
item.OpenMarginRate = EodPnlCalculator.CalculateWeightedMarginRate(tradeMargins);
item.AdditionalMarginAmount = additionalMargins.Sum(s => s.InterestPrincipalFix * DirectionRatio.ReceivePay(s.InterestDirection));
item.MarginInterestAmount = CalculateWeightedMarginInterest(eodMargins);
item.InterestAmount = eodInterests.Sum(s => s.InterestIncomeSum * (s.InterestDirection == (int)SwapDirectionEnum. ? -1 : 1));
item.InterestAmount = eodInterests.Sum(s => s.InterestIncomeSum * (-DirectionRatio.ReceivePay(s.InterestDirection)));
item.InterestRate = eodInterests.Sum(s => s.InterestRateDefault);
// 到期轧差才把期间付息/分红并入净额结算;派息日支付已在现金流层独立结算,不能重复计入估值。
var nettingDividend = (tradeExtend?.ExtendObj?.DividendPayDate ?? 1) == 0 ? pendingDividend : 0m;
@@ -2864,29 +2725,12 @@ namespace YLErp.Modules.SwapModule
return retListResult;
}
/// <summary>
/// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权,
/// 不按收付方向轧差,避免相反方向本金抵消后放大利率。
/// </summary>
private static decimal CalculateWeightedMarginRate(IEnumerable<swap_position> margins)
{
var marginList = margins.ToList();
var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix));
return totalWeight == 0
? 0
: marginList.Sum(x => x.InterestRateDefault * Math.Abs(x.InterestPrincipalFix)) / totalWeight;
}
/// <summary>
/// 计算预付金利息金额。InterestIncomeSum 已是各腿利息金额,
/// 按收取为正、支付为负直接轧差求和,不做本金加权。
/// 抽为 public static 纯函数以支持无库单测(见 SwapWeightedMarginInterestTest)。
/// </summary>
public static decimal CalculateWeightedMarginInterest(IEnumerable<eod_swap_position> margins)
{
return margins.Sum(x =>
x.InterestIncomeSum * (x.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1));
}
public static decimal CalculateWeightedMarginInterest(IEnumerable<eod_swap_position> margins) => EodPnlCalculator.CalculateWeightedMarginInterest(margins);
/// <summary>
/// 固定利息腿的累计已实现盈亏 = 累计已实现利息 + 累计已实现利息费用。
@@ -2894,10 +2738,7 @@ namespace YLErp.Modules.SwapModule
/// 抽为 public static 纯函数以支持无库单测(见 SwapFixedLegRealizedPnlTest),
/// 并消除复制粘贴带来的笔误风险(如 L1296 历史双分号)。
/// </summary>
public static void SetFixedLegRealizedPnl(eod_swap_position position)
{
position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee;
}
public static void SetFixedLegRealizedPnl(eod_swap_position position) => EodPnlCalculator.SetFixedLegRealizedPnl(position);
/// <summary>
/// 将数据库中以公司/交易簿记方向保存的日终字段转换为客户视角。
/// 该转换必须在拆分浮动收益、费用和期间付息/分红之前完成,
@@ -0,0 +1,43 @@
using YLErp.DBModels;
using YLErp.DBModels.Consts;
namespace YLErp.Modules.SwapModule;
/// <summary>
/// 平仓手续费计算——纯 static,无 this 依赖。
/// 从 SwapDealService 提取,零行为变更。
/// </summary>
public static class TradingFeeCalc
{
public static decimal CalcInitTradingFee(swap_position oriPosition, UnwindData unwindData)
{
if (oriPosition == null || unwindData == null)
{
return 0;
}
if (oriPosition.PosiFeeType == 1)
{
return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
public static decimal CalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData)
{
if (oriPosition == null || unwindData == null || oriPosition.PosiTradingFeeUnit == 0)
{
return position?.PosiTradingFeePending ?? 0;
}
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);
}
}
@@ -0,0 +1,104 @@
using YLErp.Helpers;
using YLErp.Modules.SwapModule.Margin;
namespace YLErp.Modules.SwapModule;
/// <summary>
/// 平仓数据(UnwindData)规范化——纯 static,无 this 依赖。
/// 从 SwapDealService 提取,零行为变更。
/// </summary>
internal static class UnwindNormalizer
{
internal static void NormalizeNotionalValues(UnwindData unwindData)
{
unwindData.NotionalValue = Math.Round(unwindData.NotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
unwindData.PosiNotionalValue = Math.Round(unwindData.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
internal 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;
}
internal 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 = MarginModes.Contains(leg.InterestMode)
? 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;
}
internal static bool IsFullCloseAfterDeduction(UnwindData unwindData, double remainingNotional, double remainingQuantity)
{
return unwindData.ClosePercent == 1 || (remainingNotional == 0 && remainingQuantity == 0);
}
/// <summary>
/// 手工平仓、手工互换及收益结算的利息事件按金额两位落库。
/// 自动平仓保留原有计算与落库口径,不适用本阶段的手工结算规则。
/// </summary>
internal static bool NormalizeSettledInterestAmounts(IEnumerable<swap_flow_event> flowEvents, int eventType, string eventReason)
{
if ((eventType != (int)SwapEventTypeEnum. && eventType != (int)SwapEventTypeEnum.)
|| eventReason == "系统操作_自动平仓")
{
return false;
}
foreach (var flowEvent in flowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)))
{
flowEvent.InterestPrincipal = Math.Round(flowEvent.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.InterestAmount = Math.Round(flowEvent.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.TdInterestAmount = Math.Round(flowEvent.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.InterestClosePnL = Math.Round(flowEvent.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.InterestFee = Math.Round(flowEvent.InterestFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
return true;
}
internal static void NormalizeEventUnwindDate(UnwindData unwindData)
{
unwindData.UnwindDate = unwindData.ValueDate;
}
}
@@ -0,0 +1,66 @@
# 任务 4:盘中复利从 EOD 续接(而非 PosiStartDate 全程重放)
## 状态:待立项(高风险,需专项验证)
## 现状
`CalcDailyCompoundInterest``position.PosiStartDate` 全程重放到 `endDate`,每个重置日把累计利息并入本金(复利),最后扣 `consumedInterest * closePercent`
**调用链**`InitSwapDealInterest``CalcDailyCompoundInterest(endDate, PosiStartDate→endDate 全程重放)`
**问题**:交易存续期长(数月~数年)时,每次盘中平仓都从起息日重放,计算量随天数线性增长。
## 提议
改为从上一日终快照(`preEodPosition`)续接:
- 起点 = `preEodPosition.ValueDate + 1`
- 初始本金 = `preEodPosition.TdInterestPrincipal`(已含历史滚入利息)
- 只算 `ValueDate+1``endDate` 的增量利息
## 风险分析(为什么不能直接改)
### 风险 1:并本金起点不同导致终值不等
| | 全程重放(当前) | EOD 续接(提议) |
|---|---|---|
| 起点 | `principal`(原始平仓名义本金) | `preEod.TdInterestPrincipal`(已滚利息) |
| 滚法 | 每段 `basis = principal + accrued` | 每段 `basis = preEod.TdInterestPrincipal + segmentAccrued` |
两段路径在**中间重置日的四舍五入路径不同**(精度 12 的 Round 作用在不同的中间值上),终值**不一定逐分相等**。
### 风险 2consumedInterest 语义翻转
- 全程重放:总利息 - consumedInterest × closePercent = 增量
- EOD 续接:直接算增量,**不需要**扣 consumedInterest
如果 EOD 快照的 `InterestIncomeSum` 与 consumedInterest 口径不完全一致,直接去掉扣减会引入误差。
### 风险 3resetCarryInterest 耦合
当前逻辑:`resetCarryInterest`(上一日终待实现 × remainingPercent)只在 `endDate` 恰好是重置日时并入本金。EOD 续接模式下,重置日的判定、remainingPercent 的计算、carry 的注入时机都不同。
### 风险 4:全平重放逻辑(lines 1293-1304
`InitSwapDealInterest``closePrecent == 1m` 时做 **两次** `CalcDailyCompoundInterest` 重放(截至平仓日 + 截至上一日终),取差值。EOD 续接模式下这段逻辑需要完全重新设计。
## 验证方案(立项前提)
1. 构造测试用例:同一笔复利交易,跨越 ≥2 个重置周期,有 preEod 快照
2. 用**旧全程重放**算出 `(InterestAmount, TdInterestAmount, finalBasis)`
3. 用**新 EOD 续接**算出同样三个值
4. 断言差额 < 0.01(到分)
5. 覆盖场景:
- 部分平仓(closePercent < 1
- 全平(closePercent == 1
- 平仓日 = 重置日
- 平仓日 ≠ 重置日
- 有/无 consumedInterest
- 有/无 resetCarryInterest
## 建议排期
单独 sprint 处理,不混入日常重构。改动范围:
- `CompoundInterestAccrual.AccruePeriod` 新增 `startBasis` 参数(或新方法 `AccrueFromEod`
- `CalcDailyCompoundInterest` wrapper 改为传 `preEod.TdInterestPrincipal` 作为起点
- `InitSwapDealInterest` 全平重放逻辑简化(不再需要两次重放取差值)
- `consumedInterest` 扣减逻辑移除或调整
@@ -1536,11 +1536,6 @@ namespace YLErp.Modules.SystemModule
Text = SwapTypeEnum..ToString(),
Value = SwapTypeEnum..ToString()
},
new SelectItem
{
Text = SwapTypeEnum..ToString(),
Value = SwapTypeEnum..ToString()
}
};
}
@@ -352,36 +352,6 @@ namespace YLErp.Web.Controllers
return View(model);
}
/// <summary>
/// 收益互换 多空组合平仓
/// </summary>
/// <param name="enid"></param>
/// <returns></returns>
public ActionResult SwapLongShortUnwind(string enid, bool isUseApproval = false)
{
var intid = DecryptInt(enid);
var model = new SwapDealService(CurUser).InitLongShortUnwind(intid, SwapEventTypeEnum.);
ViewBag.isUseApproval = isUseApproval;
var hasProcess = new SwapDealService(CurUser).HasTradeProcess();
//需要审批或者复核的交易都会显示行权审核提交按钮
ViewBag.IsShowReCheckClose = (valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess);
return View(model);
}
/// <summary>
/// 收益互换 多空组合互换
/// </summary>
/// <param name="enid"></param>
/// <returns></returns>
public ActionResult SwapLongShortSwap(string enid, bool isUseApproval = false)
{
var intid = DecryptInt(enid);
var model = new SwapDealService(CurUser).InitLongShortUnwind(intid, SwapEventTypeEnum.);
ViewBag.isUseApproval = isUseApproval;
var hasProcess = new SwapDealService(CurUser).HasTradeProcess();
//需要审批或者复核的交易都会显示行权审核提交按钮
ViewBag.IsShowReCheckClose = (valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess);
return View(model);
}
/// <summary>
/// 操作历史
/// </summary>
/// <param name="enid"></param>
@@ -457,26 +427,6 @@ namespace YLErp.Web.Controllers
return JsonSuccess("平仓成功");
}
/// <summary>
///多空组合 平仓
/// </summary>
/// <param name="swap_Deal"></param>
/// <returns></returns>
public JsonResult SwapLongShortUnwindJson(UnwindData unwindData)
{
new SwapDealService(CurUser).SwapLongShortUnwind(unwindData);
return JsonSuccess("平仓成功");
}
/// <summary>
///多空组合 互换
/// </summary>
/// <param name="swap_Deal"></param>
/// <returns></returns>
public JsonResult SwapLongShortJson(UnwindData unwindData)
{
new SwapDealService(CurUser).SwapLongShort(unwindData);
return JsonSuccess("互换成功");
}
/// <summary>
/// 互换
/// </summary>
/// <param name="swap_Deal"></param>
@@ -1,126 +0,0 @@
@model UnwindData
@{
ViewBag.Title = "交易 | 收益结算";
Layout = "~/Views/Shared/_InfoLayout.cshtml";
bool isUseApproval = ViewBag.isUseApproval;
bool isShowReCheckClose = ViewBag.IsShowReCheckClose;
}
@section CSS{
<link rel="stylesheet" href="~/Style/Css/unwindSwapTrade.css?v=@(HtmlUtil.JsVersion)" />
}
@section JS
{
<script>
var model = @Json.Serialize(Model);
model.StartDate = model.StartDate ? model.StartDate.substr(0, 10) : "";
model.ValueDate = model.ValueDate ? model.ValueDate.substr(0, 10) : "";
var isUseApproval = "@isUseApproval" == "True";
var g_isShowReCheckClose = "@isShowReCheckClose" == "True";
</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">
<div class="yc-panel">
<div id="commomArea" class="row" style="height:140px">
<div class="form-group col-md-4">
<label class="formlabel">成交名义本金</label>
<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" :value="formatAmount(deal.PosiNotionalValue)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-4">
<label class="formlabel">起始日期</label>
<input class="text-box" id="startDate" readonly="readonly" type="text" v-model="deal.StartDate" />
</div>
<div class="form-group col-md-4">
<label class="formlabel">收益结算日期</label>
<vue-datepicker :maxdate="maxUnwindDate" :mindate="minStartDate" :holiday="1" v-model="deal.ValueDate" v-on:input="setValueDate" />
</div>
<div class="form-group col-md-4">
<label class="formlabel">年化天数</label>
<input class="text-box" v-model="deal.AnnualDays" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-4">
<label class="formlabel">平仓总额</label>
<input class="text-box" :value="formatAmount(deal.SwapCloseAmount)" readonly="readonly" type="text" />
</div>
</div>
</div>
<div class="yc-panel">
<div class="row no-gutters" style="display:block;">
<div class="titleDiv">
<div class="titleHead"></div>
<div class="titleLabel"><label>预付金/预付金</label></div>
</div>
<div style="margin: 5px 0 0 12px">
<table class="table table-bordered ratetable" style="width:750px;">
<tbody>
<tr>
<td width="116px">收支方向</td>
<td>利息金额</td>
<td>其他费用</td>
<td>预付金/预付金平仓盈亏</td>
</tr>
<tr v-for="item in marginList">
<td :class="item.InterestDirection==1?'swapget':'swappay'">{{item.InterestDirection==1?"收取":"支付"}}</td>
<td>
<vue-number-input v-model="item.InterestAmount" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>
<vue-number-input v-model="item.InterestFee" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row no-gutters" style="display:block;">
<div class="titleDiv">
<div class="titleHead"></div>
<div class="titleLabel"><label>利息端</label></div>
</div>
<div style="margin: 5px 0 0 12px">
<table class="table table-bordered">
<tbody>
<tr>
<td>收支方向</td>
<td>利息金额</td>
<td>其他费用</td>
<td>利息端平仓盈亏</td>
</tr>
<tr v-for="item in interestList">
<td :class="item.InterestDirection==1?'swapget':'swappay'">{{item.InterestDirection==1?"收取":"支付"}}</td>
<td>
<vue-number-input v-model="item.InterestAmount" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>
<vue-number-input v-model="item.InterestFee" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
</div>
<div>
@if (isUseApproval)
{
<button class="btn btn-default swapclose" type="button" v-on:click="submitApproval('reject');">拒绝</button>
<button class="btn btn-primary swapsave" type="button" v-on:click="submitApproval('pass')">审批通过</button>
}
else
{
<button class="btn btn-default swapclose" type="button" onclick="layer.closeMe();">取消</button>
<button class="btn btn-primary swapsave" type="button" v-on:click="incomeTrade">{{getSumbitText()}}</button>
}
</div>
<div class="clearfix"></div>
</div>
</div>
</div>
@@ -1,138 +0,0 @@
@model UnwindData
@{
ViewBag.Title = "交易 | 交易平仓";
Layout = "~/Views/Shared/_InfoLayout.cshtml";
bool isUseApproval = ViewBag.isUseApproval;
bool isShowReCheckClose = ViewBag.IsShowReCheckClose;
}
@section CSS{
<link rel="stylesheet" href="~/Style/Css/unwindSwapTrade.css?v=@(HtmlUtil.JsVersion)" />
}
@section JS
{
<script>
var model = @Json.Serialize(Model);
model.StartDate = model.StartDate ? model.StartDate.substr(0, 10) : "";
model.ValueDate = model.ValueDate ? model.ValueDate.substr(0, 10) : "";
model.PayDate = model.PayDate ? model.PayDate.substr(0, 10) : "";
var isUseApproval = "@isUseApproval" == "True";
var g_isShowReCheckClose = "@isShowReCheckClose" == "True";
</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">
<div class="yc-panel">
<div id="commomArea" class="row" style="height:140px">
<div class="form-group col-md-3">
<label class="formlabel">成交名义本金</label>
<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" :value="formatAmount(deal.PosiNotionalValue)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">成交数量</label>
<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" :value="formatQuantity(deal.PositionQty)" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">起始日期</label>
<input class="text-box" id="startDate" readonly="readonly" type="text" v-model="deal.StartDate" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">平仓日期</label>
<input class="text-box" id="DealDate" readonly="readonly" type="text" v-model="deal.ValueDate" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">支付日期</label>
<vue-datepicker :mindate="minStartDate" :holiday="1" v-model="deal.PayDate" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">年化天数</label>
<input class="text-box" v-model="deal.AnnualDays" readonly="readonly" type="text" />
</div>
<div class="form-group col-md-3">
<label class="formlabel">平仓总额</label>
<input class="text-box" :value="formatAmount(deal.SwapCloseAmount)" readonly="readonly" type="text" />
</div>
</div>
</div>
<div class="yc-panel">
<div class="row no-gutters" style="display:block;">
<div class="titleDiv">
<div class="titleHead"></div>
<div class="titleLabel"><label>预付金/预付金</label></div>
</div>
<div style="margin: 5px 0 0 12px">
<table class="table table-bordered ratetable" style="width:700px;">
<tbody>
<tr>
<td width="116px">收支方向</td>
<td>资金类别</td>
<td>应返还本金</td>
<td>利息金额</td>
<td>预付金/预付金平仓盈亏</td>
</tr>
<tr v-for="item in marginList">
<td :class="item.InterestDirection==1?'swapget':'swappay'">{{item.InterestDirection==1?"收取":"支付"}}</td>
<td>{{item.InterestModeStr}}</td>
<td>
<vue-number-input v-model="item.InterestPrincipal" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>
<vue-number-input v-model="item.InterestAmount" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row no-gutters" style="display:block;">
<div class="titleDiv">
<div class="titleHead"></div>
<div class="titleLabel"><label>利息端</label></div>
</div>
<div style="margin: 5px 0 0 12px">
<table class="table table-bordered" style="width:750px;">
<tbody>
<tr>
<td>收支方向</td>
<td>利息金额</td>
<td>利息端平仓盈亏</td>
</tr>
<tr v-for="item in interestList">
<td :class="item.InterestDirection==1?'swapget':'swappay'">{{item.InterestDirection==1?"收取":"支付"}}</td>
<td>
<vue-number-input v-model="item.InterestAmount" v-bind:format="inputFormatEqvNotional" v-on:input="changeInterestAmount(item)"></vue-number-input>
</td>
<td>{{formatAmount(item.InterestClosePnL)}}</td>
</tr>
</tbody>
</table>
</div>
<div>
@if (isUseApproval)
{
<button class="btn btn-default swapclose" type="button" v-on:click="submitApproval('reject');">拒绝</button>
<button class="btn btn-primary swapsave" type="button" v-on:click="submitApproval('pass')">审批通过</button>
}
else
{
<button class="btn btn-default swapclose" type="button" onclick="layer.closeMe();">取消</button>
<button class="btn btn-primary swapsave" type="button" v-on:click="closeTrade">{{getSumbitText()}}</button>
}
</div>
<div class="clearfix"></div>
</div>
</div>
</div>
-8
View File
@@ -141,10 +141,6 @@
{
@MyControls.Btn("平仓", string.Format("unWindSwapTrade('{0}')", tradeModel.EncryptId))
}
if (longShort && !hasPosition && tradeModel.CanSwapTradeUnwind() && CurUser.交易管理_交易平仓)
{
@MyControls.Btn("平仓", string.Format("unWindSwapLongShort('{0}')", tradeModel.EncryptId))
}
if (tradeModel.CanSwapTradeUnwind() && CurUser.交易管理_收益互换)
{
@MyControls.Btn("展期信息", string.Format("extensionTime('{0}')", tradeModel.EncryptId))
@@ -153,10 +149,6 @@
{
@MyControls.Btn("收益结算", string.Format("unWindSwap('{0}')", tradeModel.EncryptId))
}
if (longShort && hasPosition && tradeModel.CanSwapTradeUnwind() && CurUser.交易管理_收益互换)
{
@MyControls.Btn("收益结算", string.Format("unWindLongShortSwap('{0}')", tradeModel.EncryptId))
}
@if (CurUser.交易管理_交易续作)
{
@MyControls.Btn("续作", string.Format("renewTrade('{0}')", tradeModel.EncryptId))
@@ -43,43 +43,5 @@
"SwapMarginRebatePnl": 200,
"SwapMarginAmount": 40000
}
},
{
"id": "LONGSHORT_UNWIND",
"desc": "多空组合平仓(unwindLongShort):从0起,不含浮动盈亏(FloatPnlSum=0)",
"eventType": 4,
"deal": { "CloseQty": 0 },
"floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 },
"interestList": [],
"marginList": [
{ "InterestClosePnL": 150, "InterestDirection": 1, "InterestPrincipal": 20000 }
],
"expected": {
"SwapCloseAmount": 150,
"SwapRealizedPnL": 150,
"SwapMarginRebatePnl": 150,
"SwapMarginAmount": -20000
}
},
{
"id": "LONGSHORT_SWAP",
"desc": "多空组合互换(swapLongShort)SwapMarginAmount 用裸 InterestPrincipal(无符号) —— 已知差异",
"eventType": 5,
"deal": { "CloseQty": 0 },
"floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 },
"interestList": [],
"marginList": [
{ "InterestClosePnL": 100, "InterestDirection": 1, "InterestPrincipal": 18000 }
],
"expected": {
"SwapCloseAmount": 100,
"SwapRealizedPnL": 100,
"SwapMarginRebatePnl": 100,
"SwapMarginAmount": 18000
},
"knownDiscrepancies": {
"SwapMarginAmount": 36000
}
}
]
} ]
}
@@ -163,8 +163,6 @@ describe('swap price precision common wiring', () => {
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
@@ -2000,9 +2000,6 @@ const subView = (function (jqGridMgr) {
if (tradeType === "收益互换") {
srcurl = "/swaptrade2/SwapUnwind/?enid=" + encryptId;
if (StructureType=="多空组合") {
srcurl = "/swaptrade2/SwapLongShortUnwind/?enid=" + encryptId;
}
area = ["1300px", "720px"];
}
if (tradeType.indexOf("远期") >= 0) {
@@ -112,7 +112,7 @@ const vue = new Vue({
this.multiplier,
isUseApproval);
this.interestList = model.FlowEvents.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9;
});
this.marginList = model.FlowEvents.filter((item) => {
return item.InterestMode == 5 || item.InterestMode == 6;
@@ -255,7 +255,7 @@ const vue = new Vue({
var postData = { ValueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: 1, eventType:3 }
main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) {
thisObj.interestList = resp.obj.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9;
});
thisObj.marginList = resp.obj.filter((item) => {
return item.InterestMode == 5 || item.InterestMode == 6;
@@ -1,228 +0,0 @@
//otcformat禁止千分位分组
window.otcformat.options.disableGrouping = true;
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',
data: {
deal: model,
interestList: [],
marginList: [],
},
computed: {
maxUnwindDate() {
return ValueDate;
},
minStartDate() {
return this.deal.StartDate;
}
},
created() {
this.initDeal();
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;
});
this.marginList = model.FlowEvents.filter((item) => {
return item.InterestMode == 5 || item.InterestMode == 6;
});
},
setValueDate(e) {//修改平仓日期
if (e) {
this.deal.ValueDate = e;
this.deal.UnwindDate = e;
}
if (!isUseApproval) {
this.getInterestList();
} else {
this.dataFormat();
}
},
dataFormat() {
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 = formatSwapAmount(x.Principal);
//x.Rate = otcformat.fixed6(x.Rate);
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 = formatSwapAmount(x.InterestFee);
x.InterestAmount = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
});
},
changeInterestAmount(item) {//修改利息金额
let interestRatio = item.InterestDirection == 1 ? 1 : -1;
item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee));
this.calcCloseAmount();
},
calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付
let thisObj = this;
thisObj.deal.SwapCloseAmount = 0;
thisObj.deal.SwapRealizedPnL = 0;
thisObj.deal.SwapMarginRebatePnl = 0;
thisObj.deal.SwapMarginAmount = 0;
this.interestList.forEach(x => {
/*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/
let interestAmount = parseFloat(x.InterestClosePnL);
thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount;
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;
});
this.marginList.forEach(x => {
let interestAmount = parseFloat(x.InterestClosePnL);
thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount;
thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount;
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;
thisObj.deal.SwapMarginAmount = parseFloat(thisObj.deal.SwapMarginAmount) + parseFloat(x.InterestPrincipal);
});
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;
var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: 1, eventType: 3 }
main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) {
thisObj.interestList = resp.obj.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
});
thisObj.marginList = resp.obj.filter((item) => {
return item.InterestMode == 5 || item.InterestMode == 6;
});
thisObj.calcCloseAmount();
thisObj.dataFormat();
});
},
incomeTrade() {//互换
var thisObj = this;
if (main.isEmpty(thisObj.deal.ValueDate)) {
main.message("请输入平仓日期");
return;
}
let reqObj = _.cloneDeep(thisObj.deal);
let marginCloneList = _.cloneDeep(thisObj.marginList);
reqObj.FlowEvents = _.cloneDeep(thisObj.interestList);
marginCloneList.forEach((item) => {
reqObj.FlowEvents.push(item);
})
var postData = { unwindData: reqObj };
var msg = "确认提交收益结算?";
var postUrl = "/swaptrade2/SwapLongShortJson";
if (g_isShowReCheckClose) {
msg = "确认提交收益结算审核?";
postUrl = "/swaptrade2/ApplyUnwind";
postData.eventType = 3;//互换3,平仓2
}
main.confirm(msg,
function () {
//重新计算百分比
var thisObj2 = thisObj;
main.post(postUrl, { unwindData: reqObj }).done(function (res) {
if (res.success) {
thisObj2.closetrade_cashWindow();
}
else {
try {
thisObj2.closetrade_cashWindow();
} catch (e) {
}
}
});
});
},
getSumbitText: function () {
return g_isShowReCheckClose ? "审核提交" : "保存";
},
submitApproval(status) {
var pop = '';
if (status === 'pass') {
pop = "确认通过审批?";
}
if (status === 'reject') {
pop = "确认拒绝?";
}
var confirmFunc = function (additionalProcessing) {
var pData = { tradeId: trade.id, status: status, text: "" };
if (!main.isEmpty(additionalProcessing)) {
pData.additionalProcessing = additionalProcessing;
}
var thisObj2 = thisObj;
main.post("/processtradelog/UpdateTradeProcessLog", pData).done(
function (data) {
if (data.obj && data.obj.proccessType == "AdditionalProcessing") {
if (data.obj.type == "LackOfMoney") {
var htmlContent = `<div style="padding:10px">${data.obj.message}</div>`;
var lackMoneyConfirmLayer = main.open2("提示",
htmlContent,
{
area: ["430px", "175px"],
btn: ['交易特批', '取消'],
yes: function (index, layero) {
var layerIndex = lackMoneyConfirmLayer;
main.confirm("客户资金或授信不足,强制成交会导致本机构产生风险!要继续审批通过?", function () {
layer.close(layerIndex);
confirmFunc("LackOfMoney");
});
},
cancel: function (index, layero) {
if (window.parent && window.parent.reloadtrade) {
thisObj2.closetrade_cashWindow();
}
(parent || window).layer.closeAll();
}
});
}
return;
}
(parent || window).main.message(data.msg);
try { thisObj2.closetrade_cashWindow(); }
catch (e) { }
if (parent) {
parent.layer.closeAll();
}
});
}
main.confirm(pop, confirmFunc);
},
closetrade_cashWindow: function () {
layer.closeMe('reloadData');
},
closeCurrentWindow: function () {
try {
if (window.parent && window.parent.reload) window.parent.reload();
} catch (e) {
}
try {
var layer = window.parent.layer;
layer.close(layer.getFrameIndex(window.name));
} catch (e) {
}
}
},
components: {
'vue-datepicker': FastVue.vueDatePicker(),
'vue-number-input': FastVue.vueNumberInput(),
}
});
@@ -868,9 +868,6 @@ const vue = new Vue({
}
},
changeInterestMode(item) {
if (item.InterestMode == 7 || item.InterestMode == 8) {
item.InterestType = 0;
}
},
changeInterestType(item) {
if (!item.interest_rest_days) {
@@ -1462,28 +1459,7 @@ const vue = new Vue({
},
// 多空组合利息腿校验
checkSwapRateList() {
var thisObj = this;
var longInterestModelCount = 0;
var shortInterestModelCount = 0;
var check = true;
if (thisObj.getSwapList != null) {
thisObj.getSwapList.forEach((val, num, arr) => {
if (val.InterestMode==7) {
longInterestModelCount++;
}
if (val.InterestMode == 8) {
shortInterestModelCount++;
}
});
}
if (longInterestModelCount > 1) {
check = false;
main.message("计息基本类型为多头存续名义本金的利息腿只能有一条");
}
if (shortInterestModelCount > 1) {
check = false;
main.message("计息基本类型为空头存续名义本金的利息腿只能有一条");
}
return check;
},
//观察日起始日期跟交易起始日期检查
@@ -1619,7 +1595,7 @@ const vue = new Vue({
//初始化利息端列表
initSwapRateList() {
var thisObj = this;
thisObj.getSwapList = thisObj.trade.swap_positions.filter(x => { if ((x.UnderlyingCode == null || x.UnderlyingCode.length == 0) && x.IsInitial && (x.InterestMode == 1 || x.InterestMode == 2 || x.InterestMode == 7 || x.InterestMode == 8 || x.InterestMode == 9)) return x; });
thisObj.getSwapList = thisObj.trade.swap_positions.filter(x => { if ((x.UnderlyingCode == null || x.UnderlyingCode.length == 0) && x.IsInitial && (x.InterestMode == 1 || x.InterestMode == 2 || x.InterestMode == 9)) return x; });
thisObj.getSwapList.forEach((val, num, arr) => {
arr[num].index = num;
arr[num].category_tag = arr[num].category_tag || '互换利率';
@@ -330,46 +330,6 @@ function unWindSwap(id) {
}
});
}
function unWindSwapLongShort(id) {
var title = "交易平仓";
var srcurl = "/swaptrade2/SwapLongShortUnwind/?enid=" + id;
main.post("/swaptrade2/CheckEodTrade?enid=" + id).done(function (res) {
if (res.success) {
main.open(title,
srcurl,
{
area: ["1300px", "780px"],
end: function () {
if (window.parent && window.parent.reloadtrade) {
window.parent.reloadtrade();
}
}
});
} else {
main.message(res.message);
}
});
}
function unWindLongShortSwap(id) {
var title = "期间互换";
var srcurl = "/swaptrade2/SwapLongShortSwap/?enid=" + id;
main.post("/swaptrade2/CheckEodTrade?enid=" + id).done(function (res) {
if (res.success) {
main.open(title,
srcurl,
{
area: ["1300px", "780px"],
end: function () {
if (window.parent && window.parent.reloadtrade) {
window.parent.reloadtrade();
}
}
});
} else {
main.message(res.message);
}
});
}
function extensionTime(id) {
var title = "展期信息";
var srcurl = "/swaptrade2/ExtensionTime/?enid=" + id;
@@ -1,197 +0,0 @@
//otcformat禁止千分位分组
window.otcformat.options.disableGrouping = true;
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',
data: {
deal: model,
interestList: [],
marginList: [],
oriClosePercent: model.ClosePercent
},
computed: {
minStartDate() {
return dealDate;
}
},
created() {
this.initDeal();
this.calcCloseAmount();
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;
});
this.marginList = model.FlowEvents.filter((item) => {
return item.InterestMode == 5 || item.InterestMode == 6;
});
},
dataFormat() {
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 = formatSwapAmount(x.Principal);
//x.Rate = otcformat.fixed6(x.Rate);
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 = formatSwapAmount(x.InterestAmount);
x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL);
});
},
changeInterestAmount(item) {//修改利息金额
let interestRatio = item.InterestDirection == 1 ? 1 : -1;
item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio+ parseFloat(item.InterestFee));
this.calcCloseAmount();
},
calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付
let thisObj = this;
thisObj.deal.SwapCloseAmount = 0;
thisObj.deal.SwapRealizedPnL = 0;
thisObj.deal.SwapMarginRebatePnl = 0;
thisObj.deal.SwapMarginAmount = 0;
this.interestList.forEach(x => {
/*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/
let interestAmount = parseFloat(x.InterestClosePnL);
thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount;
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;
});
this.marginList.forEach(x => {
let interestAmount = parseFloat(x.InterestClosePnL);
let interestRatio = x.InterestDirection == 1 ? -1 : 1;
thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount;
thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount;
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;
thisObj.deal.SwapMarginAmount = parseFloat(thisObj.deal.SwapMarginAmount) + parseFloat(x.InterestPrincipal) * interestRatio;
});
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;
let reqObj = _.cloneDeep(thisObj.deal);
let marginCloneList = _.cloneDeep(thisObj.marginList);
reqObj.FlowEvents = _.cloneDeep(thisObj.interestList);
marginCloneList.forEach((item) => {
reqObj.FlowEvents.push(item);
})
var postData = { unwindData: reqObj };
var msg = "确认提交平仓?";
var postUrl = "/swaptrade2/SwapLongShortUnwindJson";
if (g_isShowReCheckClose) {
msg = "确认提交平仓审核?";
postUrl = "/swaptrade2/ApplyUnwind";
postData.eventType = 2;//互换3,平仓2
}
main.confirm(msg,
function () {
//重新计算百分比
var thisObj2 = thisObj;
main.post(postUrl, postData).done(function (res) {
if (res.success) {
thisObj2.closetrade_cashWindow();
}
else {
try {
thisObj2.closetrade_cashWindow();
} catch (e) {
}
}
});
});
},
getSumbitText: function () {
return g_isShowReCheckClose ? "审核提交" : "保存";
},
submitApproval(status) {
var pop = '';
if (status === 'pass') {
pop = "确认通过审批?";
}
if (status === 'reject') {
pop = "确认拒绝?";
}
let thisObj = this;
var confirmFunc = function (additionalProcessing) {
var pData = { tradeId: thisObj.deal.SwapTradeId, status: status, text: "" };
if (!main.isEmpty(additionalProcessing)) {
pData.additionalProcessing = additionalProcessing;
}
var thisObj2 = thisObj;
main.post("/processtradelog/UpdateTradeProcessLog", pData).done(
function (data) {
if (data.obj && data.obj.proccessType == "AdditionalProcessing") {
if (data.obj.type == "LackOfMoney") {
var htmlContent = `<div style="padding:10px">${data.obj.message}</div>`;
var lackMoneyConfirmLayer = main.open2("提示",
htmlContent,
{
area: ["430px", "175px"],
btn: ['交易特批', '取消'],
yes: function (index, layero) {
var layerIndex = lackMoneyConfirmLayer;
main.confirm("客户资金或授信不足,强制成交会导致本机构产生风险!要继续审批通过?", function () {
layer.close(layerIndex);
confirmFunc("LackOfMoney");
});
},
cancel: function (index, layero) {
if (window.parent && window.parent.reloadtrade) {
thisObj2.closetrade_cashWindow();
}
(parent || window).layer.closeAll();
}
});
}
return;
}
(parent || window).main.message(data.msg);
try { thisObj2.closetrade_cashWindow(); }
catch (e) { }
if (parent) {
parent.layer.closeAll();
}
});
}
main.confirm(pop, confirmFunc);
},
closetrade_cashWindow: function () {
layer.closeMe('reloadData');
},
closeCurrentWindow: function () {
try {
if (window.parent && window.parent.reload) window.parent.reload();
} catch (e) {
}
try {
var layer = window.parent.layer;
layer.close(layer.getFrameIndex(window.name));
} catch (e) {
}
}
},
components: {
'vue-datepicker': FastVue.vueDatePicker(),
'vue-number-input': FastVue.vueNumberInput(),
}
});
@@ -110,7 +110,7 @@ const vue = new Vue({
this.floatPosition = positions[0];
this.initPosiNetPrice = this.floatPosition.PosiGrossPrice;
this.interestList = model.FlowEvents.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9;
});
this.marginList = model.FlowEvents.filter((item) => {
return item.InterestMode == 5 || item.InterestMode == 6;
@@ -375,7 +375,7 @@ const vue = new Vue({
' closeNotionalValue=', thisObj.deal.CloseNotionalValue, ' posiNotionalValue=', thisObj.deal.PosiNotionalValue);
main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) {
thisObj.interestList = resp.obj.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9;
});
thisObj.marginList = resp.obj.filter((item) => {
return item.InterestMode == 5 || item.InterestMode == 6;
@@ -731,9 +731,6 @@ function passorreinfo(eid, tradeType, status, isGroup, StructureType) {
else if (tradeType === "收益互换") {
weight = "95%";
url = "/swaptrade2/SwapUnwind/?enid=" + eid + "&isUseApproval=" + true;
if (StructureType == "多空组合") {
url = "/swaptrade2/SwapLongShortUnwind/?enid=" + eid + "&isUseApproval=" + true;
}
height = "800px";
}
else {
@@ -744,9 +741,6 @@ function passorreinfo(eid, tradeType, status, isGroup, StructureType) {
weight = "95%";
title = "互换审批";
url = "/swaptrade2/SwapIncome/?enid=" + eid + "&isUseApproval=" + true;
if (StructureType == "多空组合") {
url = "/swaptrade2/SwapLongShortSwap/?enid=" + eid + "&isUseApproval=" + true;
}
height = "800px";
}
else if (status === "行权待复核") {
@@ -883,9 +877,6 @@ function unWindSelect(id, tradeType, isReCheck, StructureType) {
});
} else if (tradeType == "收益互换") {
var url = "/swaptrade2/SwapUnwind/?enid=" + id + "&isUseApproval=" + true;;
if (StructureType == "多空组合") {
url = "/swaptrade2/SwapLongShortUnwind/?enid=" + id + "&isUseApproval=" + true;;
}
main.open(title, url, {
area: ["1300px", "720px"],
end: function () {
@@ -1,103 +0,0 @@
# 缺陷分析:利息部分平仓尾差(业务场景3 / 业务场景4 浮动利率)
> 数据来源:`缺陷测试-利息20260807晚.xlsx`(独立手算 oracle,非代码 re-baseline
> 分析日期:2026-08-08 关联分支:`glms/feature/1.4.2`
## 1. 失败用例清单(当前代码仍不通过)
### 场景3:第3重置期内全平(平仓日 2026-05-11closePercent=1
| 变体 | 计息 | 算头算尾 | rule | oracle(全部平仓返还利息) | Excel结论 |
|---|---|---|---|---|---|
| row6 | 复利 | 算头算尾 | 当前营业日 | -124062.54 | **不通过** |
| row7 | 复利 | 算头算尾 | 当前营业日 | 280303.16 | **不通过** |
| row8 | 复利 | 算头不算尾 | 当前营业日 | -117918.47 | 通过 |
| row9 | 复利 | 算头不算尾 | 当前营业日 | 266674.35 | 通过 |
| row10 | 复利 | 算头算尾 | 前一营业日 | -123730.45 | **不通过** |
| row11 | 复利 | 算头算尾 | 前一营业日 | 279733.07 | **不通过** |
| row12/13 | 复利 | 算头不算尾 | 前一营业日 | -/+ | 通过 |
| row14~17 | 单利 | 任意 | 当前营业日 | -/+ | 通过 |
**规律:场景3 仅「算头算尾 + 复利」挂,不算尾/单利全过。**
### 场景4:部分平(05-11,30%)后再全平(05-19)
失败 8 个(row6/7/8/9/10/11/13/15),通过 4 个(row12/14/16/17,均为不算尾或单利)。
Excel 备注(row6)原文:
> 不通过,部分平仓时,利息端平仓金额没有跟随平仓比例变化。全部平仓时,居然没有考虑已经过大支付了利息。
场景4 row6 量化:oracle 最终全平 = -124093.74,系统 = -124122.96**系统多算 29.22**
部分用例偏差更大(row8 多算 3685.96row15 多算 3961.58)。说明部分平仓后再全平的尾差链路在复利下整体脆弱。
## 2. 根因(代码实证)
### Bug A:部分平仓利息端未随平仓比例缩放
`SwapDealService.cs` 盘中平仓(复利分支,`CalcDailyCompoundInterest``daysFromPreEod==1` 早退分支):
```
InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; // 已按 closePrecent
TdInterestAmount = preEodPosition.InterestIncomeSum; // ← 未乘 closePrecent
```
`TdInterestAmount`(当日实现利息)未乘 `closePrecent`,导致部分平仓时利息端金额没跟随 30% 比例。
对应场景4 备注第一条「利息端平仓金额没有跟随平仓比例变化」。
### Bug B:全部平仓未扣减已部分平仓已付利息
`SwapEodPositionService.cs` EOD 平仓结算:
```
TdCloseInterest = flowEvents.Sum(x => x.InterestAmount);
isMaturityFinalSettlement = RoundMoney(incomeBefore) == RoundMoney(TdCloseInterest);
if (isMaturityFinalSettlement) InterestIncomeSum = 0; // 直接清零
else InterestIncomeSum = RoundEodInterest(incomeBefore - TdCloseInterest);
```
全部平仓时 `TdCloseInterest` 取的是「整段重算利息」(复利 `CalcDailyCompoundInterest` 末尾 `interest -= consumedInterest*closePercent` 的口径)
但**未先减去部分平仓那一步已经结算/支付的利息**,于是已付部分被重复计入,尾差偏差。
对应场景4 备注第二条「全部平仓时没考虑已大支付了利息」。
### Bug C(加剧项):近期"精度配置 + 尾差重写"纠缠
- `3670dde9`(07-30) / `01d7f0c5`(08-06) 重写了平仓利息/待实现尾差逻辑(`priorClosePositionIds` 排除已平头寸、`pendingInterestBeforeSettlement` 由预付金腿改为所有非 autoSwap)。
- 同期 `bff3e920`(07-29) `swappriceprecision.js``yield 6→4``price 11→9``a4906010` 净价/全价精度分开。
- 尾差 = 高精度应结 − 结算(2位)。精度配置改变 → 舍入残差落点变 → 与重写后的尾差逻辑在"部分平后再全平"长链路(场景4)上交互出错。固定利率 4-2 路径短未触发,浮动 4-2 路径长直接爆。
## 3. 为什么现有测试没护住好代码
1. **测试被 re-baseline 到代码**`01d7f0c5` 把期望常量从 `0.006383561644` 改成 `-0.010438356164`,拿新代码输出当期望值 → 测试只是复述代码行为。
2. **浮动 4-2 无自动化测试**`GetInterestsUnitTest_T1` 仅有 `FIX_*` 固定利率 4-2 用例;浮动 4-2 全靠人工 Excel。
3. **断言容差太松**:既有 `AssertInterestEqual``ConsGlobal.PriceRound-2` 容差(约 0.01),尾差差在 4~6 位小数全被放过。
4. **真 oracle 躺在 Excel 未自动化**:「善洁方法二」30%/70% 守恒检查是极佳 golden,但人肉比对,CI 不响。
## 4. 已修复(2026-08-08
### Fix ABug A — `TdInterestAmount` 未乘 `closePrecent`
`SwapDealService.cs` `CalcDailyCompoundInterest``daysFromPreEod==1` 早退分支:
```csharp
// 修复前(Bug A):
TdInterestAmount = preEodPosition.InterestIncomeSum;
// 修复后:
TdInterestAmount = preEodPosition.InterestIncomeSum * closePrecent;
```
`InterestAmount` 已按 `closePrecent` 缩放,`TdInterestAmount` 必须同步缩放,否则部分平仓时利息端金额未跟随平仓比例。
### Fix B:根因 — `resetCarryInterest` 使用 EOD 舍入快照导致精度偏差
`SwapDealService.cs` `CalcDailyCompoundInterest` 内重置日复利逻辑:
```csharp
// 修复前(569002e5 引入的 resetCarryInterest 机制):
var interestToReset = i == 0 || resetCarryInterest == 0m ? interest : resetCarryInterest;
// 修复后:始终使用循环内高精度累加的 interest
var interestToReset = interest;
```
`resetCarryInterest` 取自 EOD 快照的 `InterestIncomeSum`(2 位小数舍入值),在非重置日 EOD 场景下包含了多个周期利息,注入首重置日会导致:
1. 精度损失(舍入值 vs 循环高精度累加值)
2. 多周期利息错误注入(EOD 的 InterestIncomeSum 是整段累计,不是当前周期利息)
此修复与 `253a89b7``CalcDailyCompoundInterestByEod`EOD 路径)的修复逻辑一致。
### Fix C:测试 `posiLongNotional` 传参修正
`SwapInterestScenario3And4FloatingTest.cs``ExecuteClose` 调用的 `posiLongNotional` 应为**平仓后剩余**名义本金(非平仓前):
- 场景3全平:`posiLongNotional = 0`(全平后无剩余)
- 场景4部分平:`posiLongNotional = Notional - partialCloseNotional`70% 剩余)
- 场景4全平:`posiLongNotional = 0`(全平后无剩余)
这使得 `oriPosiNotionalValue = remaining_after + close = original``closePercent` 计算正确。
## 5. 验证手段
- **C# 测试**`UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs`
— 24 个 Excel oracle 用例,通过 EOD 结算路径(`SaveAutoEodWithCloseInterestPosition`)复现,
断言 `TdCloseInterest`(容差 0.01)。修复前 → RED(偏差 29~3961 元),修复后 → 预期 GREEN。
⚠️ 需在 Windows + VS 运行验证。
- **禁止 re-baseline**:今后任何 fix 改测试期望值常量,必须附注来源(本 Excel 手算 or 文档公式),否则评审红线。