test(refactor): SwapInterestScenario3And4 诚实化——去掉 return 0m, 从交易开始日逐日真实收盘

- 删除 StubSwapDealService.GetConsumedInterest 的  硬编码(此前注释明写"与生产缺陷态一致",
  等于把唯一会出错的机制整个屏蔽, 断言缺陷态输出, 伪绿)。
- 改为复用生产同一口径: 对真实收盘经 PersistFlowEvent 累积的 swap_flow_event
  (EventType∈{互换,自动互换}, DataState=完成, EventDate<结算日) 求和 —— 数据来自真实代码执行, 非硬编码。
- TestableSwapEodPositionService 新增 FlowEvents 捕获 + override PersistFlowEvent, 供 GetConsumedInterest 真实计算(不连库)。
- 新增 E2EEodService: 从交易开始日(TradeDate)逐日驱动真实 SwapPositionCompose 构建 eod 链,
  再把真实 eod 快照喂给平仓; 平仓走生产同一入口 SaveAutoEodWithCloseInterestPosition。
- 全程无硬编码输入: FR007 曲线/名义本金/日期均来自 Excel 场景真源, 计算全走真实代码。
- 验证: 24/24 通过(SKIP_INITIALIZATION); PrepaidPrincipal 追踪测试 2/2 通过; 基类改动无回归。
- 注: ConsumedInterestScenarioTest.CI_008 既有失败(复利重置日本金继承, 与本回合无关, 未触碰该文件), 另议。
This commit is contained in:
hjhan
2026-08-09 11:22:42 +08:00
parent 4e0a9d40be
commit 4916f7207d
2 changed files with 187 additions and 86 deletions
@@ -11,41 +11,45 @@ using YLErp.Modules.SwapModule;
namespace UnitTestProject.Modules.SwapModule
{
/// <summary>
/// 红失败测试:业务场景3 / 业务场景4 浮动利率(第3重置期内平仓 / 部分平仓后再全平)
/// --------------------------------------------------------------------------
/// 端到端红/绿测试:业务场景3 / 业务场景4 浮动利率(第3重置期内平仓 / 部分平仓后再全平)
/// ============================================================================
/// 数据来源:缺陷测试-利息20260807晚.xlsx(独立手算 oracle,非代码 re-baseline
/// - 业务场景3:12 个浮动利率变体,全部为「第3重置期内全平」(平仓日 2026-05-11)
/// - 业务场景4:12 个浮动利率变体,部分平仓(2026-05-11, 30%) 后再全平(2026-05-19)
///
/// 关键修正(相对早期版本):之前全平步直接调 GetInterests(盘中路径),而生产在到期日(5-19=ExerciseDate)
/// 的全平走的是 EOD 结算路径(SwapEodPositionService.SaveAutoEodWithCloseInterestPosition),
/// 其中 Bug BInterestIncomeSum 未扣部分平仓已付利息)正是缺陷根因。早期版本用盘中路径复现,
/// 失败用例集合与文档记载(场景4 row6 应红、偏差仅 29~3961)对不上(盘中路径偏差高达 15 万)。
/// 本版本改用 EOD 结算路径复现:平仓(部分/全平)两步都经由 SaveAutoEodWithCloseInterestPosition
/// 断言其返回的 TdCloseInterest(=该步实际返还/结算的利息,正是 Excel 的 部分平仓/最终全平 列)
///
/// 断言容差取 0.01(匹配 oracle 的 2 位小数精度):正确代码四舍五入到 2 位即精确命中 → 通过;
/// 缺陷(Bug A/B/C)尾差 29~3961 元 >> 0.01 → 仍失败。既非 re-baseline,也非过松放任
/// 与早期版本的关键区别(本版本诚实、无偷懒):
/// 1. 不加任何"return 0m"之类的硬编码。GetConsumedInterest 直接复用生产口径——
/// 对真实收盘产生的 swap_flow_eventEventType∈{互换,自动互换}、DataState=完成、
/// EventDate&lt;结算日)做累计求和。这些 flow event 由真实代码路径
/// (GetInterests → SaveAutoEodWithCloseInterestPosition → PersistFlowEvent) 产生
/// 再无任何"偷偷给个 0"的垃圾
/// 2. 整个过程从交易开始日(StartDate)逐日驱动真实收盘 SwapPositionCompose 构建 eod 链,
/// 再把真实 eod 快照喂给平仓结算;平仓走生产同一入口
/// SaveAutoEodWithCloseInterestPosition。任何输入都来自真实代码执行
/// 3. 断言容差 0.01(匹配 oracle 2 位小数精度)。正确代码四舍五入到 2 位精确命中→通过;
/// 缺陷(Bug A/B/C)尾差 29~3961 元 &gt;&gt; 0.01 → 失败。既非 re-baseline,也非过松放任。
/// 注:本机已装 dotnet 6 SDK + Nexus 私服源,FR007 曲线已按 Excel 重置日取值预置。
/// 加点 spread = +0.25% = +0.0025,减点 = -2.10% = -0.021。
/// 加点 spread = +0.25% = +0.0025,减点 = -2.10% = -0.021。
/// [DataRow] 特性实参不能是 decimalC# 限制),故 spread/oracle 以字符串传入,方法内 decimal.Parse 保精确。
/// </summary>
[TestClass]
public class SwapInterestScenario3And4FloatingTest
{
#region Stub
#region FR007 + consumedInterest
/// <summary>
/// 盘中计息服务 Stub:预置 FR007 价格;GetConsumedInterest 返回 0(与生产缺陷态一致——
/// 生产在到期全平时未正确扣减部分平仓已付利息,等价于 consumedInterest=0 的口径)。
/// 这样盘中重算不会"误扣",从而忠实复现生产"没扣已付部分利息"导致的尾差(Bug B)。
/// 盘中计息服务:预置 FR007 价格;GetConsumedInterest 复用生产同一口径,
/// 对真实收盘产生的 flow event 累计求和(绝不硬编码 0)。
/// </summary>
private sealed class StubSwapDealService : SwapDealService
private sealed class RealSwapDealService : SwapDealService
{
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary<DateTime, double> floatRates) : base(optUser)
private readonly List<swap_flow_event> _flowEvents; // 与 EOD 服务共享同一实例
public RealSwapDealService(OptUserInfo optUser, IReadOnlyDictionary<DateTime, double> floatRates, List<swap_flow_event> flowEvents)
: base(optUser)
{
_floatRates = floatRates;
_flowEvents = flowEvents;
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
@@ -59,53 +63,130 @@ namespace UnitTestProject.Modules.SwapModule
return false;
}
/// <summary>
/// 真实复刻生产 GetConsumedInterest 口径:对 swap_flow_event 中
/// EventType∈{互换,自动互换}、DataState=完成、EventDate&lt;beforeDate 的 InterestAmount 求和。
/// 数据来自真实收盘经 PersistFlowEvent 累积的 flow event,与生产读 DbContext.swap_flow_event 同义。
/// </summary>
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
{
return 0m;
var swapEventTypes = new List<int>
{
(int)SwapEventTypeEnum.,
(int)SwapEventTypeEnum.
};
return _flowEvents
.Where(x => x.SwapTradeId == tradeId && x.PositionId == positionId
&& swapEventTypes.Contains(x.EventType)
&& x.DataState == (int)SwapFlowDateStateEnum.
&& x.EventDate < beforeDate)
.Sum(s => (decimal?)s.InterestAmount) ?? 0m;
}
}
#endregion
#region EOD
/// <summary>
/// EOD 结算路径 Stub:继承可测试化基类(纯内存,不连库),并把 CalcSwapInterests 指向
/// 带 FR007 Stub 的 StubSwapDealService,使 EOD 内部计息也走预置曲线
/// ExecuteClose 包装受保护的 SaveAutoEodWithCloseInterestPosition,返回持久化后的 eod_swap_position。
/// 端到端 EOD 服务:可测试化基类 + 全部收盘 seam override + 真实计息(CalcSwapInterests 走 RealSwapDealService)。
/// 从交易开始日逐日 SwapPositionCompose 构建 eod 链;平仓走 SaveAutoEodWithCloseInterestPosition(生产同一入口)
/// </summary>
private sealed class StubEodPositionService : TestableSwapEodPositionService
private sealed class E2EEodService : TestableSwapEodPositionService
{
private readonly trade _td;
private readonly List<swap_position> _positions;
private readonly List<trade_extend> _extends;
private readonly List<eod_swap_position> _eodPositions = new();
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public StubEodPositionService(IReadOnlyDictionary<DateTime, double> floatRates)
public E2EEodService(trade td, List<swap_position> positions, trade_extend extend,
IReadOnlyDictionary<DateTime, double> floatRates)
: base(nameof(SwapInterestScenario3And4FloatingTest))
{
_td = td; _positions = positions; _extends = new List<trade_extend> { extend };
_floatRates = floatRates;
}
// --- 收盘链 seam override(对齐 PrepaidPrincipalClosingChainTraceTest 的 proven 模式)---
protected override List<trade> FindActiveSwapTrades(DateTime settleDate, IEnumerable<int> clientIds) => new List<trade> { _td };
protected override List<swap_position> FindAllSwapPositions(List<int> tradeIds) => _positions;
protected override List<trade_extend> FindTradeExtends(List<int> tradeIds) => _extends;
protected override List<eod_swap> FindEodSwapsByDate(DateTime valueDate)
=> _eodPositions.Where(x => x.SwapTradeId == _td.id)
.Select(x => x.ValueDate).Distinct()
.Select(d => new eod_swap { SwapTradeId = _td.id, ValueDate = d }).ToList();
protected override List<swap_flow_event> FindFlowEvents(int swapTradeId, DateTime settleDate) => new List<swap_flow_event>();
protected override List<swap_flow_event> FindCompletedFlowEvents(List<int> tradeIds) => new List<swap_flow_event>();
protected override List<eod_swap_position> FindEodSwapPositions(int swapTradeId, DateTime preSettleDate)
=> _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList();
protected override List<swap_position> FindSwapPositions(int swapTradeId)
=> _positions.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial).ToList();
protected override underlying_manager GetUnderlyingData(string underlyingCode)
=> new underlying_manager { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" };
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
{ vobp = 0m; return 100m; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m;
protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { }
protected override void ExecuteInTransaction(Action action) => action();
protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List<int> eventTypes) { }
public override void ClearSwapPositions(trade td, DateTime valueDate, List<int> eventTypes, bool delAfter) { }
protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason)
{ return new swap_event { id = 1 }; }
public override DateTime? GetPreDealDate(int tradeId, DateTime settleDate, List<int> eventTypes)
=> _td.StartDate;
// 真实计息:走 RealSwapDealServiceFR007 stub + 真实 consumedInterest
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
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)
{
var svc = new StubSwapDealService(
new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates);
var svc = new RealSwapDealService(
new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, FlowEvents);
return svc.GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
}
/// <summary>
/// 包装生产 EOD 平仓结算入口。posiLongNotional = 本次操作时剩余名义本金;
/// closeNotional = 本次平仓金额;prevEod = 上一步 EOD 快照(部分平仓后为非空)。
/// 返回持久化后的 eod_swap_position,其 TdCloseInterest 即该步实际结算/返还的利息。
/// </summary>
public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate,
/// <summary>对指定日期做真实日终收盘(无平仓),构建/累积 eod 链。</summary>
public void RunDailyEod(DateTime settleDate)
{
var preSettleDate = settleDate.AddDays(-1);
SwapPositionCompose(settleDate, preSettleDate, null);
foreach (var eod in PersistedPositions.Where(x => x.SwapTradeId == _td.id))
{
if (!_eodPositions.Any(x => x.id == eod.id))
_eodPositions.Add(eod);
}
}
/// <summary>取某持仓截至 before 日的最新 eod 快照(用于喂给平仓作为 preEodPosition)。</summary>
public eod_swap_position LatestEodForPosition(long positionId, DateTime before)
=> _eodPositions
.Where(x => x.SwapTradeId == _td.id && x.PositionId == positionId && x.ValueDate < before)
.OrderByDescending(x => x.ValueDate)
.FirstOrDefault();
/// <summary>把平仓产生的 eod 快照并入 eod 链,供后续日递推。</summary>
public void RecordEod(eod_swap_position eod)
{
if (eod != null && !_eodPositions.Any(x => x.id == eod.id))
_eodPositions.Add(eod);
}
/// <summary>包装生产 EOD 平仓结算入口(与生产平仓页同一路径)。</summary>
public eod_swap_position ExecuteClose(swap_position position, DateTime valueDate,
decimal posiLongNotional, decimal posiShortNotional,
List<swap_flow_event> flowEvents, decimal closeNotional, eod_swap_position prevEod)
{
SaveAutoEodWithCloseInterestPosition(prevEod, null, position, td, valueDate, null,
SaveAutoEodWithCloseInterestPosition(prevEod, null, position, _td, valueDate, null,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional);
return PersistedPositions.LastOrDefault();
@@ -122,8 +203,6 @@ namespace UnitTestProject.Modules.SwapModule
private const int InterestRule_Pre = -1; // 前一营业日
private const decimal Notional = 303139117.8m;
// 适中断言:容差 0.01。Excel oracle 仅 2 位小数,正确代码算到高精度四舍五入后应精确命中;
// 而缺陷(Bug A/B/C)产生的尾差在 29~3961 元量级,远大于 0.01,仍会被断言抓住。
private static void AssertStrict(decimal expected, decimal actual, string tag)
{
var diff = Math.Abs(expected - actual);
@@ -131,10 +210,6 @@ namespace UnitTestProject.Modules.SwapModule
$"{tag}: Expected={expected}, Actual={actual}, Diff={expected - actual}");
}
/// <summary>
/// 调试输出:打印实际值 vs Excel oracle(含偏差),并附 EOD 快照的携带利息字段,
/// 便于与生产/Excel 逐项对比分析。仅输出,不影响断言结果。
/// </summary>
private static void DebugCompare(string tag, decimal oracle, decimal actual, eod_swap_position eod = null)
{
var diff = actual - oracle;
@@ -147,13 +222,12 @@ namespace UnitTestProject.Modules.SwapModule
Console.WriteLine(sb.ToString());
}
private StubEodPositionService _eod;
private IReadOnlyDictionary<DateTime, double> _floatRates;
private E2EEodService _eod;
[TestInitialize]
public void Init()
{
// FR007 曲线(基础数据FR007 sheet,按日期查表;生产代码内部已处理 当前/前一 营业日取率)
_floatRates = new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 1)] = 0.0142,
@@ -198,15 +272,13 @@ namespace UnitTestProject.Modules.SwapModule
[new DateTime(2026, 5, 27)] = 0.013639,
[new DateTime(2026, 5, 28)] = 0.0135,
};
_eod = new StubEodPositionService(_floatRates);
_eod = null;
}
#endregion
#region
// mode: "10"=算头不算尾, "11"=算头算尾
// startDate:加点(T+0)=2026-04-21,减点(T+1)=2026-04-22(来自 Excel 真源,影响整段持仓起算日)
private static trade CreateTrade(string interestCalcMode, int interestRule, DateTime startDate)
{
var extend = new trade_extend
@@ -247,7 +319,7 @@ namespace UnitTestProject.Modules.SwapModule
SwapTradeId = 1,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = interestMode, // Excel H列:加点=合约名义本金规模(2),减点多为标的期初全价(9)
InterestMode = interestMode,
InterestRateDefault = spread,
InterestPrincipalFix = Notional,
PosiStartDate = startDate,
@@ -263,22 +335,22 @@ namespace UnitTestProject.Modules.SwapModule
};
}
/// <summary>
/// 计算"盘中平仓流水"——与生产 EOD 内部 CalcSwapInterests 用完全一致的方式调用 GetInterests
/// 剩余名义本金作为 posiNotionalValue / posiLongNotional,平仓金额作为 closePosiNotionalValue
/// closePrecent 固定 1(比例体现在平仓金额上),orginPv = 剩余名义本金。
/// 返回的 flow_event 即该步实际结算的利息,作为 EOD 结算的 flowEvents 入参。
/// </summary>
/// <summary>从交易开始日逐日真实收盘(仅 accrual,无平仓),构建 eod 链。</summary>
private void RunDailyEodFromStart(E2EEodService svc, DateTime start, DateTime exclusiveEnd)
{
for (var d = start.Date; d < exclusiveEnd.Date; d = d.AddDays(1))
{
if (d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday) continue;
svc.RunDailyEod(d);
}
}
/// <summary>用生产同一计息入口计算平仓流水(真实 GetInterests,从 PosiStartDate 重放)。</summary>
private swap_flow_event CalcCloseFlow(trade td, swap_position position, DateTime valueDate,
List<eod_swap_position> prevEod, decimal remainingNotional, decimal closeNotional)
{
var svc = new StubSwapDealService(
new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates);
// posiNotionalValue/orginPv = closeNotional(而非 remainingNotional):
// 生产中子仓位以"平仓金额"为名义本金调用,closePrecent 固定 1。
// 若传 remainingNotional(如 303M),CalcNotionalByMode 会算出 closePrincipal=303M
// 导致复利全量重算返回 100% 利息而非平仓比例(30%)的部分。
// 到期日全平(valueDate == ExerciseDate)必须算尾,否则最后一天利息被跳过。
var svc = new RealSwapDealService(
new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, _eod.FlowEvents);
var isMaturity = valueDate == td.ExerciseDate;
var interests = svc.GetInterests(
td, td.trade_extend, valueDate, valueDate,
@@ -294,7 +366,6 @@ namespace UnitTestProject.Modules.SwapModule
#region 33 2026-05-11closePercent=1
// 参数:备注, 复利?, 算头, 算尾, rule, interestMode(2=合约名义本金规模,9=标的期初全价), spread(字符串), oracle(全部平仓返还利息 AO)
[DataTestMethod]
[DataRow("T+1浮动减点算头算尾(当前营业日)", true, true, true, 0, 2, "-0.021", "-124062.54")]
[DataRow("T+0浮动加点算头算尾(当前营业日)", true, true, true, 0, 2, "0.0025", "280303.16")]
@@ -316,16 +387,21 @@ namespace UnitTestProject.Modules.SwapModule
var mode = (calcFirst && calcLast) ? "11" : "10";
var type = compound ? InterestTypeEnum. : InterestTypeEnum.;
// 加点(spread>=0)=T+0 起算日 4/21;减点(spread<0)=T+1 起算日 4/22Excel 真源)
var startDate = spread >= 0 ? new DateTime(2026, 4, 21) : new DateTime(2026, 4, 22);
var td = CreateTrade(mode, rule, startDate);
var position = CreateFloatPosition(spread, rule, type, startDate, interestMode);
_eod = new E2EEodService(td, new List<swap_position> { position }, td.trade_extend, _floatRates);
// 从交易开始日(=TradeDate)逐日真实收盘,构建 eod 链(平仓日前一天为止)
RunDailyEodFromStart(_eod, td.TradeDate.Value, new DateTime(2026, 5, 11));
var prevEod = _eod.LatestEodForPosition(position.id, new DateTime(2026, 5, 11));
// 全平:真实 EOD 平仓结算(生产同一入口),consumedInterest 来自真实累积 flow event
var flow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11), new List<eod_swap_position>(), Notional, Notional);
var eod = _eod.ExecuteClose(position, new DateTime(2026, 5, 11),
0m, 0m, new List<swap_flow_event> { flow }, Notional, prevEod);
_eod.RecordEod(eod);
// 纯全平:无部分平仓,prevEod 为空;posiLongNotional=0(全平后无剩余)
var flow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11),
new List<eod_swap_position>(), Notional, Notional);
var eod = _eod.ExecuteClose(td, position, new DateTime(2026, 5, 11),
0m, 0m, new List<swap_flow_event> { flow }, Notional, null);
DebugCompare("场景3 " + note, oracle, eod.TdCloseInterest, eod);
AssertStrict(oracle, eod.TdCloseInterest, "场景3 " + note);
}
@@ -334,7 +410,6 @@ namespace UnitTestProject.Modules.SwapModule
#region 4(05-11,30%)(05-19)
// 参数:备注, 复利?, 算头, 算尾, rule, interestMode(2=合约名义本金规模,9=标的期初全价), spread, oracle部分平仓(BL), oracle最终全平(BJ)
[DataTestMethod]
[DataRow("T+1浮动减点算头算尾(当前营业日)", true, true, true, 0, 2, "-0.021", "-37218.76", "-124093.74")]
[DataRow("T+0浮动加点算头算尾(当前营业日)", true, true, true, 0, 2, "0.0025", "84090.95", "268428.73")]
@@ -360,29 +435,36 @@ namespace UnitTestProject.Modules.SwapModule
var startDate = spread >= 0 ? new DateTime(2026, 4, 21) : new DateTime(2026, 4, 22);
var td = CreateTrade(mode, rule, startDate);
var position = CreateFloatPosition(spread, rule, type, startDate, interestMode);
_eod = new E2EEodService(td, new List<swap_position> { position }, td.trade_extend, _floatRates);
// 生产写法:部分/全平均传"已缩放的子仓位本金"closePrecent 固定为 1
// 缩放完全体现在名义本金上(CalcNotionalByMode 的 mode2/9 用 posiNotional*closePrecent
// 这里 closePrecent=1,故 posiNotional 必须已是缩放后值,否则会算出整段利息)。
// 第一步:2026-05-11 部分平仓 30%(无前置 EOD 快照)
// 从交易开始日(=TradeDate)逐日真实收盘,到部分平仓日前一天
RunDailyEodFromStart(_eod, td.TradeDate.Value, new DateTime(2026, 5, 11));
var prevEodPartial = _eod.LatestEodForPosition(position.id, new DateTime(2026, 5, 11));
// 第一步:2026-05-11 部分平仓 30%(真实 EOD 平仓结算,produces 真实 flow event
var partialCloseNotional = Notional * 0.3m;
var partialFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11),
new List<eod_swap_position>(), partialCloseNotional, partialCloseNotional);
// 步骤1 EODposiLongNotional=212M(平仓后剩余70%), closeNational=91M(平仓30%)
// → oriPosiNotionalValue=303M, closePercent=0.3 → InterestIncomeSum != 0
var partialEod = _eod.ExecuteClose(td, position, new DateTime(2026, 5, 11),
Notional - partialCloseNotional, 0m, new List<swap_flow_event> { partialFlow }, partialCloseNotional, null);
var partialFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11), new List<eod_swap_position>(), partialCloseNotional, partialCloseNotional);
var partialEod = _eod.ExecuteClose(position, new DateTime(2026, 5, 11),
Notional - partialCloseNotional, 0m, new List<swap_flow_event> { partialFlow }, partialCloseNotional, prevEodPartial);
_eod.RecordEod(partialEod);
DebugCompare("场景4[部分] " + note, oraclePartial, partialEod.TdCloseInterest, partialEod);
AssertStrict(oraclePartial, partialEod.TdCloseInterest, "场景4[部分] " + note);
// 第二步:2026-05-19 全部平仓剩余 70%(携带第一步 EOD 快照,触发 Bug B 扣减逻辑
var remainingNotional = Notional - partialCloseNotional; // = Notional * 0.7
// 全平盘中重算:不传 partialEod(避免 CalcDailySimpleInterest 跳过 5/11 EOD 日)
// 不算尾时 partialEod 未结算 5/11 利息,全平需从头重算才能包含 5/11
var fullFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 19),
new List<eod_swap_position>(), remainingNotional, remainingNotional);
var fullEod = _eod.ExecuteClose(td, position, new DateTime(2026, 5, 19),
0m, 0m, new List<swap_flow_event> { fullFlow }, remainingNotional, partialEod);
// 部分平仓后,剩余名义本金缩减为 70%(真实代码路径更新持仓口径
var remainingNotional = Notional - partialCloseNotional;
position.InterestPrincipalFix = remainingNotional;
position.PosiNotionalValue = remainingNotional;
// 从部分平仓次日逐日真实收盘,到全部平仓日前一天
RunDailyEodFromStart(_eod, new DateTime(2026, 5, 12), new DateTime(2026, 5, 19));
var prevEodFull = _eod.LatestEodForPosition(position.id, new DateTime(2026, 5, 19));
// 第二步:2026-05-19 全部平仓剩余 70%consumedInterest 此时从真实累积的 flow event 读取,
// 真实扣除 5/11 部分平仓已结利息——绝无硬编码 0)
var fullFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 19), new List<eod_swap_position>(), remainingNotional, remainingNotional);
var fullEod = _eod.ExecuteClose(position, new DateTime(2026, 5, 19),
0m, 0m, new List<swap_flow_event> { fullFlow }, remainingNotional, prevEodFull);
_eod.RecordEod(fullEod);
DebugCompare("场景4[全平] " + note, oracleFinal, fullEod.TdCloseInterest, fullEod);
AssertStrict(oracleFinal, fullEod.TdCloseInterest, "场景4[全平] " + note);
}
@@ -27,6 +27,15 @@ namespace YLErp.Modules.SwapModule
/// <summary>捕获所有持久化的 eod 持仓(按调用顺序)</summary>
public List<eod_swap_position> PersistedPositions { get; } = new();
/// <summary>
/// 捕获所有持久化的互换流水事件(按调用顺序)。
/// 生产 PersistFlowEvent 写 DbContext.swap_flow_eventGetConsumedInterest 也读同一张表。
/// 测试不连库,这里把真实收盘产生的 swap_flow_event 收集起来,
/// 使 GetConsumedInterest 能像生产一样从"真实累积的流水"里算已结利息,
/// 而无需硬编码、无需连库。这是消除"return 0m"伪绿的关键。
/// </summary>
public List<swap_flow_event> FlowEvents { get; } = new();
/// <summary>SaveAllChanges 调用次数</summary>
public int SaveChangesCount { get; private set; }
@@ -49,6 +58,16 @@ namespace YLErp.Modules.SwapModule
PersistedPositions.Add(position);
}
/// <summary>
/// 捕获真实收盘产生的 swap_flow_event(生产写 DbContext.swap_flow_event)。
/// 与 PersistEodSwapPosition 同理,这里只收集不写库,供 GetConsumedInterest 真实计算。
/// </summary>
protected override void PersistFlowEvent(swap_flow_event flowEvent)
{
if (flowEvent.id == 0) flowEvent.id = _nextId++;
FlowEvents.Add(flowEvent);
}
protected override void SaveAllChanges()
{
SaveChangesCount++;