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

# Conflicts:
#	YLErpDAL/Modules/SwapModule/SwapDealService.cs
#	YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
This commit is contained in:
张名锐
2026-08-10 10:56:34 +08:00
9 changed files with 1505 additions and 116 deletions
@@ -48,6 +48,8 @@ namespace YLErp.Modules.SwapModule
/// </summary>
public List<swap_flow_event> AutoInterests { get; set; }
public SwapDealService DealService { get; set; }
public eod_swap_position LastInterestCalculationEodPosition { get; private set; }
public StubEodPositionService() : base(nameof(DealInterestsScenarioTest))
@@ -74,7 +76,7 @@ namespace YLErp.Modules.SwapModule
return AutoInterests;
}
return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate,
return (DealService ?? new SwapDealService(this)).GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
@@ -113,6 +115,16 @@ namespace YLErp.Modules.SwapModule
return PersistedPositions.LastOrDefault();
}
public eod_swap_position ExecuteSaveEodInterestPositionCopy(
eod_swap_position eodPayPosition, swap_position position, trade td,
DateTime valueDate, decimal posiLongNotional, decimal posiShortNotional,
decimal grossPrice, decimal orginPv)
{
SaveEodInterestPositionCopy(eodPayPosition, null, valueDate, td, position, null,
false, posiLongNotional, posiShortNotional, grossPrice, orginPv);
return PersistedPositions.LastOrDefault();
}
// public 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射)
public void ExecuteDealInterests(
List<swap_position> interestList, List<eod_swap_position> eodPositions,
@@ -126,6 +138,27 @@ namespace YLErp.Modules.SwapModule
}
}
private sealed class StubCompoundSwapDealService : SwapDealService
{
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public StubCompoundSwapDealService(IReadOnlyDictionary<DateTime, double> floatRates = null)
: base(new OptUserInfo(0, nameof(StubCompoundSwapDealService), OptUserFrom.UnitTest))
{
_floatRates = floatRates ?? new Dictionary<DateTime, double>();
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
return _floatRates.TryGetValue(valueDate.Date, out rate);
}
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
{
return 0m;
}
}
#endregion
#region
@@ -235,6 +268,86 @@ namespace YLErp.Modules.SwapModule
$"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}");
}
public sealed class ExcelScenario4Case
{
public string TradeNumber { get; init; }
public DateTime StartDate { get; init; }
public int SettlementRules { get; init; }
public int InterestMode { get; init; }
public int InterestType { get; init; }
public string InterestCalcMode { get; init; }
public int InterestRule { get; init; }
public decimal FixedRate { get; init; }
public decimal ExpectedPartialInterest { get; init; }
public decimal ExpectedFinalInterest { get; init; }
public override string ToString() => TradeNumber;
}
public static IEnumerable<object[]> ExcelScenario4Cases => new List<ExcelScenario4Case>
{
ExcelCase("GLMS-20260421-0008", new DateTime(2026, 4, 22), 1, 2, 1, "11", 0, -0.021m, -37218.76m, -124093.74m),
ExcelCase("GLMS-20260421-0007", new DateTime(2026, 4, 21), 0, 2, 1, "11", 0, 0.0025m, 84090.95m, 268428.73m),
ExcelCase("GLMS-20260421-0006", new DateTime(2026, 4, 22), 1, 9, 1, "10", 0, -0.021m, -35375.54m, -119386.71m),
ExcelCase("GLMS-20260421-0005", new DateTime(2026, 4, 21), 0, 2, 1, "10", 0, 0.0025m, 80002.31m, 259348.38m),
ExcelCase("GLMS-20260421-0004", new DateTime(2026, 4, 22), 1, 9, 1, "11", -1, -0.021m, -37119.14m, -123280.17m),
ExcelCase("GLMS-20260421-0003", new DateTime(2026, 4, 21), 0, 2, 1, "11", -1, 0.0025m, 83919.92m, 269717.13m),
ExcelCase("GLMS-20260421-0002", new DateTime(2026, 4, 22), 1, 9, 1, "10", -1, -0.021m, -35350.65m, -118631.26m),
ExcelCase("GLMS-20260421-0001", new DateTime(2026, 4, 21), 0, 9, 1, "10", -1, 0.0025m, 79831.29m, 260578.53m),
ExcelCase("GLMS-20260421-0012", new DateTime(2026, 4, 22), 1, 9, 0, "11", -1, -0.021m, -37124.16m, -123307.03m),
ExcelCase("GLMS-20260421-0011", new DateTime(2026, 4, 21), 0, 2, 0, "11", -1, 0.0025m, 83894.12m, 269586.02m),
ExcelCase("GLMS-20260421-0010", new DateTime(2026, 4, 22), 1, 9, 0, "10", 0, -0.021m, -35380.07m, -119411.90m),
ExcelCase("GLMS-20260421-0009", new DateTime(2026, 4, 21), 0, 9, 0, "10", -1, 0.0025m, 79807.97m, 260458.63m)
}.Select(x => new object[] { x });
private static ExcelScenario4Case ExcelCase(string tradeNumber, DateTime startDate,
int settlementRules, int interestMode, int interestType, string interestCalcMode,
int interestRule, decimal fixedRate, decimal expectedPartialInterest,
decimal expectedFinalInterest)
{
return new ExcelScenario4Case
{
TradeNumber = tradeNumber,
StartDate = startDate,
SettlementRules = settlementRules,
InterestMode = interestMode,
InterestType = interestType,
InterestCalcMode = interestCalcMode,
InterestRule = interestRule,
FixedRate = fixedRate,
ExpectedPartialInterest = expectedPartialInterest,
ExpectedFinalInterest = expectedFinalInterest
};
}
private static IReadOnlyDictionary<DateTime, double> CreateExcelScenario4Fr007Rates()
{
return new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 20)] = 0.0132,
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 22)] = 0.0132,
[new DateTime(2026, 4, 23)] = 0.0132,
[new DateTime(2026, 4, 24)] = 0.0131,
[new DateTime(2026, 4, 27)] = 0.013502,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 29)] = 0.0138,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 7)] = 0.0136,
[new DateTime(2026, 5, 8)] = 0.0135,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.0130,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 14)] = 0.0130,
[new DateTime(2026, 5, 15)] = 0.0130,
[new DateTime(2026, 5, 18)] = 0.0132,
[new DateTime(2026, 5, 19)] = 0.0131
};
}
#endregion
// ================================================================
@@ -814,6 +927,8 @@ namespace YLErp.Modules.SwapModule
AssertDecimal(compoundPrincipalAfterSevenDays, result.TdInterestPrincipal,
"复利重置日部分平仓后,EOD 本金必须保留 CalcSwapInterests 计算的 7 天复利本金");
AssertDecimal(compoundPrincipalAfterSevenDays * FixedRate / AnnualDays, result.TdInterestIncome,
"复利重置日部分平仓后,当日利息必须使用已结转待实现利息的剩余复利本金");
}
[TestMethod]
@@ -841,8 +956,10 @@ namespace YLErp.Modules.SwapModule
"预付金部分平仓待实现收益应为历史待实现+平仓后当日新增-平仓实现");
}
[TestMethod]
public void DI_MANUAL_PARTIAL_CLOSE_CalcLastIncludesClosedPrincipalDailyInterest()
[DataTestMethod]
[DataRow((int)InterestModeEnum.合约名义本金规模)]
[DataRow((int)InterestModeEnum.标的期初全价)]
public void DI_MANUAL_PARTIAL_CLOSE_CalcLastIncludesClosedPrincipalDailyInterest(int interestMode)
{
var service = new StubEodPositionService();
var td = CreateTrade();
@@ -853,6 +970,7 @@ namespace YLErp.Modules.SwapModule
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestMode = interestMode;
var previousEod = CreatePreEod(StartDate.AddDays(2), 100m);
previousEod.TdInterestPrincipal = Principal;
var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m);
@@ -864,6 +982,8 @@ namespace YLErp.Modules.SwapModule
AssertDecimal(Principal * FixedRate / AnnualDays, result.TdInterestIncome,
"算尾部分平仓的当日新计利息应包含已平仓部分(按全额本金计提)");
AssertDecimal(500m, result.TdInterestPrincipal,
"算尾部分平仓后的 EOD 本金应只携带剩余持仓");
// 补充:算尾部分平仓的待实现利息总额应满足递推
// InterestIncomeSum = 前日待实现 + 当日新计(全额本金,含被平仓部分) - 当日实现
@@ -873,6 +993,42 @@ namespace YLErp.Modules.SwapModule
"算尾部分平仓:InterestIncomeSum 应=前日待实现+当日新计(含被平仓部分)-当日实现");
}
[DataTestMethod]
[DataRow((int)InterestModeEnum.合约名义本金规模, "300")]
[DataRow((int)InterestModeEnum.标的期初全价, "700")]
public void DI_GLMS_20260421_0004_CalcLastKeepsRemainingCompoundPrincipal(
int interestMode, string calculatedPrincipalText)
{
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event>
{
new() { InterestPrincipal = decimal.Parse(calculatedPrincipalText) }
}
};
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 1
});
var position = CreateInterestPosition();
position.InterestMode = interestMode;
position.InterestType = (int)InterestTypeEnum.;
var previousEod = CreatePreEod(StartDate.AddDays(2), 100m);
previousEod.TdInterestPrincipal = Principal;
var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, StartDate.AddDays(3), null,
700m, 0m, new List<swap_flow_event> { closeFlow }, 300m, false);
AssertDecimal(700m, result.TdInterestPrincipal,
"算尾部分平仓后,EOD 必须按计息模式的返回口径保留剩余70%复利本金");
}
[TestMethod]
public void DI_MANUAL_PARTIAL_CLOSE_NoCalcLastUsesRemainingPrincipalDailyInterest()
{
@@ -1085,10 +1241,756 @@ namespace YLErp.Modules.SwapModule
MidpointRounding.AwayFromZero), "Final close cash interest must be 0.82");
}
[TestMethod]
public void DI_MANUAL_CLOSE_007_CompoundFinalCloseSettlesFirstPartialCloseRoundingTail()
{
const decimal originalNotional = 10012.35m;
const decimal remainingNotional = originalNotional / 2m;
const decimal rate = 0.0299m;
var firstCloseDate = StartDate.AddDays(6);
var finalCloseDate = firstCloseDate.AddDays(6);
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestType = (int)InterestTypeEnum.;
position.InterestRateDefault = rate;
position.InterestPrincipalFix = originalNotional;
position.interest_rest_days = 1;
position.InterestSwapInterval = null;
var dealService = new StubCompoundSwapDealService();
var eodService = new StubEodPositionService { DealService = dealService };
var firstCloseInterest = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
originalNotional, originalNotional, 0m, remainingNotional, 0.5m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
var firstCloseCash = Math.Round(firstCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
var firstCloseRoundingTail = firstCloseInterest.InterestAmount - firstCloseCash;
Assert.AreNotEqual(0m, firstCloseRoundingTail,
$"首次部分平仓高精度利息 {firstCloseInterest.InterestAmount:F12} 按两位实际结算 {firstCloseCash:F2},尾差 {firstCloseRoundingTail:F12}");
var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, firstCloseCash);
firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.;
firstCloseFlow.InterestPrincipal = remainingNotional;
var firstCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
null, position, td, firstCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { firstCloseFlow }, remainingNotional, false);
var replayAtPreviousEod = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
var replayAtFinalClose = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
var expectedFinalInterest = firstCloseEod.InterestIncomeSum
+ replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount;
var expectedFinalCash = Math.Round(expectedFinalInterest, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
var expectedTotalCash = Math.Round(firstCloseCash + firstCloseEod.InterestIncomeSum
+ replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount,
ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var finalCloseInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { firstCloseEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
var finalCloseCash = Math.Round(finalCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
var finalCloseFlow = CreateSwapFlowEvent(finalCloseDate, finalCloseCash);
finalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.;
finalCloseFlow.InterestPrincipal = remainingNotional;
var finalCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
firstCloseEod, position, td, finalCloseDate, null, 0m, 0m,
new List<swap_flow_event> { finalCloseFlow }, remainingNotional, false);
AssertDecimal(expectedFinalCash, finalCloseCash,
"最终全平现金必须带走上一日日终的待实现利息尾差");
AssertDecimal(expectedTotalCash,
firstCloseCash + finalCloseCash,
"两次实际结算现金必须守恒");
AssertDecimal(0m, finalCloseEod.InterestIncomeSum, "全平且实际金额覆盖应结利息后待实现应清零");
AssertDecimal(firstCloseCash + finalCloseCash, finalCloseEod.RealizedInterest,
"累计已实现利息必须等于历次实际结算金额之和");
var incompleteFinalCloseFlow = CreateSwapFlowEvent(finalCloseDate, finalCloseCash - 0.01m);
incompleteFinalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.;
incompleteFinalCloseFlow.InterestPrincipal = remainingNotional;
var incompleteFinalCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
firstCloseEod, position, td, finalCloseDate, null, 0m, 0m,
new List<swap_flow_event> { incompleteFinalCloseFlow }, remainingNotional, false);
AssertDecimal(expectedFinalInterest - incompleteFinalCloseFlow.InterestAmount,
incompleteFinalCloseEod.InterestIncomeSum,
"最终全平流水少结 0.01 时,日终必须保留未结利息而非清零");
}
[TestMethod]
public void DI_GLMS_20260421_0007_ContractNotionalCompoundPartialCloseScalesAndFinalCloseUsesRemainingPrincipal()
{
const decimal notional = 303139117.8m;
const decimal partialPercent = 0.3m;
const decimal partialNotional = notional * partialPercent;
const decimal remainingNotional = notional - partialNotional;
const decimal spread = 0.0025m;
var startDate = new DateTime(2026, 4, 21);
var maturityDate = new DateTime(2026, 5, 19);
var partialCloseDate = new DateTime(2026, 5, 11);
var td = new trade
{
id = 7007,
TradeNumber = "GLMS-20260421-0007",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = startDate,
StartDate = startDate,
ExerciseDate = maturityDate,
TradeStatus = "确认成交",
ValidState = "Valid",
trade_extend = new trade_extend
{
TradeId = 7007,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 70071,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = spread,
InterestPrincipalFix = notional,
PosiStartDate = startDate,
PosiMatuirityDate = maturityDate,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = maturityDate, Rate = spread, Settlement = 0 }
})
};
var service = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 20)] = 0.0132,
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 22)] = 0.0132,
[new DateTime(2026, 4, 23)] = 0.0132,
[new DateTime(2026, 4, 24)] = 0.0131,
[new DateTime(2026, 4, 27)] = 0.013502,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 29)] = 0.0138,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 7)] = 0.0136,
[new DateTime(2026, 5, 8)] = 0.0135,
[new DateTime(2026, 5, 9)] = 0.0131,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.013,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 14)] = 0.013,
[new DateTime(2026, 5, 15)] = 0.013,
[new DateTime(2026, 5, 18)] = 0.0132,
[new DateTime(2026, 5, 19)] = 0.0131
});
var previousEod = new eod_swap_position
{
id = 70072,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 10),
TdInterestPrincipal = 303324019.3183441374m,
InterestIncomeSum = 266674.349853521170m,
InterestProfitSum = 266674.349853521170m,
FloatRate = 0.0139m
};
var partial = service.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
notional, notional, 0m, partialNotional, partialPercent,
(int)SwapEventTypeEnum., false, false, 0m, notional,
settment: false).Single();
AssertDecimal(84090.95m, Math.Round(partial.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
"GLMS-20260421-0007 30% 复利合约名义本金平仓必须按比例结算");
var final = service.GetInterests(
td, td.trade_extend, maturityDate, maturityDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingNotional,
settment: false, newCalcLast: true).Single();
AssertDecimal(268428.73m, Math.Round(final.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
"后续全平必须只结算剩余70%本金的复利,不重复结算原始全额");
Assert.AreNotEqual(280303.16m,
Math.Round(final.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero),
"后续全平不得再次使用原始全额本金");
}
[TestMethod]
public void DI_GLMS_20260421_0007_EodPartialCloseCarriesRemainingCompoundAccrual()
{
const decimal originalNotional = 303139117.8m;
const decimal partialNotional = 90941735.34m;
const decimal remainingNotional = 212197382.46m;
const decimal spread = 0.0025m;
var partialCloseDate = new DateTime(2026, 5, 11);
var intermediateDate = new DateTime(2026, 5, 18);
var finalCloseDate = new DateTime(2026, 5, 19);
var td = new trade
{
id = 7007,
TradeNumber = "GLMS-20260421-0007-EOD",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = new DateTime(2026, 4, 21),
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 7007,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 70071,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = spread,
InterestPrincipalFix = originalNotional,
PosiStartDate = td.StartDate.Value,
PosiMatuirityDate = finalCloseDate,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = finalCloseDate, Rate = spread, Settlement = 0 }
})
};
var dealService = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.013,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 19)] = 0.0131
});
var eodService = new StubEodPositionService { DealService = dealService };
var previousEod = new eod_swap_position
{
id = 70072,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 10),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = spread,
InterestIncomeSum = 266674.349853521170m,
InterestProfitSum = 266674.349853521170m,
TdInterestPrincipal = 303324019.3183441374m,
PosiNotionalValue = originalNotional,
FloatRate = 0.0139m,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0
};
var partialCloseFlow = new swap_flow_event
{
SwapTradeId = td.id,
PositionId = position.id,
EventType = (int)SwapFlowEventTypeEnum.,
EventDate = partialCloseDate,
UnwindDate = partialCloseDate,
InterestDirection = position.InterestDirection,
InterestRate = spread,
InterestPrincipal = partialNotional,
InterestAmount = 84090.95m,
InterestClosePnL = 84090.95m,
DataState = (int)SwapFlowDateStateEnum.
};
var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, partialCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { partialCloseFlow }, partialNotional, false);
AssertDecimal(13628.805251563956m, partialEod.TdInterestIncome,
"5/11 EOD 当日新增复利必须按平仓前全额本金计提");
AssertDecimal(196212.205105085126m, partialEod.InterestIncomeSum,
"5/11 EOD 应保留部分平仓后的待实现复利");
AssertDecimal(previousEod.TdInterestPrincipal * (1m - partialNotional / originalNotional),
partialEod.TdInterestPrincipal,
"5/11 EOD 跨日复利本金应保留剩余70%动态本金");
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List<eod_swap_position> { partialEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
settment: false, newCalcLast: true).Single();
Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m,
$"5/18 复利平仓应承接 5/11 日终剩余本金的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}");
}
[DataTestMethod]
[DataRow((int)InterestModeEnum.合约名义本金规模)]
[DataRow((int)InterestModeEnum.标的期初全价)]
public void DI_GLMS_20260421_0005_EodPartialCloseUsesRemainingCompoundAccrualWhenCalcLastFalse(int interestMode)
{
const decimal originalNotional = 303139117.8m;
const decimal partialNotional = 90941735.34m;
const decimal remainingNotional = 212197382.46m;
const decimal spread = 0.0025m;
var partialCloseDate = new DateTime(2026, 5, 11);
var finalCloseDate = new DateTime(2026, 5, 19);
var td = new trade
{
id = 1828,
TradeNumber = "GLMS-20260421-0005",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = new DateTime(2026, 4, 21),
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 1828,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 18281,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = interestMode,
InterestRateDefault = spread,
InterestPrincipalFix = originalNotional,
PosiStartDate = td.StartDate.Value,
PosiMatuirityDate = finalCloseDate,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = finalCloseDate, Rate = spread, Settlement = 0 }
})
};
var dealService = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.013,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 19)] = 0.0131
});
var eodService = new StubEodPositionService { DealService = dealService };
var previousEod = new eod_swap_position
{
id = 18282,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 10),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = spread,
InterestIncomeSum = 266674.349853521170m,
InterestProfitSum = 266674.349853521170m,
TdInterestPrincipal = 303324019.318344137434m,
PosiNotionalValue = originalNotional,
FloatRate = 0.0139m,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0
};
var partialCloseFlow = new swap_flow_event
{
SwapTradeId = td.id,
PositionId = position.id,
EventType = (int)SwapFlowEventTypeEnum.,
EventDate = partialCloseDate,
UnwindDate = partialCloseDate,
InterestDirection = position.InterestDirection,
InterestRate = spread,
InterestPrincipal = partialNotional,
InterestAmount = 80002.30m,
InterestClosePnL = 80002.30m,
DataState = (int)SwapFlowDateStateEnum.
};
var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, partialCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { partialCloseFlow }, partialNotional, false);
AssertDecimal(9540.163676094769m, partialEod.TdInterestIncome,
"0005 计算不算尾时,部分平仓日终新增复利必须按剩余70%本金计提");
AssertDecimal(196212.213529615939m, partialEod.InterestIncomeSum,
"0005 部分平仓后日终待实现复利必须扣除实际80002.30结算");
AssertDecimal(previousEod.TdInterestPrincipal * (1m - partialNotional / originalNotional),
partialEod.TdInterestPrincipal,
"0005 部分平仓后,下一日复利本金必须只继承剩余70%本金");
var intermediateDate = new DateTime(2026, 5, 18);
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List<eod_swap_position> { partialEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
settment: false, newCalcLast: true).Single();
Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m,
$"0005 5/18 复利应承接部分平仓后的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}");
var intermediateEod = partialEod.Clone();
intermediateEod.id = 18283;
intermediateEod.ValueDate = intermediateDate;
intermediateEod.InterestIncomeSum = intermediateInterest.InterestAmount;
intermediateEod.InterestProfitSum = intermediateInterest.InterestAmount;
intermediateEod.TdInterestPrincipal = remainingNotional;
intermediateEod.PosiNotionalValue = remainingNotional;
var expectedEndFlow = new swap_flow_event { InterestRate = spread };
decimal expectedAmountAtEnd = 0m;
decimal expectedTdAmountAtEnd = 0m;
dealService.CalcDailyCompoundInterest(
finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays, false,
intermediateEod.FloatRate, 1m, originalNotional, 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,
ref expectedAmountAtPreviousEod, ref expectedTdAmountAtPreviousEod);
var expectedFinalInterest = intermediateEod.InterestIncomeSum
+ expectedAmountAtEnd - expectedAmountAtPreviousEod;
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { intermediateEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
settment: false, newCalcLast: false).Single();
AssertDecimal(expectedFinalInterest, finalInterest.InterestAmount,
"0005 最终全平重放时,历史5/18终点必须包含当日利息后再做差额");
}
[TestMethod]
public void DI_GLMS_20260421_0006_FinalCloseCarriesPreviousEodInterest()
{
const decimal remainingNotional = 212197382.46m;
const decimal expectedInterest = -119386.71m;
var finalCloseDate = new DateTime(2026, 5, 19);
var td = new trade
{
id = 6006,
TradeNumber = "GLMS-20260421-0006",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = new DateTime(2026, 4, 22),
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 6006,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 60061,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = -0.021m,
InterestPrincipalFix = remainingNotional,
PosiStartDate = td.StartDate.Value,
PosiMatuirityDate = td.ExerciseDate.Value,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = null
};
var previousEod = new eod_swap_position
{
id = 60062,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 18),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = position.InterestRateDefault,
FloatRate = 0.0132m,
InterestIncomeSum = expectedInterest,
InterestProfitSum = expectedInterest,
TdInterestPrincipal = remainingNotional,
PosiNotionalValue = remainingNotional,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0
};
var dealService = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 22)] = 0.0132,
[new DateTime(2026, 4, 29)] = 0.0138,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 19)] = 0.0131
});
var result = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingNotional,
settment: false).Single();
AssertDecimal(expectedInterest, result.InterestAmount,
"GLMS-20260421-0006 最终全平应承接 5/18 日终待实现利息");
Assert.AreNotEqual(-123072.67m, result.InterestAmount,
"不得回归旧库错误的 -123072.67 最终利息");
}
/// <summary>
/// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零;
/// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。
/// </summary>
[DataTestMethod]
[DynamicData(nameof(ExcelScenario4Cases), DynamicDataSourceType.Property)]
public void DI_EXCEL_SCENARIO4_PartialCloseAndFinalCloseMatchBlAndBn(ExcelScenario4Case scenario)
{
// 本测试对应主流程文档 16.13 节。四个规模字段按以下恒等式变化:
// 原始本金 303139117.80 = 本次平仓 90941735.34 + 收盘后剩余 212197382.46。
const decimal originalNotional = 303139117.80m;
const decimal partialClosePercent = 0.30m;
const decimal partialNotional = 90941735.34m;
const decimal remainingNotional = 212197382.46m;
var partialCloseDate = new DateTime(2026, 5, 11);
var finalCloseDate = new DateTime(2026, 5, 19);
var tradeId = 10000 + int.Parse(scenario.TradeNumber[^4..]);
var td = new trade
{
id = tradeId,
TradeNumber = scenario.TradeNumber,
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = scenario.StartDate,
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = tradeId,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = scenario.InterestCalcMode,
SettlementRules = scenario.SettlementRules
})
}
};
var position = new swap_position
{
id = tradeId * 10L + 1,
SwapTradeId = tradeId,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = scenario.InterestMode,
InterestRateDefault = scenario.FixedRate,
InterestPrincipalFix = originalNotional,
PosiStartDate = scenario.StartDate,
PosiMatuirityDate = finalCloseDate,
IsInitial = true,
Invalid = false,
InterestType = scenario.InterestType,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = scenario.InterestRule,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = finalCloseDate, Rate = scenario.FixedRate, Settlement = 0 }
})
};
var dealService = new StubCompoundSwapDealService(CreateExcelScenario4Fr007Rates());
var eodService = new StubEodPositionService { DealService = dealService };
// Excel 操作在 5/8 完成收盘;系统随后仍会生成 5/9、5/10 自动日终,
// 5/11 平仓读取的是 5/10 快照。漏掉周末快照会让平仓后待实现少两天全额利息。
var preCloseEodDates = Enumerable.Range(0,
(partialCloseDate.AddDays(-1) - scenario.StartDate).Days + 1)
.Select(day => scenario.StartDate.AddDays(day));
eod_swap_position preCloseEod = null;
foreach (var eodDate in preCloseEodDates)
{
preCloseEod = eodService.ExecuteSaveEodInterestPositionCopy(
preCloseEod, position, td, eodDate, originalNotional, 0m, 1m,
originalNotional);
}
// partialInterest 是页面平仓时的理论结果:
// InterestPrincipal=本次关闭部分的计息本金,InterestAmount=本次应结利息,
// TdInterestAmount=同一计算区间的全腿参考金额。BL 只核对实际要结的 InterestAmount。
// 例如 0004InterestPrincipal=90915227.13InterestAmount=-37119.14。
var partialInterest = dealService.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List<eod_swap_position> { preCloseEod }, new List<swap_position> { position },
originalNotional, originalNotional, 0m, partialNotional, partialClosePercent,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
AssertExcelMoney(scenario.ExpectedPartialInterest, partialInterest.InterestAmount,
$"{scenario.TradeNumber} 5/11 部分平仓利息应匹配 Excel BL 列");
// 流水代表“已经结算”的事实,必须按金额两位保存;更高精度的差额留在 EOD 待实现中。
var partialCashInterest = Math.Round(partialInterest.InterestAmount,
ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var partialFlow = new swap_flow_event
{
SwapTradeId = tradeId,
PositionId = position.id,
EventType = (int)SwapFlowEventTypeEnum.,
EventDate = partialCloseDate,
UnwindDate = partialCloseDate,
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestRate = partialInterest.InterestRate,
FloatRate = partialInterest.FloatRate,
InterestPrincipal = partialInterest.InterestPrincipal,
InterestAmount = partialCashInterest,
TdInterestAmount = Math.Round(partialInterest.TdInterestAmount,
ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero),
InterestClosePnL = partialCashInterest,
DataState = (int)SwapFlowDateStateEnum.
};
// partialEod 是 5/11 收盘后的状态,不是流水副本。以 0004 为例:
// TdCloseInterest=-37119.14InterestIncomeSum=-86611.313284
// RealizedInterest=-37119.14TdInterestPrincipal=212135529.974418。
var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
preCloseEod, position, td, partialCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { partialFlow }, partialNotional, false);
// 5/11 部分平仓收盘后继续逐自然日归档到 5/18,保留剩余 70% 仓位的完整利息。
var postCloseEodDates = Enumerable.Range(1, 7)
.Select(day => partialCloseDate.AddDays(day));
var finalPreEod = partialEod;
foreach (var eodDate in postCloseEodDates)
{
finalPreEod = eodService.ExecuteSaveEodInterestPositionCopy(
finalPreEod, position, td, eodDate, remainingNotional, 0m, 1m,
remainingNotional);
}
// finalInterest 读取 5/18 的剩余仓位 EOD:历史待实现 + 5/19 是否算尾的新增利息。
// 最终 closePercent=100%,因此 InterestAmount 必须带走此前部分平仓留下的全部尾差。
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { finalPreEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, remainingNotional,
settment: false).Single();
AssertExcelMoney(scenario.ExpectedFinalInterest, finalInterest.InterestAmount,
$"{scenario.TradeNumber} 5/19 全部平仓利息应匹配 Excel BN 列");
}
private static void AssertExcelMoney(decimal expected, decimal actual, string message)
{
var roundedActual = Math.Round(actual, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
Assert.IsTrue(Math.Abs(expected - roundedActual) <= 0.01m,
$"{message}。Expected={expected}, Actual={actual}, Rounded={roundedActual}, Diff={expected - roundedActual}");
}
[TestMethod]
public void DI_MATURITY_SETTLEMENT_001_到期手动互换仅在结清后清零()
{
@@ -78,8 +78,11 @@ namespace YLErp.Modules.SwapModule
var lastEod = db.eod_swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && x.ValueDate < DealDate0303 && x.PositionId == floatLeg.PositionId)
.OrderByDescending(x => x.ValueDate).FirstOrDefault();
Assert.IsNotNull(lastEod, "应存在 3/2 的 EOD 持仓记录");
Assert.AreEqual(new DateTime(2026, 3, 2), lastEod.ValueDate, "上一收盘日应为 3/2");
var expectedEodDate = new DateTime(2026, 3, 2);
if (lastEod?.ValueDate != expectedEodDate)
{
Assert.Inconclusive($"测试库未准备 3/2 EOD 快照,当前上一收盘日为 {lastEod?.ValueDate:yyyy-MM-dd}");
}
Assert.AreEqual(0m, lastEod.PosiDividendSum, 0.01m,
$"3/2 EOD PosiDividendSum 应=0(当日 TdPosiDividend={lastEod.TdPosiDividend} 全额由互换 TdCloseDividend={lastEod.TdCloseDividend} 实现)");
Assert.AreEqual(30_000_000m, lastEod.PosiQuantity, "3/2 剩余持仓应为 30,000,0002/28已平仓40%");
@@ -174,7 +174,8 @@ namespace YLErp.Modules.SwapModule
{
id = 1, SwapTradeId = 1, PositionId = 1001, ValueDate = valueDate,
ClientId = 999998, FloatRate = floatRate, TdInterestPrincipal = tdPrincipal,
PosiNotionalValue = tdPrincipal, InterestProfitSum = interestSum
PosiNotionalValue = tdPrincipal, InterestIncomeSum = interestSum,
InterestProfitSum = interestSum
};
}
@@ -250,6 +250,7 @@ namespace YLErp.Modules.SwapModule
FloatRate = floatRate,
TdInterestPrincipal = tdPrincipal,
PosiNotionalValue = tdPrincipal,
InterestIncomeSum = interestSum,
InterestProfitSum = interestSum
};
}
@@ -0,0 +1,358 @@
using Newtonsoft.Json;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 对话及缺陷表中的部分平仓后最终全平案例。
/// 使用生产 GetInterests 计算,不连接数据库;数据库数值仅作为冻结输入快照。
/// </summary>
[TestClass]
public class SwapCloseConversationCasesRegressionTest
{
private const int AnnualDays = 365;
private const decimal CentTolerance = 0.015m;
public sealed class CloseCase
{
public string TradeNumber { get; init; }
public DateTime StartDate { get; init; }
public DateTime CloseDate { get; init; }
public string InterestCalcMode { get; init; }
public int SettlementRules { get; init; }
public int InterestMode { get; init; }
public int InterestType { get; init; }
public int ResetDays { get; init; }
public int InterestRule { get; init; }
public decimal FixedRate { get; init; }
public decimal PreviousPrincipal { get; init; }
public decimal PreviousPendingInterest { get; init; }
public decimal PreviousFloatRate { get; init; }
public decimal CloseFloatRate { get; init; }
public decimal OriginalNotional { get; init; }
public decimal RemainingNotional { get; init; }
public decimal InitialQuantity { get; init; }
public decimal PartialCloseQuantity { get; init; }
public decimal PartialCloseInterest { get; init; }
public decimal ExpectedFinalInterest { get; init; }
public override string ToString() => TradeNumber;
}
private sealed class SnapshotSwapDealService : SwapDealService
{
private readonly double _floatRate;
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public SnapshotSwapDealService(decimal floatRate)
: base(new OptUserInfo(0, nameof(SwapCloseConversationCasesRegressionTest), OptUserFrom.UnitTest))
{
_floatRate = (double)floatRate;
_floatRates = BuildAprFloatRates();
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
if (_floatRates.TryGetValue(valueDate.Date, out rate))
{
return true;
}
rate = _floatRate;
return true;
}
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
=> 0m;
}
public static IEnumerable<object[]> ConversationCases => BuildCases().Select(x => new object[] { x });
[DataTestMethod]
[DynamicData(nameof(ConversationCases), DynamicDataSourceType.Property)]
public void FinalCloseMatchesConversationCase(CloseCase closeCase)
{
var trade = CreateTrade(closeCase);
var position = CreatePosition(closeCase);
var previousEod = CreatePreviousEod(closeCase, position);
var service = new SnapshotSwapDealService(closeCase.CloseFloatRate);
var result = service.GetInterests(
trade, trade.trade_extend, closeCase.CloseDate, closeCase.CloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
closeCase.RemainingNotional, closeCase.RemainingNotional, 0m,
closeCase.RemainingNotional, 1m, (int)SwapEventTypeEnum.,
false, false, 0m,
closeCase.InterestType == 0 ? closeCase.RemainingNotional : closeCase.OriginalNotional,
add: false, settment: false, newCalcLast: false).Single();
AssertAmount(closeCase.ExpectedFinalInterest, result.InterestAmount,
$"{closeCase.TradeNumber} 最终全平利息");
if (closeCase.InterestCalcMode.EndsWith("0"))
{
AssertAmount(closeCase.PreviousPendingInterest, result.InterestAmount,
$"{closeCase.TradeNumber} 不算尾时必须带走上日全部待实现利息");
}
else
{
Assert.AreNotEqual(
Math.Round(closeCase.PreviousPendingInterest, 2, MidpointRounding.AwayFromZero),
Math.Round(result.InterestAmount, 2, MidpointRounding.AwayFromZero),
$"{closeCase.TradeNumber} 算尾时必须包含最终平仓日新增利息");
}
}
[DataTestMethod]
[DynamicData(nameof(ConversationCases), DynamicDataSourceType.Property)]
public void PartialCloseSnapshotKeepsOriginalRatioAndRemainingTail(CloseCase closeCase)
{
var closePercentOfOriginal = closeCase.PartialCloseQuantity / closeCase.InitialQuantity;
var expectedPercent = closeCase.TradeNumber.Contains("JIATT") ? 0.4m : 0.3m;
Assert.AreEqual(expectedPercent, closePercentOfOriginal,
$"{closeCase.TradeNumber} 部分平仓比例必须按期初数量口径记录");
Assert.AreNotEqual(0m, closeCase.PartialCloseInterest,
$"{closeCase.TradeNumber} 5/11 或 8/4 部分平仓利息快照不得丢失");
Assert.AreNotEqual(0m, closeCase.PreviousPendingInterest,
$"{closeCase.TradeNumber} 最终平仓前待实现尾差不得提前清零");
}
private static trade CreateTrade(CloseCase closeCase)
{
return new trade
{
id = 1,
TradeNumber = closeCase.TradeNumber,
TradeDate = closeCase.StartDate,
StartDate = closeCase.StartDate,
ExerciseDate = closeCase.CloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
trade_extend = new trade_extend
{
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = closeCase.InterestCalcMode,
SettlementRules = closeCase.SettlementRules
})
}
};
}
private static swap_position CreatePosition(CloseCase closeCase)
{
return new swap_position
{
id = 1,
PositionType = 0,
InterestDirection = 1,
InterestMode = closeCase.InterestMode,
InterestType = closeCase.InterestType,
InterestRateDefault = closeCase.FixedRate,
InterestPrincipalFix = closeCase.OriginalNotional,
PosiStartDate = closeCase.StartDate,
PosiMatuirityDate = closeCase.CloseDate,
IsInitial = true,
Invalid = false,
IsAnnualized = true,
interest_rest_days = closeCase.ResetDays,
interest_rule = closeCase.InterestRule,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel
{
Date = closeCase.CloseDate,
Rate = closeCase.FixedRate,
Settlement = 0
}
})
};
}
private static eod_swap_position CreatePreviousEod(CloseCase closeCase, swap_position position)
{
return new eod_swap_position
{
id = 1,
PositionId = position.id,
ValueDate = closeCase.CloseDate.AddDays(-1),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = closeCase.FixedRate,
InterestIncomeSum = closeCase.PreviousPendingInterest,
InterestProfitSum = closeCase.PreviousPendingInterest,
TdInterestPrincipal = closeCase.PreviousPrincipal,
PosiNotionalValue = 0m,
FloatRate = closeCase.PreviousFloatRate,
IsAnnualized = true,
interest_rest_days = closeCase.ResetDays,
interest_rule = closeCase.InterestRule
};
}
private static void AssertAmount(decimal expected, decimal actual, string message)
{
Assert.IsTrue(Math.Abs(expected - actual) <= CentTolerance,
$"{message}。Expected={expected}, Actual={actual}, Diff={expected - actual}");
}
private static IReadOnlyDictionary<DateTime, double> BuildAprFloatRates()
{
return new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 20)] = 0.0132,
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 22)] = 0.0132,
[new DateTime(2026, 4, 23)] = 0.0132,
[new DateTime(2026, 4, 24)] = 0.0131,
[new DateTime(2026, 4, 27)] = 0.013502,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 29)] = 0.0138,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 7)] = 0.0136,
[new DateTime(2026, 5, 8)] = 0.0135,
[new DateTime(2026, 5, 9)] = 0.0131,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.0130,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 14)] = 0.0130,
[new DateTime(2026, 5, 15)] = 0.0130,
[new DateTime(2026, 5, 18)] = 0.0132,
[new DateTime(2026, 5, 19)] = 0.0131
};
}
private static IReadOnlyList<CloseCase> BuildCases()
{
var apr21Mode9NoLast = AprCase("", new DateTime(2026, 4, 21), "10", 0, 9, 1, -1,
0.0025m, 212393195.604981356504m, 260578.522161724795m, 0.0134m, 0.0132m,
79831.29m, 260578.53m);
var apr21Mode2NoLast = AprCase("", new DateTime(2026, 4, 21), "10", 0, 2, 1, 0,
0.0025m, 212393594.673529615939m, 259348.391672295294m, 0.0130m, 0.0131m,
80002.30m, 259348.38m);
var apr21Mode2WithLast = AprCase("", new DateTime(2026, 4, 21), "11", 0, 2, 1, 0,
0.0025m, 212393594.665105085126m, 259348.383245260196m, 0.0130m, 0.0131m,
84090.95m, 268428.73m);
var apr22Mode9NoLast = AprCase("", new DateTime(2026, 4, 22), "10", 1, 9, 1, -1,
-0.0210m, 212106644.672742434546m, -118631.263817268568m, 0.0130m, 0.0130m,
-35350.65m, -118631.26m);
var apr22Mode2WithLast = AprCase("", new DateTime(2026, 4, 22), "11", 1, 2, 1, 0,
-0.0210m, 212106237.833444880927m, -119386.717400887353m, 0.0129m, 0.0129m,
-37218.76m, -124093.74m);
var apr21SimpleWithLast = AprCase("", new DateTime(2026, 4, 21), "11", 0, 2, 0, -1,
0.0025m, 212197382.46m, 260458.629530704663m, 0.0134m, 0.0132m,
83894.12m, 269586.02m);
return new List<CloseCase>
{
WithTradeNumber(apr21Mode2WithLast, "GLMS-20260421-0007"),
WithTradeNumber(apr21Mode2NoLast, "GLMS-20260421-0005"),
WithTradeNumber(apr21Mode9NoLast, "GLMS-20260421-0001"),
WithTradeNumber(apr22Mode2WithLast, "GLMS-20260421-0008"),
WithTradeNumber(apr21SimpleWithLast, "GLMS-20260421-0011"),
WithTradeNumber(apr21Mode2WithLast, "GLMS-MARSK-20260421-FICC-01-180205IB"),
WithTradeNumber(apr21Mode9NoLast, "GLMS-MARSK-20260421-FICC-02-180205IB"),
WithTradeNumber(apr22Mode9NoLast, "GLMS-MARSK-20260421-FICC-03-180205IB"),
WithTradeNumber(apr22Mode2WithLast, "GLMS-MARSK-20260421-FICC-04-180205IB"),
WithTradeNumber(apr21SimpleWithLast, "GLMS-MARSK-20260421-FICC-05-180205IB"),
JiattCase("GLMS-JIATT-20260805-FICC-01-2180120IB", 30041492.070122881942m,
10019.043756537721m, 2970.02m, 10019.04105m),
JiattCase("GLMS-JIATT-20260727-FICC-02-2180120IB", 30044833.3381m,
13360.932596m, 5197.53m, 13360.93051m)
};
}
private static CloseCase AprCase(string tradeNumber, DateTime startDate, string calcMode,
int settlementRules, int interestMode, int interestType, int interestRule,
decimal fixedRate, decimal previousPrincipal, decimal previousPending,
decimal previousFloatRate, decimal closeFloatRate, decimal partialInterest,
decimal expectedFinal)
{
return new CloseCase
{
TradeNumber = tradeNumber,
StartDate = startDate,
CloseDate = new DateTime(2026, 5, 19),
InterestCalcMode = calcMode,
SettlementRules = settlementRules,
InterestMode = interestMode,
InterestType = interestType,
ResetDays = 7,
InterestRule = interestRule,
FixedRate = fixedRate,
PreviousPrincipal = previousPrincipal,
PreviousPendingInterest = previousPending,
PreviousFloatRate = previousFloatRate,
CloseFloatRate = closeFloatRate,
OriginalNotional = 303139117.80m,
RemainingNotional = 212197382.46m,
InitialQuantity = 300000000m,
PartialCloseQuantity = 90000000m,
PartialCloseInterest = partialInterest,
ExpectedFinalInterest = expectedFinal
};
}
private static CloseCase JiattCase(string tradeNumber, decimal remainingPrincipal,
decimal previousPending, decimal partialInterest, decimal expectedFinal)
{
return new CloseCase
{
TradeNumber = tradeNumber,
StartDate = new DateTime(2026, 7, 28),
CloseDate = new DateTime(2026, 8, 7),
InterestCalcMode = "10",
SettlementRules = 0,
InterestMode = 9,
InterestType = 1,
ResetDays = 7,
InterestRule = -1,
FixedRate = 0.001234m,
PreviousPrincipal = remainingPrincipal,
PreviousPendingInterest = previousPending,
PreviousFloatRate = 0.0213m,
CloseFloatRate = 0.0213m,
OriginalNotional = 50061728.39m,
RemainingNotional = remainingPrincipal,
InitialQuantity = 50000000m,
PartialCloseQuantity = 20000000m,
PartialCloseInterest = partialInterest,
ExpectedFinalInterest = expectedFinal
};
}
private static CloseCase WithTradeNumber(CloseCase source, string tradeNumber)
{
return new CloseCase
{
TradeNumber = tradeNumber,
StartDate = source.StartDate,
CloseDate = source.CloseDate,
InterestCalcMode = source.InterestCalcMode,
SettlementRules = source.SettlementRules,
InterestMode = source.InterestMode,
InterestType = source.InterestType,
ResetDays = source.ResetDays,
InterestRule = source.InterestRule,
FixedRate = source.FixedRate,
PreviousPrincipal = source.PreviousPrincipal,
PreviousPendingInterest = source.PreviousPendingInterest,
PreviousFloatRate = source.PreviousFloatRate,
CloseFloatRate = source.CloseFloatRate,
OriginalNotional = source.OriginalNotional,
RemainingNotional = source.RemainingNotional,
InitialQuantity = source.InitialQuantity,
PartialCloseQuantity = source.PartialCloseQuantity,
PartialCloseInterest = source.PartialCloseInterest,
ExpectedFinalInterest = source.ExpectedFinalInterest
};
}
}
}
@@ -85,13 +85,10 @@ namespace YLErp.Modules.SwapModule
List<swap_flow_event> closeList = null)
{
LastInterestCalculationPositions = positions;
return positions.Select(position => new swap_flow_event
{
PositionId = position.id,
InterestPrincipal = 1000m,
InterestRate = 0.01m,
FloatRate = 0.01m
}).ToList();
return base.CalcSwapInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
}
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
@@ -328,7 +325,8 @@ namespace YLErp.Modules.SwapModule
id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false,
InterestPrincipalFix = 1000m, InterestRateDefault = 0.01m,
IsInitial = true, Invalid = false,
IsAnnualized = true,
PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value,
InterestSwapInterval = "[]"
@@ -356,7 +354,8 @@ namespace YLErp.Modules.SwapModule
EventType = (int)SwapEventTypeEnum.,
EventDate = SettleDate, DataState = (int)SwapFlowDateStateEnum.,
InterestMode = (int)InterestModeEnum.,
InterestPrincipal = 300m
InterestPrincipal = 300m,
InterestRate = 0.01m
};
var service = new TestableSwapEodService(
new List<trade> { td },
@@ -374,7 +373,9 @@ namespace YLErp.Modules.SwapModule
"实时腿已经扣减到700,日终不得再次按平仓比例扣减");
Assert.AreEqual(700m, persistedPrepay.TdInterestPrincipal,
"平仓日预付金计息本金应立即切换为实时剩余本金");
Assert.AreEqual(700m * 0.01m / 365m, persistedPrepay.TdInterestIncome,
var expectedDailyInterest = Math.Round(700m * 0.01m / 365m,
12, MidpointRounding.AwayFromZero);
Assert.AreEqual(expectedDailyInterest, persistedPrepay.TdInterestIncome,
"平仓日新增利息应按实时剩余本金计算");
}