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,
"平仓日新增利息应按实时剩余本金计算");
}
+98 -62
View File
@@ -636,11 +636,14 @@ namespace YLErp.Modules.SwapModule
List<eod_swap_position> lastEodPositions = new SwapEodPositionService(this).GetPreEodPositions(tradeId, _preSetteDate);//上一交易数据
var posiLongNotionalValue = longPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金
var posiShortNotionalValue = shortPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金
var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue);
var posiNotionalValue = stockEqvNotional * closePercent;//剩余名义本金
var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional);
var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); // 当前平仓前的实时剩余本金
var posiNotionalValue = stockEqvNotional * closePercent;// 本次平仓名义本金
var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); // 上一日终的浮动端本金
var grossPrice = realPostitions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice;
var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId && x.UnwindDate == unwindDate && eventTypes.Contains(x.EventType) && x.DataState == (int)SwapFlowDateStateEnum.).ToList();
var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId
&& x.UnwindDate == unwindDate
&& eventTypes.Contains(x.EventType)
&& x.DataState == (int)SwapFlowDateStateEnum.).ToList();
bool tdClose = closeList.Count > 0;
interests = GetInterests(td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, stockEqvNotional, posiLongNotionalValue, posiShortNotionalValue, posiNotionalValue, closePercent, eventType, tdClose, false, grossPrice ?? 0, orginPv, true, false,false, closeList);
return interests;
@@ -826,10 +829,17 @@ namespace YLErp.Modules.SwapModule
// 计算计息区间
int interestPeriod = position.interest_rest_days ?? 1;
// true 跳过 不计利息; false 正常利息
bool swap = InitInterestDate(unwindDate, preDealDate, td, tdClose, out DateTime startDate, out DateTime endDate);
// 计算名义本金
var (closePrincipal, posiPrincipal, newClosePercent) = CalcNotionalByMode(position, closePrecent, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue);
if ((InterestModeEnum)position.InterestMode == InterestModeEnum.
|| (InterestModeEnum)position.InterestMode == InterestModeEnum.
&& posiNotionalValue == 0m)
{
closePrincipal = closePosiNotionalValue;
}
if ((InterestModeEnum)position.InterestMode == InterestModeEnum. || (InterestModeEnum)position.InterestMode == InterestModeEnum.)
{
positionClone.InterestDirection = position.InterestDirection == (int)SwapDirectionEnum. ? (int)SwapDirectionEnum. : (int)SwapDirectionEnum.;
@@ -892,25 +902,15 @@ namespace YLErp.Modules.SwapModule
/// </summary>
private (decimal close, decimal posi, decimal closePct) CalcNotionalByMode(swap_position position, decimal closePercent, decimal posiNotional, decimal posiLong, decimal posiShort)
{
decimal closePrincipal = posiNotional;
decimal posiPrincipal = posiNotional;
decimal newClosePercent = closePercent;
decimal closePrincipal = posiNotional; // 平仓部分的名义本金
decimal posiPrincipal = posiNotional; // 持仓部分的名义本金
decimal newClosePercent = closePercent; // 调整后的平仓比例
switch ((InterestModeEnum)position.InterestMode)
{
case InterestModeEnum.:
// 【缺陷修复】界面「全部平仓→部分平仓」改比例,下方利息腿数据完全不变。
// 原实现:closePrincipal = posiPrincipal = Fix; newClosePercent = 1m;
// 固定值腿的"计息本金"确实固定(Fix),但"本次平仓结算/返还多少利息"必须按平仓比例缩放。
// 旧代码把 closePrincipal 与 newClosePercent 两个缩放入口同时抹平,
// 导致 GetUnwindInterests 盘中预览无论传 30%/50%/70%/100%,返回利息完全相同。
// 修复口径:与 初始/追加预付金 腿(下方 case)以及 标的期初全价(mode 9) 完全一致——
// closePrincipal 承担"本期新增利息"的缩放,newClosePercent 承担"历史累计/已消耗利息"的缩放,
// 二者作用在不同项上,不会双重缩放(mode 9 已由回归测试证明线性)。
// 兼容性:EOD 结算路径 SwapEodPositionService:1350 传 closePrecent = 1 字面量,
// 此处 Fix × 1 = Fix,日终行为与修复前完全一致。
closePrincipal = position.InterestPrincipalFix * closePercent;
posiPrincipal = position.InterestPrincipalFix;
closePrincipal = posiPrincipal = position.InterestPrincipalFix;
newClosePercent = 1m;
break;
case InterestModeEnum.:
closePrincipal = posiLong * closePercent;
@@ -920,6 +920,9 @@ namespace YLErp.Modules.SwapModule
closePrincipal = posiShort * closePercent;
posiPrincipal = posiShort;
break;
case InterestModeEnum.:
closePrincipal = posiNotional * closePercent;
break;
case InterestModeEnum.:
closePrincipal = posiNotional * closePercent;
break;
@@ -944,7 +947,6 @@ namespace YLErp.Modules.SwapModule
{
if (posiNotionalValue <= 0) return originalClosePercent;
var remaining = originalClosePercent * notionalValue / posiNotionalValue;
SwapCalcTrace.Critical($" [平仓比例语义翻转 A→B] original(占期初)={originalClosePercent} notional={notionalValue} posiNotional={posiNotionalValue} → remaining(占剩余)={remaining}");
return remaining > 1 ? 1 : remaining;
}
@@ -1124,7 +1126,6 @@ namespace YLErp.Modules.SwapModule
{
if (preEod.id == 0)
{
SwapCalcTrace.Critical($" [preEod.id==0 播种] 无上一日终归档→以交易起始日初始化: TdInterestPrincipal={posiPrincipal:F4} ValueDate={td.StartDate.Value:yyyy-MM-dd}{(calcFirst ? " (calcFirst-1)" : "")}");
preEod.FloatRate = floatRate;
preEod.TdInterestPrincipal = posiPrincipal;
preEod.PosiNotionalValue = posiPrincipal;
@@ -1193,22 +1194,27 @@ namespace YLErp.Modules.SwapModule
// 根因修复:预付金(保证金)腿的计息基数维度应为"保证金本金"自身,而非整笔交易的名义本金(orginPv)。
// 否则公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv
// 会把交易名义本金(千万~亿级)当减项扣掉,使"应返还本金"(InterestPrincipal)与计息基数变成巨负值。
// 此处将预付金腿的 orginPv 对齐为其自身保证金(InterestPrincipalFix)
// 与日终路径(SwapEodPositionService 对预付金腿 orginPv=InterestPrincipalFix)保持一致
// 此处将预付金腿的 orginPv 对齐为上一日保证金本金;无历史归档时才取当前本金。
// 差分公式必须使用同一时点口径:上一日本金 + 当前本金 - 上一本金 = 当前本金
// 若已有部分平仓后仍取当前本金,会把上一日本金原样保留,导致当日继续按平仓前本金计息。
// 仅作用于初始预付金(5)/追加预付金(6);其它计息模式(含债券本金腿 标的期初全价=9)仍用交易名义本金,不受影响。
if (position.InterestMode == (int)InterestModeEnum.
|| position.InterestMode == (int)InterestModeEnum.)
{
SwapCalcTrace.Critical($" [orginPv重映射] InterestMode={position.InterestMode} 预付金腿: orginPv {orginPv:F4} → InterestPrincipalFix({position.InterestPrincipalFix:F4})");
orginPv = position.InterestPrincipalFix;
var previousPrincipal = preEodPosition.InterestPrincipalFix != 0m
? preEodPosition.InterestPrincipalFix
: preEodPosition.TdInterestPrincipal;
orginPv = preEodPosition.id != 0 && previousPrincipal != 0m
? previousPrincipal
: position.InterestPrincipalFix;
}
if (swap)
{
interest.InterestAmount = 0; // 利息金额
interest.TdInterestAmount = 0; // 当日新增利息
interest.InterestAmount = 0;
interest.TdInterestAmount = 0;
interest.InterestAmount = 0;
interest.InterestClosePnL = 0;
interest.InterestClosePnL = 0; // 利息端平仓盈亏
}
else
{
@@ -1216,37 +1222,81 @@ namespace YLErp.Modules.SwapModule
decimal TdInterestAmount = 0;
var interestRatio = position.InterestDirection == 1 ? 1m : -1m;
var floateRate = preEodPosition.FloatRate;
SwapCalcTrace.Critical($"InitSwapDealInterest posId={position.id} mode={position.InterestMode} type={(position.InterestType == (int)InterestTypeEnum.复利 ? "" : "")} swap={swap}");
SwapCalcTrace.Critical($" preEod.id={preEodPosition.id} ValueDate={preEodPosition.ValueDate:yyyy-MM-dd} FloatRate={preEodPosition.FloatRate}");
SwapCalcTrace.Critical($" endDate={endDate:yyyy-MM-dd} closePercent={closePrecent} posiNotional={posiNotionalValue:F4} closeNotional={closePosiNotionalValue:F4} orginPv={orginPv:F4} calcFirst={calcFirst} calcLast={calcLast}");
if (position.InterestType == (int)InterestTypeEnum.)
{
var daysFromStart = (endDate - position.PosiStartDate).Days;
var daysFromPreEod = preEodPosition.id != 0
? (endDate - preEodPosition.ValueDate).Days
: 0;
SwapCalcTrace.Critical($" 复利: daysFromStart={daysFromStart} daysFromPreEod={daysFromPreEod} interest_rest_days={position.interest_rest_days ?? 1}");
// 不算尾 + 当日即新周期首日 + 未到重置日 ==> 说明这一天应归入下一个计息周期 当天无需单独计息
if (!calcLast && daysFromPreEod == 1 && daysFromStart % (position.interest_rest_days ?? 1) != 0)
{
SwapCalcTrace.Critical($" [复利跨期直取] 直接用 preEod: InterestAmount={preEodPosition.InterestIncomeSum * closePrecent:F4} (不走重放)");
interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent;
interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent; // 计息基数
interest.FloatRate = preEodPosition.FloatRate;
InterestAmount = preEodPosition.InterestIncomeSum * closePrecent;
TdInterestAmount = preEodPosition.InterestIncomeSum * closePrecent;
InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; // 利息金额 = 待实现 * 平仓比例
TdInterestAmount = preEodPosition.InterestIncomeSum; // 当日新增利息
interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.InterestClosePnL = interest.InterestAmount * interestRatio;
interest.InterestClosePnL = interest.InterestAmount * interestRatio; // 利息端平仓盈亏 = 利息金额 * 方向
return interest;
}
// remainingPercent 只用于把上一日待实现分配给本次计算对应的本金。
// 按照利息腿的实际 计息基数 重新计算一个历史待实现利息的 平仓比例。不替代全局的平仓比例
// 部分平仓计算关闭 30% 时取 30%;最终全平剩余仓位时取 100%。
var remainingPercent = preEodPosition.TdInterestPrincipal > 0m
? closePosiNotionalValue / preEodPosition.TdInterestPrincipal
: 1m;
remainingPercent = Math.Max(0m, Math.Min(1m, remainingPercent));
// resetCarryInterest 是重置日并入复利本金的历史待实现,不是当天新增利息。
// 把上日尚未实现的的利息 按本次平掉的这部分计息基数分给本次平仓 并在重置日并入计息基数
// 它只在当前 endDate 恰好为重置日时使用,避免把同一笔历史利息重复资本化。
var resetCarryInterest = preEodPosition.InterestIncomeSum * remainingPercent;
CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, needPrice,
floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount,
consumedInterest, resetCarryInterest);
if (preEodPosition.id != 0 && closePrecent == 1m)
{
// 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。
// InterestAmount 是本次最终应结金额;TdInterestAmount 是不按关闭比例缩放的参考累计值。
// 二者在全平时都以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走。
var interestAtEnd = new swap_flow_event { InterestRate = rate };
decimal amountAtEnd = 0m;
decimal tdAmountAtEnd = 0m;
// InitInterestDate 在最终日不算尾时会先把 endDate 回拨一天;
// 历史差分的 amountAtEnd 需补回该日,但计算器仍使用交易 calcLast,
// 并将重放日期限制在合约到期日,避免提前全平或超期重复计息。
// 如果算尾 重放日 = 正常到期日
// 不算尾 且未超过到期日 重放日 = endDate+1 (补齐不算尾那天漏计的利息)
// 加1天超过到期日 截断到到期日
var replayEndDate = endDate;
if (!calcLast && endDate < valueDate)
{
replayEndDate = endDate.AddDays(1);
if (replayEndDate > td.ExerciseDate.Value)
{
replayEndDate = td.ExerciseDate.Value;
}
}
// 计算截至本次平仓日的累计利息 amountAtEnd
CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue,
interestAtEnd, annualDays, needPrice, floateRate, closePrecent, orginPv,
calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest);
var interestAtPreviousEod = new swap_flow_event { InterestRate = rate };
decimal amountAtPreviousEod = 0m;
decimal tdAmountAtPreviousEod = 0m;
// 最终日重放仍遵守交易的 calcLast;上一日终是历史截点而非合约尾日,
// 因此此处按闭区间包含上一日终当天,避免算头不算尾时重复加入该日利息。
// 计算截至上一日终累积的利息 amountAtPreviousEod
CalcDailyCompoundInterest(preEodPosition.ValueDate, position, closePosiNotionalValue,
interestAtPreviousEod, annualDays, needPrice, floateRate, closePrecent, orginPv,
calcFirst, true, ref amountAtPreviousEod, ref tdAmountAtPreviousEod, consumedInterest);
// 例如 00045/18 待实现 -118631.261797,加 5/19 新增约 -4648.912760
// 得到最终应结 -123280.174557,按金额两位落为 Excel BN 的 -123280.17。
// 上一日终已保存的待实现利息 + 截至平仓日累计利息 - 截至上一日终累计利息
// 这样只带走“上一日终以后新增的利息”,同时保留历史部分平仓时因两位金额结算留下的尾差,最终全平一次性结清。
InterestAmount = preEodPosition.InterestIncomeSum + amountAtEnd - amountAtPreviousEod;
TdInterestAmount = preEodPosition.InterestIncomeSum + tdAmountAtEnd - tdAmountAtPreviousEod;
}
}
else
{
@@ -1287,9 +1337,6 @@ namespace YLErp.Modules.SwapModule
decimal tdDynomicPrincipal = dynomicPrincipal;
var calcDays = (endDate - startDate).Days;
double floatRate = Convert.ToDouble(floateRate);
SwapCalcTrace.Critical($"CalcDailyCompoundInterest(盘中复利) posId={position.id} principal={principal:F4} calcFirst={calcFirst} calcLast={calcLast}");
SwapCalcTrace.Critical($" PosiStartDate={startDate:yyyy-MM-dd} calcDays={calcDays} interest_rest_days={interestPeriod} orginPv={orginPv:F4}");
SwapCalcTrace.Critical($" consumedInterest={consumedInterest:F4} resetCarryInterest={resetCarryInterest:F4} closePercent={closePercent}");
for (int i = 0; i <= calcDays; i++)
{
var accrueDate = startDate.AddDays(i);
@@ -1313,10 +1360,14 @@ namespace YLErp.Modules.SwapModule
{
if (i % interestPeriod == 0)
{
// 复利时:利息并入本金(FR007 取价已提前到 calcFirst/calcLast 跳过之前完成)
// 始终使用循环内高精度累加的 interest,不使用 EOD 快照的 resetCarryInterest(舍入值)
// 否则非重置日 EOD 的 InterestIncomeSum 包含多个周期利息,注入首重置日会导致精度偏差。
var interestToReset = interest;
// 每个重置节点 计息基数 = 前日本金 + 本期利息
// resetCarryInterest 是上一日终待实现按本次平仓比例分摊后的存量
// 只能在 endDate 恰好是当前复利重置日时并入本金。历史重置点必须使用
// 重放到当时的 interest,否则会把上一日终存量反复注入历史本金,
// 例如 0007 的 5/11 部分平仓会由 84,090.95 被多算为 84,114.88。
var interestToReset = i > 0 && accrueDate == endDate && resetCarryInterest != 0m
? resetCarryInterest
: interest;
dynomicPrincipal = principal + interestToReset;
tdDynomicPrincipal = principal + interestToReset;
flowEvent.InterestPrincipal = tdDynomicPrincipal;
@@ -1372,12 +1423,6 @@ namespace YLErp.Modules.SwapModule
decimal dynomicPrincipal = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv;
decimal tdDynomicPrincipal = dynomicPrincipal;
var calcDays = (endDate - startDate).Days;
SwapCalcTrace.Critical($"CalcDailySimpleInterest posId={position.id} mode={position.InterestMode} type={(position.InterestType == (int)InterestTypeEnum.复利 ? "" : "")}");
SwapCalcTrace.Critical($" PosiStartDate={startDate:yyyy-MM-dd} endDate={endDate:yyyy-MM-dd} calcDays={calcDays} calcFirst={calcFirst} calcLast={calcLast}");
SwapCalcTrace.Critical($" preEod.id={preEodPosition.id} ValueDate={preEodPosition.ValueDate:yyyy-MM-dd} TdInterestPrincipal={preEodPosition.TdInterestPrincipal:F4} InterestProfitSum={interestProfitSum:F4}");
SwapCalcTrace.Critical($" dynomicPrincipal = TdInterestPrincipal({preEodPosition.TdInterestPrincipal:F4}) + posiPrincipal({posiPrincipal:F4}) - orginPv({orginPv:F4}) = {dynomicPrincipal:F4}");
SwapCalcTrace.Critical($" 已结扣除(InterestProfitSum*closePercent)={interest:F4} consumedInterest={consumedInterest:F4} closePercent={closePercent}");
SwapCalcTrace.Critical($" 逐日累加上限: accrueDate > preEod.ValueDate({preEodPosition.ValueDate:yyyy-MM-dd}) 才计息");
double floatRate = Convert.ToDouble(floateRate);
for (int i = 0; i <= calcDays; i++)
{
@@ -1418,7 +1463,6 @@ namespace YLErp.Modules.SwapModule
}
interest += interest1;
tdinterest += tdinterest1;
SwapCalcTrace.Day(i, accrueDate, (decimal)floatRate, flowEvent.InterestPrincipal, interest1, interest);
}
}
InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
@@ -1446,12 +1490,10 @@ namespace YLErp.Modules.SwapModule
decimal tdDynomicPrincipal = posiPrincipal;
double floatRate = Convert.ToDouble(floateRate);
var days = (endDate - tradeDate).Days;
SwapCalcTrace.Critical($"CalcDailyCompoundInterestByEod(收盘复利) posId={position.id}");
SwapCalcTrace.Critical($" preEod.id={preEodPosition.id} ValueDate={preEodPosition.ValueDate:yyyy-MM-dd} TdInterestPrincipal={preEodPosition.TdInterestPrincipal:F4} InterestProfitSum={preEodPosition.InterestProfitSum:F4}");
SwapCalcTrace.Critical($" tradeDate={tradeDate:yyyy-MM-dd} endDate={endDate:yyyy-MM-dd} days={days} interest_rest_days={interestPeriod} closePercent={closePercent} orginPv={orginPv:F4} principal={principal:F4} posiPrincipal={posiPrincipal:F4}");
SwapCalcTrace.Critical($" {(days % interestPeriod == 0 ? ": " : ": base=TdInterestPrincipal+posiPrincipal-orginPv")}");
LogFactory.GetLogger("test").Error("lksafhasdhfjas");
if (days % interestPeriod == 0)
{
LogFactory.GetLogger("test").Error("kluausdyfh");
var remainingPercent = posiPrincipal > 0m
? principal / posiPrincipal
: 1m;
@@ -1542,12 +1584,6 @@ namespace YLErp.Modules.SwapModule
flowEvent.FloatRate = Convert.ToDecimal(floatRate);
var baseTdInterestPrincipal = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv;
var baseInterestPrincipal = baseTdInterestPrincipal * closePercent;
SwapCalcTrace.Critical($"CalcDailySimpleInterestByEod(收盘单利) posId={position.id}");
SwapCalcTrace.Critical($" preEod.id={preEodPosition.id} ValueDate={preEodPosition.ValueDate:yyyy-MM-dd} TdInterestPrincipal={preEodPosition.TdInterestPrincipal:F4} InterestProfitSum={preEodPosition.InterestProfitSum:F4}");
SwapCalcTrace.Critical($" tradeDate={tradeDate:yyyy-MM-dd} endDate={endDate:yyyy-MM-dd} calcDays={calcDays} closePercent={closePercent} orginPv={orginPv:F4} posiPrincipal={posiPrincipal:F4}");
SwapCalcTrace.Critical($" baseTdInterestPrincipal = TdInterestPrincipal({preEodPosition.TdInterestPrincipal:F4}) + posiPrincipal({posiPrincipal:F4}) - orginPv({orginPv:F4}) = {baseTdInterestPrincipal:F4}");
SwapCalcTrace.Critical($" baseInterestPrincipal(=base*closePercent)={baseInterestPrincipal:F4} floatRate={floatRate} rate={flowEvent.InterestRate} annualized={position.IsAnnualized}");
SwapCalcTrace.Critical($" 输出 InterestAmount = InterestProfitSum({preEodPosition.InterestProfitSum:F4}) + interest");
// 修复:正确计算本次利息(基于实际持仓本金)
decimal interest = baseInterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate));
@@ -127,12 +127,6 @@ namespace YLErp.Modules.SwapModule
}
}
/// <summary>持久化互换流水事件(生产: DbContext.swap_flow_event.Add;测试: 收集到列表)</summary>
protected virtual void PersistFlowEvent(swap_flow_event flowEvent)
{
DbContext.swap_flow_event.Add(flowEvent);
}
/// <summary>保存所有变更(生产: DbContext.SaveChanges;测试: 计数)</summary>
protected virtual void SaveAllChanges()
{
@@ -779,14 +773,14 @@ namespace YLErp.Modules.SwapModule
// 利息腿:插入资金记录(使用系统操作_互换)
if (unwindData.SwapCloseAmount != 0)
{
clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapCloseAmount), ClientCashInCashOut._互换, cashHappenDate);
clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapCloseAmount), ClientCashInCashOut._互换, cashHappenDate);
clientCashIds.Add(clientCashId);
}
// 预付金腿:单独插入一条资金记录(系统操作_预付金返息)
if (unwindData.SwapMarginRebatePnl != 0)
{
clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapMarginRebatePnl), ClientCashInCashOut._预付金返息, unwindData.ValueDate);
clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapMarginRebatePnl), ClientCashInCashOut._预付金返息, unwindData.ValueDate);
clientCashIds.Add(clientCashId);
}
unwindData.SwapCloseAmount = unwindData.SwapRealizedPnL;//需要算上预付金利息 和 分红; 只是不算预付金返还
@@ -796,19 +790,19 @@ namespace YLErp.Modules.SwapModule
var dividendPayDate = (dividendEvents != null && dividendEvents.Count > 0)
? dividendEvents.First().PayDate.Value
: unwindData.ValueDate;
clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapDividendPnl), ClientCashInCashOut._互换, dividendPayDate);
clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapDividendPnl), ClientCashInCashOut._互换, dividendPayDate);
clientCashIds.Add(clientCashId);
}
unwindData.ClientCashIds = clientCashIds;
string data = JsonConvert.SerializeObject(unwindData);
var swapEvent = AddSwapEvent(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum., data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件
var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum., data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件
if (flowEvents!=null)
{
flowEvents.ForEach(x =>
{
x.EventId = swapEvent.id;
PersistFlowEvent(x);
DbContext.swap_flow_event.Add(x);
});
UpdateInitalPostion(flowEvents, td.id);
}
@@ -819,7 +813,7 @@ namespace YLErp.Modules.SwapModule
dividendEvents.ForEach(x =>
{
x.EventId = swapEvent.id;
PersistFlowEvent(x);
DbContext.swap_flow_event.Add(x);
});
UpdateInitalPostion(dividendEvents, td.id);
}
@@ -829,7 +823,7 @@ namespace YLErp.Modules.SwapModule
/// 互换更新实时持仓信息
/// </summary>
/// <param name="flowEvents"></param>
protected virtual void UpdateInitalPostion(List<swap_flow_event> flowEvents, int swapTradeId)
private void UpdateInitalPostion(List<swap_flow_event> flowEvents, int swapTradeId)
{
var positions = DbContext.swap_position.Where(x => !x.IsInitial && x.SwapTradeId == swapTradeId && !x.Invalid);
foreach (var position in positions)
@@ -1275,8 +1269,13 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 自动互换用,当日无互换,当日有平仓
/// 将平仓/自动互换的盘中利息结果写成当日日终利息腿。
/// 字段完整口径和逐日示例见《收益互换日终收盘总流程与当前代码审查》7.2、16.7、16.13 节。
/// </summary>
/// <remarks>
/// 关键状态链:上日待实现 + 当日新增 - 当日结息 = 当日待实现;
/// 上日累计已实现 + 当日结息(按收付方向)= 当日累计已实现。
/// </remarks>
/// <param name="eodPayPosition">上一日日终持仓</param>
/// <param name="newEodPayPosition">当前收盘日日终持仓 不可能为空</param>
/// <param name="position">利息腿信息</param>
@@ -1291,8 +1290,11 @@ namespace YLErp.Modules.SwapModule
{
Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
var tradeExtend = td.trade_extend.ExtendObj;
// oriPosiNotionalValue 是平仓前规模,posiNotionalValue 是收盘后剩余规模,closeNational 是本次关闭规模。
// 例如 30% 平仓:303139117.80 = 212197382.46 + 90941735.34。
decimal oriPosiNotionalValue = posiLongNotional + posiShortNational + closeNational;
decimal posiNotionalValue = posiLongNotional + posiShortNational;
// ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。
decimal ratio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;//收取为正,支付为负
if (marginTypes.Contains(position.InterestMode))
{
@@ -1301,10 +1303,16 @@ namespace YLErp.Modules.SwapModule
// 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。
// 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum
// 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。
var hasPreviousEod = eodPayPosition != null && eodPayPosition.id != 0;
// InterestIncomeSum 是尚未结算的高精度利息;RealizedInterest 是生命周期累计已结利息。
// 二者不能相互替代,也不能在部分平仓后重新从 0 开始。
var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m;
var lastInterestFeeSum = eodPayPosition?.InterestFeeSum ?? 0m;
var lastRealizedInterest = eodPayPosition?.RealizedInterest ?? 0m;
var lastRealizedInterestFee = eodPayPosition?.RealizedInterestFee ?? 0m;
// 先保留平仓前的复利本金;后面 interests.First().InterestPrincipal 是本次已平部分,
// 不能用它代表平仓前全额本金计算当日总利息。
var lastTdInterestPrincipal = eodPayPosition?.TdInterestPrincipal ?? 0m;
// 保留上一日日终标识和计息上下文,部分平仓只从 ValueDate 之后续算,不能重置到交易起始日。
eodPayPosition = eodPayPosition?.Clone() ?? new eod_swap_position();
eodPayPosition.ClientId = td.ClientId;
@@ -1332,8 +1340,9 @@ namespace YLErp.Modules.SwapModule
}
else
{
orginPv = oriPosiNotionalValue;
orginPv = posiNotionalValue;
}
// closePercent 描述本次关闭占平仓前仓位的比例;上例为 90941735.34 / 303139117.80 = 30%。
decimal closePercent = oriPosiNotionalValue == 0 ? 0 : closeNational / oriPosiNotionalValue;
var eventType = autoSwap ? (int)SwapEventTypeEnum. : (int)SwapEventTypeEnum.;
bool longShort = td.StructureType == ClientMarginTypeEnum..ToString();
@@ -1350,10 +1359,10 @@ namespace YLErp.Modules.SwapModule
List<eod_swap_position> preEodPositions = new List<eod_swap_position>();
preEodPositions.Add(eodPayPosition);
var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true;
// 使用 oriPosiNotionalValue(平仓前原始名义本金)而非 posiNotionalValue(平仓后剩余):
// EOD 重算需基于完整头寸计算总应计利息(TdInterestAmount),再由 flowEvents 的 TdCloseInterest 扣减平仓部分
// 若用剩余本金(如 212M),重算只得到 70% 利息,导致 InterestIncomeSum 偏差
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, oriPosiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast || (valueDate == td.ExerciseDate));
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:本次事件发生前理论应结的高精度利息
// manualSettledInterestAmountswap_flow_event 实际落库的手工结息,金额已按分处理。
decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount);
decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount);
decimal manualSettledInterestAmount = flowEvents.Sum(x => x.InterestAmount);
@@ -1387,9 +1396,12 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.interest_rest_days = position.interest_rest_days;
newEodPayPosition.interest_rule = position.interest_rule;
//利息端估值用信息
// TdInterestPrincipal 是“下一日继续计息的收盘后本金”,不是原始合同规模,也不是本次平仓本金。
// 模式9单利直接取剩余名义本金;复利还要保留重置时已经并入本金的待实现利息。
newEodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode)
? position.InterestPrincipalFix
: position.InterestType != (int)InterestTypeEnum.
: position.InterestMode == (int)InterestModeEnum.
&& position.InterestType != (int)InterestTypeEnum.
? posiNotionalValue
: interests.Count > 0 ? interests.First().InterestPrincipal : 0;
if (interval != null)
@@ -1403,35 +1415,104 @@ namespace YLErp.Modules.SwapModule
//当日已实现,平仓时已处理
newEodPayPosition.TdInterestFee = flowEvents.Sum(s => s.InterestFee);
newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee;
// TdCloseInterest 只表示当天真正结算出去的金额;部分平仓未结部分继续留在 InterestIncomeSum。
newEodPayPosition.TdCloseInterest = manualSettledInterestAmount + autoSettledInterestAmount;
// intersetAcmount 是收盘后本金的一天应计展示值。算尾部分平仓时,下面的复利分支会改用
// 平仓前全额本金重算当天新增,但跨日携带的 TdInterestPrincipal 仍只能是剩余本金。
var intersetAcmount = newEodPayPosition.TdInterestPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate);
if (position.IsAnnualized)
{
intersetAcmount /= tradeExtend.AnnualDays;
}
newEodPayPosition.TdInterestIncome = !autoSwap
? TdInterestAmount - lastInterestIncomeSum
: intersetAcmount;
newEodPayPosition.TdInterestIncome = autoSwap
? intersetAcmount
: !hasPreviousEod
? interestAmountBeforeSettlement
: posiNotionalValue == 0m
? interestAmountBeforeSettlement - lastInterestIncomeSum
: lastRealizedInterest != 0m || lastRealizedInterestFee != 0m || !calcLast
? intersetAcmount
: TdInterestAmount - lastInterestIncomeSum;
if (!autoSwap
&& closePercent > 0m && closePercent < 1m
&& posiNotionalValue > 0m
&& position.InterestType == (int)InterestTypeEnum.
&& (position.InterestMode == (int)InterestModeEnum.
|| position.InterestMode == (int)InterestModeEnum.))
{
// 模式2(合约名义本金规模)和模式9(标的期初全价)都以名义本金
// 作为复利基数;算尾用平仓前全额当日利息再扣实际结算,
// 不算尾只计剩余本金,避免已平部分利息进入后续复利。
// fullPrincipal 是平仓前动态复利本金,仅用于判断平仓日应按全额还是剩余额计息。
var fullPrincipal = lastTdInterestPrincipal > 0m
? lastTdInterestPrincipal
: oriPosiNotionalValue;
// 当日计提按平仓前全额动态本金;跨日携带必须只留剩余仓位。
// calcLast=true 时,模式2返回本次已平部分本金,需反推剩余本金;
// 模式9返回的已是剩余本金,不能再次按比例放大(GLMS-20260421-0004)。
// calcLast=false 快速路径返回上一 EOD 全额本金,保留原剩余比例缩放。
var usesFullPreviousEodPrincipal = !calcLast
&& hasPreviousEod
&& (valueDate - eodPayPosition.ValueDate).Days == 1
&& (valueDate - position.PosiStartDate).Days % (position.interest_rest_days ?? 1) != 0;
if (calcLast
&& position.InterestMode == (int)InterestModeEnum.)
{
// 模式2的 InterestPrincipal 是已平部分,需反推平仓前全额后再取剩余;
// 模式9已直接返回剩余动态本金,再反推会把 30% 平仓后的本金放大 7/3 倍。
// 例如模式9的 212135529.97 已是剩余本金,错误反推会变成 494982903.27。
newEodPayPosition.TdInterestPrincipal *= (1m - closePercent) / closePercent;
}
else if (usesFullPreviousEodPrincipal)
{
newEodPayPosition.TdInterestPrincipal *= 1m - closePercent;
}
// 不算尾时,TdInterestPrincipal 已由计息器完成重置日待实现利息结转,
// 并在非重置日分支按剩余仓位调整;若再次用上日本金乘剩余比例,
// 会漏掉重置后已并入本金的待实现利息(如 2026-08-04 两笔 JIATT 交易)。
var accrualPrincipal = calcLast
? fullPrincipal
: newEodPayPosition.TdInterestPrincipal;
newEodPayPosition.TdInterestIncome = accrualPrincipal
* (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate);
if (position.IsAnnualized)
{
newEodPayPosition.TdInterestIncome /= tradeExtend.AnnualDays;
}
}
if (!autoSwap
&& closePercent > 0m && closePercent < 1m
&& posiNotionalValue > 0m
&& position.InterestType == (int)InterestTypeEnum.
&& (position.InterestMode == (int)InterestModeEnum.
|| position.InterestMode == (int)InterestModeEnum.))
{
// 单利算尾当日仍按平仓前全额计提,跨日 EOD 本金只携带剩余持仓。
newEodPayPosition.TdInterestPrincipal = posiNotionalValue;
}
Log.Info($"InterestIncomeSum is {lastInterestIncomeSum},TdInterestIncome is {newEodPayPosition.TdInterestIncome}" +
$",TdCloseInterest is {newEodPayPosition.TdCloseInterest}");
Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee}" +
$",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}");
if (closePercent == 1)
{
// 全量平仓后不应把待实现利息或费用带入下一交易日。
newEodPayPosition.InterestIncomeSum = 0;
newEodPayPosition.InterestFeeSum = 0;
}
else
{
var pendingInterestBeforeSettlement = autoSwap
? interestAmountBeforeSettlement
: lastInterestIncomeSum + newEodPayPosition.TdInterestIncome;
newEodPayPosition.InterestIncomeSum = RoundEodInterest(
pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
}
// pendingInterestBeforeSettlement 是“扣款前待实现”。普通平仓按上日待实现 + 当日新增;
// 自动互换的 interestAmountBeforeSettlement 已经是完整理论应结,不能再加一次上日值。
var pendingInterestBeforeSettlement = autoSwap
? interestAmountBeforeSettlement
: lastInterestIncomeSum + newEodPayPosition.TdInterestIncome;
var pendingInterestFeeBeforeSettlement = eodPayPosition.InterestFeeSum
+ newEodPayPosition.TdInterestFee;
// InterestIncomeSum 是收盘后仍未结算的尾差/剩余利息。
// 部分平仓:扣款前待实现 - TdCloseInterest;最终全平且两位金额已覆盖时直接清零。
newEodPayPosition.InterestIncomeSum = closePercent == 1
&& RoundMoney(pendingInterestBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest)
? 0m
: RoundEodInterest(pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = closePercent == 1
&& RoundMoney(pendingInterestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee)
? 0m
: RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee);
//持仓内容-利息腿-损益统计(本方视角)
// InterestProfitSum 是利息腿待实现总额,包含利息和费用;无费用时等于 InterestIncomeSum。
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值
newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum;
@@ -1441,6 +1522,8 @@ namespace YLErp.Modules.SwapModule
Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee}" +
$",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}");
//累计已实现
// RealizedInterest 只增不回滚:上日累计已实现 + 当日结息按方向后的金额。
// 收取腿的 -37119.14 会把累计已实现更新为 -37119.14;后续普通 EOD 保持该值。
newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio;
newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee;
SetFixedLegRealizedPnl(newEodPayPosition);
@@ -125,6 +125,8 @@ namespace YLErp.Web.Controllers
renewTrade.id = 0;
renewTrade.TradeNumber = string.Empty;
renewTrade.ParentTradeId = 0;
renewTrade.IsGroup = 0;
renewTrade.IsApproval = false;
renewTrade.TradeDate = defaultTrade.TradeDate;
renewTrade.StartDate = defaultTrade.StartDate;
renewTrade.ExerciseDate = null;
@@ -160,6 +162,7 @@ namespace YLErp.Web.Controllers
renewTrade.trade_swap.id = 0;
renewTrade.trade_swap.TradeId = 0;
renewTrade.trade_swap.FlowId = null;
renewTrade.trade_swap.OriginalTradeId = null;
// The renewed payment floating leg opens the opposite underlying side.
// 这里只需要对【支付】相关腿进行操作
@@ -202,6 +205,7 @@ namespace YLErp.Web.Controllers
// 清空运行时累计字段(这些字段在源交易存续期间可能被累计)
renewPosition.InterestAmount = 0;
renewPosition.InterestFeePending = 0;
renewPosition.FloatRate = 0;
renewPosition.PosiDividendIncome = 0;
renewPosition.InterestSwapInterval = null;
renewPosition.Obervation = null;