feat(swap): 实现预付金利息腿实时剩余本金计算功能
- 新增 ResolveInterestLegPositionsAsOf 方法处理预付金利息腿实时本金计算 - 添加 FindCompletedFlowEvents 方法查询已完成的现金流事件 - 在日终持仓服务中集成实时剩余本金计算逻辑 - 移除日终重复扣减预付金本金的逻辑 - 优化利息端估值计算方式 - 添加部分平仓后预付金日终按实时剩余本金计息的测试用例 - 增加平仓日预付金日终不得重复扣减实时剩余本金的验证 - 完善历史回放场景下的本金调整功能
This commit is contained in:
@@ -36,6 +36,7 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
// 输出别名(转发到基类捕获属性)
|
||||
public List<eod_swap_position> CreatedEodPositions => PersistedPositions;
|
||||
public List<swap_position> LastInterestCalculationPositions { get; private set; }
|
||||
|
||||
public TestableSwapEodService(
|
||||
List<trade> trades, List<swap_position> positions,
|
||||
@@ -80,7 +81,17 @@ namespace YLErp.Modules.SwapModule
|
||||
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
|
||||
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
|
||||
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
|
||||
List<swap_flow_event> closeList = null) => new List<swap_flow_event>();
|
||||
List<swap_flow_event> closeList = null)
|
||||
{
|
||||
LastInterestCalculationPositions = positions;
|
||||
return positions.Select(position => new swap_flow_event
|
||||
{
|
||||
PositionId = position.id,
|
||||
InterestPrincipal = 1000m,
|
||||
InterestRate = 0.01m,
|
||||
FloatRate = 0.01m
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
|
||||
=> SwapPositionCompose(settleDate, preSettleDate, null);
|
||||
@@ -250,5 +261,154 @@ namespace YLErp.Modules.SwapModule
|
||||
Assert.IsTrue(ex.Message.Contains("未收盘"), $"异常消息应含'未收盘',实际:{ex.Message}");
|
||||
Console.WriteLine($"SPC_004: 抛异常'{ex.Message}' ✅");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SPC_005_部分平仓后_预付金日终按实时剩余本金计息()
|
||||
{
|
||||
const long initialPrepayId = 2;
|
||||
var td = CreateTrade();
|
||||
var initialPrepay = new swap_position
|
||||
{
|
||||
id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false,
|
||||
PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value,
|
||||
InterestSwapInterval = "[]"
|
||||
};
|
||||
var realPrepay = new swap_position
|
||||
{
|
||||
id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = 700m, IsInitial = false, Invalid = false
|
||||
};
|
||||
var prepayEod = new eod_swap_position
|
||||
{
|
||||
id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId,
|
||||
ValueDate = PreSettleDate, PosiDirection = 0,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = 700m, TdInterestPrincipal = 700m
|
||||
};
|
||||
var service = new TestableSwapEodService(
|
||||
new List<trade> { td },
|
||||
new List<swap_position> { CreateFloatPosition(1, 1000), initialPrepay, realPrepay },
|
||||
new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod },
|
||||
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } },
|
||||
new List<trade_extend> { CreateExtend() }, new List<swap_flow_event>());
|
||||
|
||||
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
|
||||
|
||||
var calculatedPrepay = service.LastInterestCalculationPositions
|
||||
.Single(x => x.id == initialPrepayId);
|
||||
Assert.AreEqual(700m, calculatedPrepay.InterestPrincipalFix);
|
||||
Assert.AreEqual(initialPrepayId, calculatedPrepay.id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SPC_006_平仓日_预付金日终不得重复扣减实时剩余本金()
|
||||
{
|
||||
const long initialPrepayId = 2;
|
||||
var td = CreateTrade();
|
||||
var initialPrepay = new swap_position
|
||||
{
|
||||
id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false,
|
||||
IsAnnualized = true,
|
||||
PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value,
|
||||
InterestSwapInterval = "[]"
|
||||
};
|
||||
var realPrepay = new swap_position
|
||||
{
|
||||
id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = 700m, IsInitial = false, Invalid = false
|
||||
};
|
||||
var prepayEod = new eod_swap_position
|
||||
{
|
||||
id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId,
|
||||
ValueDate = PreSettleDate, PosiDirection = 0,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = 1000m, TdInterestPrincipal = 1000m
|
||||
};
|
||||
var closeFlow = CreateCloseFlowEvent(1, 300);
|
||||
closeFlow.InterestRate = 0.01m;
|
||||
var service = new TestableSwapEodService(
|
||||
new List<trade> { td },
|
||||
new List<swap_position> { CreateFloatPosition(1, 1000), initialPrepay, realPrepay },
|
||||
new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod },
|
||||
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } },
|
||||
new List<trade_extend> { CreateExtend() },
|
||||
new List<swap_flow_event> { closeFlow });
|
||||
|
||||
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
|
||||
|
||||
var persistedPrepay = service.CreatedEodPositions
|
||||
.Single(x => x.PositionId == initialPrepayId);
|
||||
Assert.AreEqual(700m, persistedPrepay.InterestPrincipalFix,
|
||||
"实时腿已经扣减到700,日终不得再次按平仓比例扣减");
|
||||
Assert.AreEqual(700m, persistedPrepay.TdInterestPrincipal,
|
||||
"平仓日预付金计息本金应立即切换为实时剩余本金");
|
||||
Assert.AreEqual(700m * 0.01m / 365m, persistedPrepay.TdInterestIncome,
|
||||
"平仓日新增利息应按实时剩余本金计算");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SPC_007_HistoricalReplayUsesAsOfPrincipal()
|
||||
{
|
||||
const long originalPositionId = 2;
|
||||
var original = new swap_position
|
||||
{
|
||||
id = originalPositionId, PosiDirection = 0,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = 10000m
|
||||
};
|
||||
var realtime = new swap_position
|
||||
{
|
||||
PositionId = originalPositionId,
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipalFix = 7000m
|
||||
};
|
||||
var close = new swap_flow_event
|
||||
{
|
||||
PositionId = originalPositionId,
|
||||
PositionType = 0,
|
||||
EventType = (int)SwapEventTypeEnum.平仓,
|
||||
EventDate = new DateTime(2026, 7, 9),
|
||||
InterestMode = (int)InterestModeEnum.初始预付金,
|
||||
InterestPrincipal = 3000m
|
||||
};
|
||||
var floatClose = new swap_flow_event
|
||||
{
|
||||
PositionId = 1,
|
||||
PositionType = 1,
|
||||
EventType = (int)SwapEventTypeEnum.平仓,
|
||||
EventDate = new DateTime(2026, 7, 9),
|
||||
TradingAmount = 3000000m
|
||||
};
|
||||
var originalWithFloat = new List<swap_position>
|
||||
{
|
||||
original,
|
||||
new swap_position { id = 1, PosiDirection = 1, PosiNotionalValue = 10000000m }
|
||||
};
|
||||
|
||||
var beforeClose = SwapDealService.ResolveInterestLegPositionsAsOf(
|
||||
originalWithFloat, new List<swap_position> { realtime },
|
||||
new[] { close, floatClose }, new DateTime(2026, 7, 8))
|
||||
.Single(x => x.id == originalPositionId);
|
||||
var onCloseDate = SwapDealService.ResolveInterestLegPositionsAsOf(
|
||||
originalWithFloat, new List<swap_position> { realtime },
|
||||
new[] { close, floatClose }, new DateTime(2026, 7, 9))
|
||||
.Single(x => x.id == originalPositionId);
|
||||
|
||||
Assert.AreEqual(10000m, beforeClose.InterestPrincipalFix);
|
||||
Assert.AreEqual(7000m, onCloseDate.InterestPrincipalFix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,6 +665,58 @@ namespace YLErp.Modules.SwapModule
|
||||
return p;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算预付金腿当前真实持仓 (当前持仓+未来持仓)
|
||||
/// </summary>
|
||||
/// <param name="origPositions"></param>
|
||||
/// <param name="realPositions"></param>
|
||||
/// <param name="completedFlowEvents"></param>
|
||||
/// <param name="settleDate"></param>
|
||||
/// <returns></returns>
|
||||
public static List<swap_position> ResolveInterestLegPositionsAsOf(
|
||||
List<swap_position> origPositions, List<swap_position> realPositions,
|
||||
IEnumerable<swap_flow_event> completedFlowEvents, DateTime settleDate)
|
||||
{
|
||||
realPositions ??= new List<swap_position>();
|
||||
var futureFlows = (completedFlowEvents ?? Enumerable.Empty<swap_flow_event>())
|
||||
.Where(x => x.EventType == (int)SwapEventTypeEnum.平仓 && x.EventDate > settleDate)
|
||||
.ToList();
|
||||
var originalNotional = origPositions.Where(x => x.PosiDirection > 0)
|
||||
.Sum(x => x.PosiNotionalValue);
|
||||
var futureCloseNotional = futureFlows.Where(x => x.PositionType > 0)
|
||||
.Sum(x => x.TradingAmount);
|
||||
var hasNotionalFlows = futureCloseNotional > 0 && originalNotional > 0;
|
||||
var futureClosePrincipal = futureFlows
|
||||
.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金
|
||||
|| x.InterestMode == (int)InterestModeEnum.追加预付金)
|
||||
.GroupBy(x => x.PositionId)
|
||||
.ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal));
|
||||
|
||||
return origPositions.Where(x => x.PosiDirection == 0).Select(p =>
|
||||
{
|
||||
if (p.InterestMode == (int)InterestModeEnum.初始预付金
|
||||
|| p.InterestMode == (int)InterestModeEnum.追加预付金)
|
||||
{
|
||||
var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id);
|
||||
if (realLeg != null)
|
||||
{
|
||||
var futurePrincipal = hasNotionalFlows
|
||||
? p.InterestPrincipalFix * futureCloseNotional / originalNotional
|
||||
: futureClosePrincipal.TryGetValue(p.id, out var flowPrincipal) ? flowPrincipal : 0m;
|
||||
var asOfPrincipal = realLeg.InterestPrincipalFix + futurePrincipal;
|
||||
asOfPrincipal = Math.Min(p.InterestPrincipalFix, Math.Max(0m, asOfPrincipal));
|
||||
if (asOfPrincipal != p.InterestPrincipalFix)
|
||||
{
|
||||
var clone = p.Clone();
|
||||
clone.InterestPrincipalFix = asOfPrincipal;
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public static decimal ResolveUnwindPreviousNotional(
|
||||
eod_swap lastEod,
|
||||
|
||||
@@ -307,6 +307,14 @@ namespace YLErp.Modules.SwapModule
|
||||
return DbContext.swap_flow_event.Where(eventExpression).ToList();
|
||||
}
|
||||
|
||||
protected virtual List<swap_flow_event> FindCompletedFlowEvents(List<int> tradeIds)
|
||||
{
|
||||
return DbContext.swap_flow_event
|
||||
.Where(x => tradeIds.Contains(x.SwapTradeId)
|
||||
&& x.DataState == (int)SwapFlowDateStateEnum.完成)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
@@ -352,6 +360,7 @@ namespace YLErp.Modules.SwapModule
|
||||
var tradeRealPositionList = allTradePositionList.Where(t => !t.IsInitial).ToList();
|
||||
var tradeExtendList = FindTradeExtends(tradeIds);
|
||||
var eodSwapList = FindEodSwapsByDate(preSettleDate);
|
||||
var completedFlowEvents = FindCompletedFlowEvents(tradeIds);
|
||||
List<int> eventTyps = new List<int>() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 };
|
||||
foreach (var td in tradeQueryList)
|
||||
{
|
||||
@@ -364,7 +373,10 @@ namespace YLErp.Modules.SwapModule
|
||||
var realPositions = tradeRealPositionList.Where(s => s.SwapTradeId == td.id);
|
||||
var posiList = positions.Where(x => x.PosiQuantity > 0).ToList();
|
||||
var realPosiList = realPositions.ToList();
|
||||
var interestList = positions.Where(x => x.InterestDirection > 0).ToList();
|
||||
var tradeCompletedFlowEvents = completedFlowEvents.Where(x => x.SwapTradeId == td.id).ToList();
|
||||
var interestList = SwapDealService.ResolveInterestLegPositionsAsOf(
|
||||
positions.ToList(), realPosiList, tradeCompletedFlowEvents, settleDate)
|
||||
.Where(x => x.InterestDirection > 0).ToList();
|
||||
DateTime posiDate = td.TradeDate.Value;//交易日期
|
||||
var lastEodSwap = eodSwapList.FirstOrDefault(x => x.SwapTradeId == td.id);
|
||||
//上一交易日无日终归档,且不是交易日期,且当前收盘日期不是交易日期,报错
|
||||
@@ -1351,8 +1363,9 @@ namespace YLErp.Modules.SwapModule
|
||||
//持仓内容-利息腿
|
||||
newEodPayPosition.InterestDirection = position.InterestDirection;
|
||||
newEodPayPosition.InterestMode = position.InterestMode;
|
||||
// ResolveInterestLegPositions 已提供平仓后的实时剩余本金,日终不再重复扣减。
|
||||
newEodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix;
|
||||
newEodPayPosition.InterestPrincipalFix *= (1 - closePercent);
|
||||
// newEodPayPosition.InterestPrincipalFix *= (1 - closePercent);
|
||||
newEodPayPosition.InterestRateDefault = position.InterestRateDefault;
|
||||
newEodPayPosition.InterestSwapInterval = position.InterestSwapInterval;
|
||||
newEodPayPosition.IsAnnualized = position.IsAnnualized;
|
||||
@@ -1364,9 +1377,11 @@ namespace YLErp.Modules.SwapModule
|
||||
newEodPayPosition.interest_rest_days = position.interest_rest_days;
|
||||
newEodPayPosition.interest_rule = position.interest_rule;
|
||||
//利息端估值用信息
|
||||
newEodPayPosition.TdInterestPrincipal = position.InterestMode == (int)InterestModeEnum.标的期初全价
|
||||
? posiNotionalValue
|
||||
: interests.Count > 0 ? interests.First().InterestPrincipal : 0;
|
||||
newEodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode)
|
||||
? position.InterestPrincipalFix
|
||||
: position.InterestMode == (int)InterestModeEnum.标的期初全价
|
||||
? posiNotionalValue
|
||||
: interests.Count > 0 ? interests.First().InterestPrincipal : 0;
|
||||
if (interval != null)
|
||||
{
|
||||
newEodPayPosition.TdInterestRate = interval.Rate;
|
||||
|
||||
Reference in New Issue
Block a user