Files
zszq-trs/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs
T
hjhan 9efaa1b5cc chore(swap)+test: 删分红EOD历史死代码 + 补计算过程常驻日志(零行为语义变更);契约修复转为暂缓
死代码删除(SwapEodPositionService.UpdateEodPosition,论证+实证链完整):
- todayConsumedDividend/originNotional/totalPayment/totalInterest 自 0910969e(7/2 改递推式)
  起计算结果从未被消费,仅残留全历史 CalcBondPayment 只读查询+日志副作用,且构成
  脏数据(OriginalStockEqvNotional=null/PosiNetPrice=0)下 EOD 崩溃点(除零,实测复现)
- 配套边界测试 DividendEodNoDoubleCountTest.脏数据边界_*:删除前红(崩溃)→删除后绿
  (不抛异常+四输出字段与正常数据逐字段一致);stash 基线对比确认离线套件无其他差异
- 止血预案:git revert 本提交即回到已验证态

计算过程常驻日志(快速定位,纯增):
- [EOD平仓后收盘结息]:口径/autoSwap/平仓前/剩余/平掉额——排障第一入口
- [分红-EOD计提Copy/Update]:窗口/qty/税率/当日新计/账面前值→后值——与
  [分红-登记日口径]窗口命中日志构成分红计算全链
- [利息-全平专属分支]:全平分支触发标记,兼作契约修复落地后的验证哨兵

契约修复(EOD传真实比例)转为暂缓:影响快照种子,黄金回放验收门因96库网络未恢复
无法执行,按风险优先级撤回行为变更;方案+回归网(同形状等价oracle)+落地哨兵已
全部就绪并留档于裁决文档,DB恢复后可直接落地。
2026-08-16 15:47:09 +08:00

471 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Newtonsoft.Json;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GetInterests 双显式入口语义字符化测试(Step3"特判降级"的前置钉子)。
///
/// 背景:GetIntradayUnwindInterests(盘中:平仓前剩余×实际比例)与
/// CalcEodPostCloseSettleInterestsEOD平仓后收盘:平仓后剩余×恒1)是同一经济事件
/// (部分平仓)的两套传参语义,靠 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底粘合。
/// 本测试钉死当前行为,使后续特判降级/语义重构有回归网:
/// ① 复利×mode2closePrincipal(特判产物)是 CalcDailyCompoundInterest 的重放本金——
/// 两入口 closePosiNotionalValue 均为实际平掉额 → InterestAmount 必须相等;
/// ② 单利×mode2CalcDailySimpleInterest 消费的是 posiPrincipal×closePercent——
/// 盘中(平仓前×比例) vs EOD(剩余×1) 数值口径可能不同,本测试【记录现状】(见各断言注释);
/// ③ mode9 全平(posi=0):兜底覆盖生效,结息额非零。
///
/// 数据基建复用 GetInterestsUnitTest_T0 的构建器口径(T+04/27起息,"11"算头算尾)。
/// </summary>
[TestClass]
public class GetInterestsEntrySemanticsTest
{
private const decimal Principal = 1000m;
private const decimal FixedRate = 0.01m;
private const decimal FloatRate = 0.001m;
private const int AnnualDays = 365;
private const int ResetPeriod = 3;
private static readonly DateTime TradeDate = new(2026, 4, 27);
private static readonly DateTime StartDate = new(2026, 4, 27);
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
private static readonly DateTime UnwindDate = new(2026, 4, 30);
// 平仓前剩余 1000,平掉 30%(300),收盘后剩余 700
private const decimal PreClose = 1000m;
private const decimal Closed = 300m;
private const decimal Remaining = 700m;
private const decimal ClosePercent = 0.3m;
#region Stub(浮动利率内存取价,与 T0 同款)
private sealed class StubSwapDealService : SwapDealService
{
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary<DateTime, double> floatRates) : base(optUser)
{
_floatRates = floatRates;
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; }
if (_floatRates.TryGetValue(valueDate.Date, out rate)) return true;
rate = 0;
return false;
}
// 离线自洽:本测试场景无历史已结利息,等价于此前"空库查询返回 0"的行为,
// 使复利路径(GetConsumedInterest)不再依赖数据库连通(YLErp_UNIT_TEST_SKIP_INITIALIZATION=1 可跑)。
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
=> 0m;
}
private static SwapDealService CreateService() => new StubSwapDealService(
new OptUserInfo(0, nameof(GetInterestsEntrySemanticsTest), OptUserFrom.UnitTest),
new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 27)] = (double)FloatRate,
[new DateTime(2026, 4, 28)] = (double)FloatRate,
[new DateTime(2026, 4, 29)] = (double)FloatRate,
[new DateTime(2026, 4, 30)] = (double)FloatRate,
});
#endregion
#region 数据构建(T0 口径)
private static trade CreateTrade()
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11", // 算头算尾
SettlementRules = 0
})
};
return new trade
{
id = 1, TradeNumber = "UT-INT-ENTRY-SEMANTICS", ClientId = 999998,
TradeType = "收益互换", TradeDate = TradeDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
trade_extend = extend
};
}
private static swap_position CreatePosition(InterestModeEnum mode, InterestTypeEnum interestType, bool floating = false)
{
var intervalModels = new List<IntervalModel>
{
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
};
return new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)mode,
InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)interestType,
IsAnnualized = true, interest_rest_days = ResetPeriod, interest_rule = 0,
FloatRateUnderlyingCode = floating ? "FR007" : null,
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
};
}
private static eod_swap_position CreatePreEod(decimal interestSum, decimal principal)
=> new()
{
id = 1, SwapTradeId = 1, PositionId = 1001, ValueDate = new DateTime(2026, 4, 29),
ClientId = 999998, FloatRate = FloatRate, TdInterestPrincipal = principal,
PosiNotionalValue = principal, InterestIncomeSum = interestSum, InterestProfitSum = interestSum
};
#endregion
/// <summary>
/// 复利×mode2×部分平仓30%:【同请求形状⇒同额】oracle(契约目标语义,修复落地时的现成回归网)。
///
/// 修复前(b01b485e 钉住的分歧):盘中 0.036165(平掉额全程重放=确认书公式)vs
/// EOD 0.059042(恒1 掉进全平专属分支,全腿待实现+末段增量,无契约依据,重算结果被丢弃)。
/// 修复(契约修复§六):EOD 普通当日平仓重算(autoSwap=false)改传 Intraday 形状
/// (平仓前剩余+实际平掉额+真实比例),部分平仓不再进 closePrecent==1 分支。
/// 依据:项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md(契约公式唯一确定应结=平掉额×全程参考利率)。
/// 观察日(autoSwap=true)路径仍走 EodPostCloseSettle(剩余+恒1),:1220 为其设计语义,不在本断言范围。
/// </summary>
[TestMethod]
public void 复利_mode2_部分平仓_双入口契约口径一致()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.复利, floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
// 契约目标形状(修复暂缓中,生产仍传 剩余+恒1):与盘中一致(平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3)
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[复利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
// 契约 oracle:两入口同请求形状必须同额(=确认书公式"平掉额×全程参考利率")
Assert.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m,
"GetInterests 层契约目标:同请求形状必须同额(生产入口修复暂缓中,本断言为落地时的现成回归网)");
// 手算锚点(300×[(1+0.011×3/365)×(1+0.011×1/365)1],与裁决文档§二玩具参数一致)
Assert.AreEqual(0.036165m, Math.Round(intraday[0].InterestAmount, 6, MidpointRounding.AwayFromZero),
"盘中重放=契约公式手算锚点 0.036165");
}
/// <summary>
/// 【回归钉子】复利×mode2×部分平仓:观察日路径(EodPostCloseSettle 剩余+恒1)保持设计语义不回退。
/// 修复只改 autoSwap=false 分支;观察日恒1 全量结息是 :1220 分支的设计意图(结现),锁死其当前值。
/// </summary>
[TestMethod]
public void 复利_mode2_部分平仓_观察日恒1语义保持()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.复利, floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var observationDay = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Closed, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, observationDay.Count);
Assert.AreEqual(0.059041913305m, observationDay[0].InterestAmount, 0.000000001m,
"观察日(autoSwap=true)路径:剩余+恒1 的全平分支为其设计语义(结现),修复不得改变此值");
}
/// <summary>
/// 单利×mode2×部分平仓30%:记录两入口当前口径(快照×比例 vs 重放基数差异面)。
/// 单利消费 posiPrincipal×closePercent:盘中 1000×0.3 vs EOD 700×1 —— 若两值不等,
/// 这是当前系统的已知口径差异面(非断言失败项),数值以 Console 留档,供特判降级时对照。
/// </summary>
[TestMethod]
public void 单利_mode2_部分平仓_双入口口径留档()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
// 契约目标形状(修复暂缓中,生产仍传 剩余+恒1):与盘中一致(平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3)
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[单利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
Console.WriteLine($"[单利mode2] TdInterestAmount: 盘中={intraday[0].TdInterestAmount} / EOD={eodPostClose[0].TdInterestAmount}");
// 契约目标:两入口同请求形状必须同额(单利:平掉额基数 + 快照×比例链路一致)
Assert.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m,
"GetInterests 层契约目标:单利×mode2 同请求形状必须同额(生产入口修复暂缓中)");
Assert.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0");
}
/// <summary>
/// mode9 全平(契约目标形状:平仓前剩余=平掉额=1000、比例恒1):
/// 结息额非零且=全平语义(:1220 全平分支:待实现+末段增量,尾差一次带走——裁决§五.2 维持)。
/// </summary>
[TestMethod]
public void 复利_mode9_全平_兜底覆盖生效结息额非零()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.标的期初全价, InterestTypeEnum.复利, floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
// 全平:平仓前剩余=平掉=1000,比例恒1(全平专属分支)
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, PreClose, PreClose, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result.Count);
Console.WriteLine($"[复利mode9全平] InterestAmount={result[0].InterestAmount}");
Assert.IsTrue(result[0].InterestAmount != 0m,
"mode9 全平:结息本金=平掉额(1000),结息额非零(全平语义钉子)");
}
#region CalcEodPostCloseSettleInterests 接缝映射钉子
/// <summary>
/// 参数捕获 stub:拦下 CalcSwapInterests 的全部实参,不触库、不真算。
/// </summary>
private sealed class CalcSwapInterestsCapture : TestableSwapEodPositionService
{
public CalcSwapInterestsCapture() : base(nameof(GetInterestsEntrySemanticsTest)) { }
public List<swap_flow_event> CapturedCloseList = null;
public bool CapturedTdClose;
public int CapturedEventType;
public decimal CapturedPosiNotional;
public decimal CapturedClosePosiNotional;
public decimal CapturedClosePercent;
public decimal CapturedOrginPv;
public bool CapturedAdd;
public bool CapturedSettment;
public bool CapturedNewCalcLast;
public int CallCount;
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent,
int eventType, bool tdClose,
decimal orginPv,
bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
CallCount++;
CapturedTdClose = tdClose; CapturedEventType = eventType;
CapturedPosiNotional = posiNotionalValue; CapturedClosePosiNotional = closePosiNotionalValue;
CapturedClosePercent = closePrecent; CapturedOrginPv = orginPv;
CapturedAdd = add; CapturedSettment = settment; CapturedNewCalcLast = newCalcLast;
CapturedCloseList = closeList;
return new List<swap_flow_event>();
}
public List<swap_flow_event> ExposedEodPostCloseSettle(InterestCalcRequest req)
=> CalcEodPostCloseSettleInterests(req);
}
/// <summary>
/// 钉死 InterestCalcRequest.EodPostCloseSettle 工厂 → CalcEodPostCloseSettleInterests →
/// CalcSwapInterests 的位置参数转发契约。这段转发是位置传参最易错位的环节
/// posiNotionalValue/closePosiNotionalValue/orginPv 三个相邻同型 decimal,编译器不查错位),
/// 任何映射改动(含将来删 needPrice/grossPrice 死参数)都必须保持本断言绿。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_工厂到接缝_参数映射钉死()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var positions = new List<swap_position> { position };
var stub = new CalcSwapInterestsCapture();
var req = InterestCalcRequest.EodPostCloseSettle(
td, td.trade_extend, UnwindDate, UnwindDate,
new List<eod_swap_position> { preEod }, positions,
remainingNotionalAfterClose: Remaining,
closedNotional: Closed,
eventType: (int)SwapEventTypeEnum.平仓, tdClose: false,
orginPv: PreClose, add: true, newCalcLast: false);
stub.ExposedEodPostCloseSettle(req);
Assert.AreEqual(1, stub.CallCount, "默认实现应恰好调用一次 CalcSwapInterests(虚接缝兼容既有测试替身)");
Assert.AreEqual(Remaining, stub.CapturedPosiNotional, "posiNotionalValue 位 = 平仓后剩余(700)——语义核心,错位即红");
Assert.AreEqual(Closed, stub.CapturedClosePosiNotional, "closePosiNotionalValue 位 = 实际平掉额(300)");
Assert.AreEqual(1m, stub.CapturedClosePercent, "closePrecent 恒 1(全额结息)");
Assert.AreEqual((int)SwapEventTypeEnum.平仓, stub.CapturedEventType);
Assert.IsFalse(stub.CapturedTdClose);
Assert.AreEqual(PreClose, stub.CapturedOrginPv, "orginPv 位 = 上一日终本金——与相邻 decimal 最易错位处");
Assert.IsTrue(stub.CapturedAdd);
Assert.IsFalse(stub.CapturedSettment, "settment=false:走盘中重放算法(EOD平仓后收盘复用重放)");
Assert.IsFalse(stub.CapturedNewCalcLast);
Assert.IsNull(stub.CapturedCloseList, "该场景不传 closeList");
}
#endregion
#region 守恒不变量(§7-1, oracle/免库, EOD平仓后收盘×部分平仓 裸格)
// 守恒不变量统一断言在"剩余持仓前递"(preEod.PosiNotionalValue)上:该字段由 CalcUnwindInterest/
// InitSwapDealInterest 在 preEod.id==0 时写入(posiPrincipal),与利息算法(单/复、FR007)无关,
// 是最稳健、码算、免库的守恒观测点。期初(orginPv) = 前递剩余 + 平掉额(closePosiNotionalValue) 必须成立。
// 全部内存构造(StubSwapDealService 避库)funding-leg(mode2)不触发早路由 continue,故亦是早路由改动护栏。
/// <summary>
/// 建一个"无历史 eod"快照(id==0),使引擎把本次剩余持仓写入 preEod.PosiNotionalValue。
/// </summary>
private static eod_swap_position NewPreEod(decimal carryPrincipal)
=> new()
{
id = 0, SwapTradeId = 1, PositionId = 1001,
ValueDate = new DateTime(2026, 4, 29), ClientId = 999998,
FloatRate = 0m, TdInterestPrincipal = carryPrincipal,
PosiNotionalValue = carryPrincipal, InterestIncomeSum = 0.05m, InterestProfitSum = 0.05m
};
/// <summary>
/// §7-1 守恒①:EOD平仓后收盘×部分平仓,引擎把剩余持仓(700)前递进 preEod.PosiNotionalValue
/// 且 期初 = 前递剩余(码算) + 平掉额(输入) = 1000。
/// 守 2035e1df 裸格(§6 空洞1):若 EOD 入口把前递值误写成平掉额/期初,守恒等式即破。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_部分平仓_守恒_剩余前递且期初等于剩余加平掉额()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
var preEod = NewPreEod(Remaining); // 无历史 eod → 引擎写回剩余
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Closed, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result.Count, "EOD平仓后收盘部分平仓应产生 1 条利息事件");
// 码算:引擎把剩余持仓前递(return 700)
Assert.AreEqual(Remaining, preEod.PosiNotionalValue,
"EOD平仓后收盘必须把剩余持仓(700)前递进 preEod.PosiNotionalValue;若误写平掉额/期初则守恒破坏");
// 守恒:期初 = 前递剩余(码算) + 平掉额(输入)
Assert.AreEqual(PreClose, preEod.PosiNotionalValue + Closed,
"期初(orginPv=1000) 必须 = 剩余(700) + 平掉额(300);本金口径不守恒则利息算错");
}
/// <summary>
/// §7-1 守恒②:EOD平仓后收盘×全平,剩余持仓前递=0(清仓)。守全平非零边界的互补面。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_全平_守恒_剩余前递归零()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
var preEod = NewPreEod(0m);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, 0m, PreClose, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result.Count);
Assert.AreEqual(0m, preEod.PosiNotionalValue,
"全平后剩余持仓前递必须为 0;非 0 表示平仓未清仓,守恒破坏");
Assert.AreEqual(PreClose, preEod.PosiNotionalValue + PreClose,
"全平守恒:期初(1000) = 剩余(0) + 平掉额(1000)");
}
/// <summary>
/// §7-1 守恒③(逐日):两次部分平仓,Day2 剩余前递 = 当日剩余(码算),且 期初 - 前递剩余 = 平掉额,
/// 构成跨日携带链守恒。Day1 期初1000→平300剩700Day2 期初700→平210剩490;累计平掉510+剩余490=1000。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_两次部分平仓_逐日守恒_期初减剩余前递等于平掉额()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
// Day1:期初1000,平300,剩700
var preEod1 = NewPreEod(PreClose);
var result1 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
new List<eod_swap_position> { preEod1 }, new List<swap_position> { position },
Remaining, Closed, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result1.Count);
Assert.AreEqual(Remaining, preEod1.PosiNotionalValue, "Day1 剩余前递应为 700");
// Day2:期初=Day1剩余700,平210,剩490
const decimal day2OrginPv = 700m;
const decimal day2Closed = 210m;
const decimal day2Remaining = 490m;
var preEod2 = NewPreEod(day2OrginPv); // 承载=Day1剩余700
var result2 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
new List<eod_swap_position> { preEod2 }, new List<swap_position> { position },
day2Remaining, day2Closed, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: day2OrginPv,
add: false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result2.Count);
// 码算:Day2 剩余前递=当日剩余(490)
Assert.AreEqual(day2Remaining, preEod2.PosiNotionalValue, "Day2 剩余前递=剩余(490,码算值)");
// 逐日守恒:期初 - 剩余前递 = 平掉额(210)
Assert.AreEqual(day2Closed, day2OrginPv - preEod2.PosiNotionalValue,
"Day2 守恒:期初(700) - 剩余前递(490) 必须 = 平掉额(210);跨日携带链本金不守恒则利息算错");
}
/// <summary>
/// §7-1 守恒④(纯数学,ClosePercentMath):多次平仓累计占期初比例 = 1 - ∏(1 - 各次剩余口径)。
/// 初次占期初30%(平300/名义1000)→剩余口径0.3;二次占期初50%(平350/剩余700)→剩余口径0.5
/// 累计平掉 = 1 - 0.7×0.5 = 0.65。验证 ClosePercentMath 双口径换算在多次平仓下不漂移。
/// </summary>
[TestMethod]
public void 多次平仓_占期初累计比例等于各次剩余口径连乘补数()
{
var b1 = ClosePercentMath.ToRemainingClosePercent(0.3m, 1000m, 1000m);
Assert.AreEqual(0.3m, b1, "初次平仓占期初30% → 剩余口径应为 0.3");
var b2 = ClosePercentMath.ToRemainingClosePercent(0.5m, 700m, 700m);
Assert.AreEqual(0.5m, b2, "二次平仓占期初50%(占剩余700) → 剩余口径应为 0.5");
var cumulativeClosed = 1m - (1m - b1) * (1m - b2);
Assert.AreEqual(0.65m, cumulativeClosed, 0.0000001m,
"多次平仓累计平掉比例必须=各次剩余口径连乘的补数;否则本金口径在多次平仓下分裂");
var back = ClosePercentMath.ToOriginalClosePercent(cumulativeClosed, 1000m, 1000m);
Assert.AreEqual(0.65m, back, 0.0000001m, "累计占期初比例反向还原必须一致");
}
#endregion
}
}