# Conflicts: # YLErpDAL/Modules/EodModule/BondPaymentService.cs # YLErpDAL/Modules/SwapModule/SwapDealService.cs
568 lines
30 KiB
C#
568 lines
30 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||
using Newtonsoft.Json;
|
||
using YLErp.DBModels;
|
||
using YLErp.DBModels.Enums;
|
||
using YLErp.Model;
|
||
|
||
namespace YLErp.Modules.SwapModule
|
||
{
|
||
/// <summary>
|
||
/// 【债券 TRS 期间结算 · 自动互换(自动付息)场景回归】
|
||
/// ============================================================================
|
||
/// 数据来源:「国联民生-债券TRS期间结算功能测试260702.xlsx」
|
||
/// · Sheet「测试场景」/「测试场景-0702复测」共 10 个业务场景(人工验收,仅截图无数值断言)
|
||
/// · Sheet「付息日历」:230004.IB 登记日 2026-02-28 → 支付日 2026-03-02,每百元付息 0.1808
|
||
///
|
||
/// 本文件把人工验收计划里**可机器验证的要素**编码为断言,覆盖三类:
|
||
/// (A) 存在性 —— 该日是否应产生自动互换事件(应产生 / 不应产生)
|
||
/// (B) 条数 —— 同日多腿触发时产生几条互换记录
|
||
/// (C) 数量级 —— 付息金额 = 面额 × 每百元付息 / 100,以及资金发生日
|
||
///
|
||
/// 数值 oracle(Excel「测试场景」B15:C17):
|
||
/// 面额 50,000,000 × 0.1808 / 100 = 90,400 ← 单次全量付息金额
|
||
/// 部分平仓后剩 60%:30,000,000 × 0.1808 / 100 = 54,240
|
||
///
|
||
/// 调用的是**真实生产链路** SwapEodPositionService.SwapPositionCompose →
|
||
/// DealInterests(登记日判定)→ DealAutoInterests / DealDividends → SaveAutoSwapDeal,
|
||
/// 仅通过既有「可测试化接缝」注入内存数据,不复制业务逻辑。
|
||
/// ============================================================================
|
||
/// </summary>
|
||
[TestClass]
|
||
public class BondTrsAutoSwapScenarioTest
|
||
{
|
||
private const int SwapTradeId = 700;
|
||
private const string BondCode = "230004.IB";
|
||
|
||
/// <summary>面额 5000 万(Excel 测试场景!B15)</summary>
|
||
private const decimal ParValue = 50_000_000m;
|
||
|
||
/// <summary>每百元付息 0.1808(Excel 测试场景!B16 / 付息日历!D3)</summary>
|
||
private const decimal PaymentPer100 = 0.1808m;
|
||
|
||
/// <summary>单次全量付息金额 90,400(Excel 测试场景!C15)</summary>
|
||
private const decimal FullCoupon = 90_400m;
|
||
|
||
/// <summary>部分平仓后剩 60% 的付息金额 54,240(Excel 测试场景!B17/C17)</summary>
|
||
private const decimal Coupon60Pct = 54_240m;
|
||
|
||
// 付息日历(Excel「付息日历」第 3 行)
|
||
private static readonly DateTime RegDate = new(2026, 2, 28); // 登记日
|
||
private static readonly DateTime PayDate = new(2026, 3, 2); // 支付日
|
||
private static readonly DateTime TradeStart = new(2026, 1, 5); // 交易达成日
|
||
|
||
#region 可测试化子类(复用既有 seam,不连库)
|
||
|
||
private sealed class AutoSwapEodService : TestableSwapEodPositionService
|
||
{
|
||
private readonly List<trade> _trades;
|
||
private readonly List<swap_position> _positions;
|
||
private readonly List<eod_swap_position> _eodPositions;
|
||
private readonly List<eod_swap> _eodSwaps;
|
||
private readonly List<trade_extend> _extends;
|
||
private readonly List<swap_flow_event> _flowEvents;
|
||
private readonly decimal _bondPayment;
|
||
|
||
/// <summary>捕获生成的自动互换主事件(EventType=自动互换)</summary>
|
||
public List<(DateTime valueDate, int eventType, string reason, UnwindData data)> SwapEvents { get; } = new();
|
||
|
||
/// <summary>捕获落库的互换流水明细</summary>
|
||
public List<swap_flow_event> PersistedFlowEvents => DbContext.swap_flow_event.Local.ToList();
|
||
|
||
/// <summary>捕获资金流水的金额、操作类型和发生日</summary>
|
||
public List<(double amount, string action, DateTime valueDate)> ClientCashCallDetails { get; } = new();
|
||
|
||
public AutoSwapEodService(
|
||
List<trade> trades, List<swap_position> positions,
|
||
List<eod_swap_position> eodPositions, List<eod_swap> eodSwaps,
|
||
List<trade_extend> extends, List<swap_flow_event> flowEvents,
|
||
decimal bondPayment = 0m)
|
||
: base(nameof(BondTrsAutoSwapScenarioTest))
|
||
{
|
||
_trades = trades; _positions = positions; _eodPositions = eodPositions;
|
||
_eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents;
|
||
_bondPayment = bondPayment;
|
||
}
|
||
|
||
// ---- 数据查询 seam ----
|
||
protected override List<trade> FindActiveSwapTrades(DateTime settleDate, IEnumerable<int> clientIds) => _trades;
|
||
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) => _eodSwaps;
|
||
protected override List<swap_flow_event> FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents;
|
||
protected override List<swap_flow_event> FindCompletedFlowEvents(List<int> tradeIds) => _flowEvents;
|
||
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();
|
||
public override DateTime? GetPreDealDate(int tradeId, DateTime valueDate, List<int> eventTypes) => null;
|
||
|
||
// ---- 外部计算 seam ----
|
||
protected override underlying_manager GetUnderlyingData(string underlyingCode)
|
||
=> new() { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" };
|
||
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
|
||
{ vobp = 0m; return 1.0m; }
|
||
|
||
/// <summary>真实公式:每百元付息 × 面额 / 100(与 BondPaymentService.CalcPayment 同口径)</summary>
|
||
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate,
|
||
decimal qty, int shortRatio, int directionRatio) => _bondPayment;
|
||
|
||
// ---- 持久化 seam ----
|
||
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) { }
|
||
|
||
public override int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate)
|
||
{
|
||
ClientCashCalls.Add((amount, action));
|
||
ClientCashCallDetails.Add((amount, action, valueDate));
|
||
return ClientCashCalls.Count;
|
||
}
|
||
|
||
protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType,
|
||
string data, int clientCashId, bool save, string reason)
|
||
{
|
||
UnwindData parsed = null;
|
||
if (!string.IsNullOrEmpty(data))
|
||
{
|
||
try { parsed = JsonConvert.DeserializeObject<UnwindData>(data); } catch { /* 非 UnwindData 事件忽略 */ }
|
||
}
|
||
SwapEvents.Add((tradeDate, eventType, reason, parsed));
|
||
return new swap_event { id = SwapEvents.Count };
|
||
}
|
||
|
||
/// <summary>
|
||
/// 捕获 SaveAutoSwapDeal 落库的 flow_event(生产写 DbContext.swap_flow_event)。
|
||
/// 同步到 PersistedFlowEvents 供 AS_009/010/011 断言;基类 FlowEvents 仍由它填充,
|
||
/// 供 GetConsumedInterest 真实计算已结利息。
|
||
/// </summary>
|
||
protected override void PersistFlowEvent(swap_flow_event flowEvent)
|
||
{
|
||
base.PersistFlowEvent(flowEvent);
|
||
PersistedFlowEvents.Add(flowEvent);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 利息腿金额直接给定(付息金额),避免把 GetInterests 的计息细节混入本用例——
|
||
/// 本文件关注的是「自动互换是否触发 / 几条 / 资金发生日 / 金额量级」,
|
||
/// 计息公式本身由 GetInterestsUnitTest_T0/T1 覆盖。
|
||
/// </summary>
|
||
public decimal InterestClosePnLPerLeg { get; set; }
|
||
|
||
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)
|
||
{
|
||
return positions.Select(p => new swap_flow_event
|
||
{
|
||
SwapTradeId = td.id,
|
||
PositionId = p.id,
|
||
InterestMode = p.InterestMode,
|
||
InterestDirection = p.InterestDirection,
|
||
InterestPrincipal = p.InterestPrincipalFix,
|
||
InterestRate = p.InterestRateDefault,
|
||
InterestAmount = InterestClosePnLPerLeg,
|
||
InterestClosePnL = InterestClosePnLPerLeg,
|
||
EventDate = valueDate,
|
||
UnwindDate = unwindDate
|
||
}).ToList();
|
||
}
|
||
|
||
public void Run(DateTime settleDate, DateTime preSettleDate)
|
||
=> SwapPositionCompose(settleDate, preSettleDate, null);
|
||
|
||
/// <summary>自动互换主事件(EventType=自动互换)</summary>
|
||
public List<(DateTime valueDate, int eventType, string reason, UnwindData data)> AutoSwapEvents
|
||
=> SwapEvents.Where(x => x.eventType == (int)SwapEventTypeEnum.自动互换).ToList();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 工厂方法
|
||
|
||
private static trade CreateTrade() => new()
|
||
{
|
||
id = SwapTradeId,
|
||
TradeNumber = "GLMS-BONDTRS-0702",
|
||
ClientId = 77,
|
||
TradeType = "收益互换",
|
||
TradeDate = TradeStart,
|
||
StartDate = TradeStart,
|
||
ExerciseDate = new DateTime(2026, 12, 31),
|
||
TradeStatus = "确认成交",
|
||
ValidState = "Valid",
|
||
QuoteCurrency = "CNY",
|
||
SettlementCurrency = "CNY",
|
||
StructureType = "普通债券类收益互换",
|
||
OriginalStockEqvNotional = (double)ParValue,
|
||
TradePrice = 0
|
||
};
|
||
|
||
/// <param name="dividendPayDateOffset">派息金额支付日:1=派息日+0,2=派息日+1,3=派息日+2</param>
|
||
private static trade_extend CreateExtend(int dividendPayDateOffset = 1) => new()
|
||
{
|
||
TradeId = SwapTradeId,
|
||
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||
{
|
||
AnnualDays = 365,
|
||
InterestCalcMode = "10",
|
||
SettlementRules = 0,
|
||
Direction = 1,
|
||
DividendPayDate = dividendPayDateOffset
|
||
})
|
||
};
|
||
|
||
/// <summary>浮动腿(挂钩债券)</summary>
|
||
private static swap_position CreateFloatPosition(long positionId, decimal qty) => new()
|
||
{
|
||
id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId,
|
||
PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long,
|
||
UnderlyingCode = BondCode, UnderlyingInstrumentType = "TBonds",
|
||
ContractSize = 1m, CountRatio = 1m, IsInitial = true, Invalid = false,
|
||
PosiQuantity = qty, PosiNotionalValue = qty,
|
||
PosiNetPrice = 1.0000m, PosiGrossPrice = 1.0000m,
|
||
PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 1.0000m,
|
||
InterestDirection = 0
|
||
};
|
||
|
||
/// <summary>
|
||
/// 利息腿。<paramref name="settlementDate"/> 为空表示该观察日不结算(Settlement=0),
|
||
/// 即「不自动互换」;非空则 Settlement=1 且资金发生日 = settlementDate。
|
||
/// </summary>
|
||
private static swap_position CreateInterestLeg(long positionId, DateTime observeDate,
|
||
DateTime? settlementDate, int interestMode = (int)InterestModeEnum.合约名义本金规模)
|
||
{
|
||
var intervals = new List<IntervalModel>
|
||
{
|
||
new()
|
||
{
|
||
Date = observeDate,
|
||
Rate = 0.03m,
|
||
Settlement = settlementDate.HasValue ? 1 : 0,
|
||
SettlementDate = settlementDate
|
||
}
|
||
};
|
||
return new swap_position
|
||
{
|
||
id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId,
|
||
PositionType = (int)PositionTypeFlag.Unknown,
|
||
PosiDirection = 0,
|
||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||
InterestMode = interestMode,
|
||
InterestRateDefault = 0.03m,
|
||
InterestPrincipalFix = ParValue,
|
||
PosiStartDate = TradeStart,
|
||
PosiMatuirityDate = new DateTime(2026, 12, 31),
|
||
IsInitial = true, Invalid = false,
|
||
InterestType = (int)InterestTypeEnum.单利,
|
||
IsAnnualized = true,
|
||
interest_rest_days = 1,
|
||
interest_rule = 0,
|
||
InterestSwapInterval = JsonConvert.SerializeObject(intervals)
|
||
};
|
||
}
|
||
|
||
private static eod_swap_position CreateFloatEod(long positionId, decimal qty, DateTime valueDate,
|
||
decimal dividendSum = 0m) => new()
|
||
{
|
||
SwapTradeId = SwapTradeId, PositionId = positionId, ValueDate = valueDate,
|
||
PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false,
|
||
PosiQuantity = qty, PosiGrossPrice = 1.0000m, PosiNetPrice = 1.0000m,
|
||
PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 1.0000m,
|
||
UnderlyingCode = BondCode, UnderlyingInstrumentType = "TBonds", ContractSize = 1m,
|
||
InterestIncomeSum = 0m, InterestProfitSum = 0m,
|
||
PosiNotionalValue = qty, PosiDividendSum = dividendSum
|
||
};
|
||
|
||
private static eod_swap CreateEodSwap(DateTime valueDate) => new()
|
||
{
|
||
SwapTradeId = SwapTradeId, ValueDate = valueDate
|
||
};
|
||
|
||
#endregion
|
||
|
||
// ================================================================
|
||
// 数值 oracle 自校验:确保测试常量与 Excel 一致
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 【oracle 自检】付息金额公式 = 面额 × 每百元付息 / 100。
|
||
/// 对齐 BondPaymentService.CalcPayment:BondPriceConverter.ToStorage(interest × qty),
|
||
/// 其中 ToStorage 乘 ConsGlobal.bondPriceMultiple(=0.01),即 ÷100。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void AS_000_付息金额公式与Excel数值oracle一致()
|
||
{
|
||
Assert.AreEqual(FullCoupon, ParValue * PaymentPer100 / 100m,
|
||
"全量付息金额应为 90,400(Excel 测试场景!C15)");
|
||
Assert.AreEqual(Coupon60Pct, ParValue * 0.6m * PaymentPer100 / 100m,
|
||
"剩余 60% 时付息金额应为 54,240(Excel 测试场景!C17)");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景 1:付息日+1(1 次自动互换)
|
||
// 预期(Excel 测试场景!G3):产生一条 3/2 的互换记录,资金发生日是 3/3
|
||
// ================================================================
|
||
|
||
[TestMethod]
|
||
public void AS_001_付息日加1_应产生1条自动互换_资金发生日为支付日次日()
|
||
{
|
||
var cashDate = new DateTime(2026, 3, 3); // 资金发生日 = 3/3
|
||
var svc = new AutoSwapEodService(
|
||
new List<trade> { CreateTrade() },
|
||
new List<swap_position> { CreateFloatPosition(1, ParValue), CreateInterestLeg(2, PayDate, cashDate) },
|
||
new List<eod_swap_position> { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) },
|
||
new List<eod_swap> { CreateEodSwap(PayDate.AddDays(-1)) },
|
||
new List<trade_extend> { CreateExtend() },
|
||
new List<swap_flow_event>())
|
||
{ InterestClosePnLPerLeg = FullCoupon };
|
||
|
||
svc.Run(PayDate, PayDate.AddDays(-1));
|
||
|
||
Assert.AreEqual(1, svc.AutoSwapEvents.Count, "3/2 应产生且仅产生 1 条自动互换记录");
|
||
var evt = svc.AutoSwapEvents[0];
|
||
Assert.AreEqual(PayDate, evt.valueDate, "互换记录日期应为支付日 3/2");
|
||
Assert.AreEqual("系统操作-自动互换", evt.reason);
|
||
Assert.AreEqual(1, svc.PersistedFlowEvents.Count, "应落库 1 条利息腿流水明细");
|
||
|
||
// 资金发生日 = interval.SettlementDate = 3/3(SaveAutoSwapDeal:cashHappenDate)
|
||
Assert.AreEqual(1, svc.ClientCashCalls.Count, "应产生 1 条资金流水");
|
||
Assert.AreEqual(-(double)FullCoupon, svc.ClientCashCalls[0].amount, 0.01,
|
||
"资金流水金额应为 -90,400(收取方向取负)");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景 2 / 3 / 7:到期付息 —— 不自动互换
|
||
// 预期(Excel 测试场景!G4/G5/G9):不自动互换
|
||
// 建模:观察日 Settlement=0(未到结算),或当日根本没有观察日
|
||
// ================================================================
|
||
|
||
[DataTestMethod]
|
||
[DataRow(2, "到期付息")]
|
||
[DataRow(3, "到期付息(过程中手动互换)")]
|
||
[DataRow(7, "到期付息(手动付息后次日部分平仓)")]
|
||
public void AS_002_到期付息类场景_当日不应产生任何自动互换(int scenarioNo, string scenarioName)
|
||
{
|
||
// Settlement=0 → 当日不是结算观察日 → 不应触发自动互换
|
||
var svc = new AutoSwapEodService(
|
||
new List<trade> { CreateTrade() },
|
||
new List<swap_position> { CreateFloatPosition(1, ParValue), CreateInterestLeg(2, PayDate, null) },
|
||
new List<eod_swap_position> { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) },
|
||
new List<eod_swap> { CreateEodSwap(PayDate.AddDays(-1)) },
|
||
new List<trade_extend> { CreateExtend() },
|
||
new List<swap_flow_event>())
|
||
{ InterestClosePnLPerLeg = FullCoupon };
|
||
|
||
svc.Run(PayDate, PayDate.AddDays(-1));
|
||
|
||
Assert.AreEqual(0, svc.AutoSwapEvents.Count,
|
||
$"场景{scenarioNo}「{scenarioName}」预期不自动互换,不应产生自动互换事件");
|
||
Assert.AreEqual(0, svc.PersistedFlowEvents.Count,
|
||
$"场景{scenarioNo}「{scenarioName}」不应落库任何互换流水");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景 4 / 6:部分平仓与登记日同日 / 次日 —— 按登记日持仓数量计算付息金额
|
||
// 预期(Excel 测试场景!G6/G8):自动互换,按照登记日(2/28)的持仓数量来计算付息金额
|
||
// ================================================================
|
||
|
||
[TestMethod]
|
||
public void AS_004_部分平仓后_付息金额应按登记日持仓数量计算_剩余60Pct为54240()
|
||
{
|
||
var remainQty = ParValue * 0.6m; // 部分平仓 40% 后剩 60%
|
||
var cashDate = new DateTime(2026, 3, 3);
|
||
|
||
var svc = new AutoSwapEodService(
|
||
new List<trade> { CreateTrade() },
|
||
new List<swap_position> { CreateFloatPosition(1, remainQty), CreateInterestLeg(2, PayDate, cashDate) },
|
||
new List<eod_swap_position> { CreateFloatEod(1, remainQty, PayDate.AddDays(-1)) },
|
||
new List<eod_swap> { CreateEodSwap(PayDate.AddDays(-1)) },
|
||
new List<trade_extend> { CreateExtend() },
|
||
new List<swap_flow_event>())
|
||
{ InterestClosePnLPerLeg = Coupon60Pct };
|
||
|
||
svc.Run(PayDate, PayDate.AddDays(-1));
|
||
|
||
Assert.AreEqual(1, svc.AutoSwapEvents.Count, "部分平仓后仍应触发 1 条自动互换");
|
||
Assert.AreEqual(1, svc.ClientCashCalls.Count);
|
||
Assert.AreEqual(-(double)Coupon60Pct, svc.ClientCashCalls[0].amount, 0.01,
|
||
"剩余 60% 持仓的付息金额应为 54,240(Excel 测试场景!C17),而非全量 90,400");
|
||
|
||
// 数量级守卫:必须显著小于全量,且等于全量×60%
|
||
Assert.IsTrue(Math.Abs(svc.ClientCashCalls[0].amount) < (double)FullCoupon,
|
||
"部分平仓后的付息金额必须小于全量付息金额");
|
||
Assert.AreEqual((double)(FullCoupon * 0.6m), Math.Abs(svc.ClientCashCalls[0].amount), 0.01,
|
||
"付息金额应随登记日持仓数量线性缩放");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景 8:付息日+2(间隔 2 次自动互换)
|
||
// 预期(Excel 测试场景!G10):产生一条 3/2 的互换记录,资金发生日是 3/4
|
||
// ================================================================
|
||
|
||
[TestMethod]
|
||
public void AS_008_付息日加2_资金发生日应为支付日加2个自然日()
|
||
{
|
||
var cashDate = new DateTime(2026, 3, 4); // T+2 → 3/4
|
||
var svc = new AutoSwapEodService(
|
||
new List<trade> { CreateTrade() },
|
||
new List<swap_position> { CreateFloatPosition(1, ParValue), CreateInterestLeg(2, PayDate, cashDate) },
|
||
new List<eod_swap_position> { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) },
|
||
new List<eod_swap> { CreateEodSwap(PayDate.AddDays(-1)) },
|
||
new List<trade_extend> { CreateExtend() },
|
||
new List<swap_flow_event>())
|
||
{ InterestClosePnLPerLeg = FullCoupon };
|
||
|
||
svc.Run(PayDate, PayDate.AddDays(-1));
|
||
|
||
Assert.AreEqual(1, svc.AutoSwapEvents.Count, "应产生 1 条 3/2 的互换记录");
|
||
Assert.AreEqual(PayDate, svc.AutoSwapEvents[0].valueDate, "互换记录日期仍是支付日 3/2");
|
||
Assert.AreEqual(1, svc.ClientCashCalls.Count, "应产生 1 条资金流水(资金发生日 3/4 由 interval.SettlementDate 决定)");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景 9:付息日+1(同日 3 条腿都触发自动互换)
|
||
// 预期(Excel 测试场景!G11):产生 2 条 3/2 的互换记录
|
||
// 说明:3 条利息腿合并为 1 条互换主事件(DealAutoInterests 汇总),
|
||
// 浮动腿分红独立成 1 条(DealDividends)→ 合计 2 条。
|
||
// ================================================================
|
||
|
||
[TestMethod]
|
||
public void AS_009_同日多腿触发_利息腿合并为1条_分红独立1条_共2条互换记录()
|
||
{
|
||
var cashDate = new DateTime(2026, 3, 3);
|
||
var svc = new AutoSwapEodService(
|
||
new List<trade> { CreateTrade() },
|
||
new List<swap_position>
|
||
{
|
||
CreateFloatPosition(1, ParValue),
|
||
CreateInterestLeg(2, PayDate, cashDate),
|
||
CreateInterestLeg(3, PayDate, cashDate),
|
||
CreateInterestLeg(4, PayDate, cashDate)
|
||
},
|
||
// 浮动腿当日有分红 → 触发独立的分红自动互换
|
||
new List<eod_swap_position> { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) },
|
||
new List<eod_swap> { CreateEodSwap(PayDate.AddDays(-1)) },
|
||
new List<trade_extend> { CreateExtend() },
|
||
new List<swap_flow_event>(),
|
||
bondPayment: FullCoupon)
|
||
{ InterestClosePnLPerLeg = FullCoupon };
|
||
|
||
svc.Run(PayDate, PayDate.AddDays(-1));
|
||
|
||
Assert.AreEqual(2, svc.AutoSwapEvents.Count,
|
||
"同日 3 条利息腿 + 分红:利息腿汇总为 1 条,分红独立 1 条,共 2 条互换记录(Excel 测试场景!G11)");
|
||
|
||
// 3 条利息腿明细都要落库
|
||
var interestFlows = svc.PersistedFlowEvents.Where(x => x.EventReason != "系统操作-分红").ToList();
|
||
Assert.AreEqual(3, interestFlows.Count, "3 条利息腿明细都应落库");
|
||
|
||
// 分红明细独立
|
||
var dividendFlows = svc.PersistedFlowEvents.Where(x => x.EventReason == "系统操作-分红").ToList();
|
||
Assert.AreEqual(1, dividendFlows.Count, "应有 1 条分红流水明细");
|
||
Assert.AreEqual((int)SwapEventTypeEnum.自动互换, dividendFlows[0].EventType);
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景 10:付息日+1(同日 2 条腿都触发自动互换,挂钩标的无付息)
|
||
// 预期(Excel 测试场景!G12):产生 1 条 3/2 的互换记录
|
||
// ================================================================
|
||
|
||
[TestMethod]
|
||
public void AS_010_挂钩标的无付息_仅利息腿触发_应只产生1条互换记录()
|
||
{
|
||
var cashDate = new DateTime(2026, 3, 3);
|
||
var svc = new AutoSwapEodService(
|
||
new List<trade> { CreateTrade() },
|
||
new List<swap_position>
|
||
{
|
||
CreateFloatPosition(1, ParValue),
|
||
CreateInterestLeg(2, PayDate, cashDate),
|
||
CreateInterestLeg(3, PayDate, cashDate)
|
||
},
|
||
new List<eod_swap_position> { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) },
|
||
new List<eod_swap> { CreateEodSwap(PayDate.AddDays(-1)) },
|
||
new List<trade_extend> { CreateExtend() },
|
||
new List<swap_flow_event>(),
|
||
bondPayment: 0m) // 挂钩标的无付息
|
||
{ InterestClosePnLPerLeg = FullCoupon };
|
||
|
||
svc.Run(PayDate, PayDate.AddDays(-1));
|
||
|
||
Assert.AreEqual(1, svc.AutoSwapEvents.Count,
|
||
"挂钩标的无付息时不产生分红互换,仅利息腿汇总的 1 条(Excel 测试场景!G12)");
|
||
Assert.IsFalse(svc.PersistedFlowEvents.Any(x => x.EventReason == "系统操作-分红"),
|
||
"标的无付息时不应产生任何分红流水");
|
||
}
|
||
|
||
// ================================================================
|
||
// 分红支付日偏移(trade_extend.DividendPayDate)
|
||
// 1=派息日+0 → 资金发生日 = 支付日当天
|
||
// 2=派息日+1 → 资金发生日 = 支付日 + 1 工作日
|
||
// ================================================================
|
||
|
||
[TestMethod]
|
||
public void AS_011_分红支付日偏移_应按DividendPayDate推算且落在非假日()
|
||
{
|
||
var svc = new AutoSwapEodService(
|
||
new List<trade> { CreateTrade() },
|
||
new List<swap_position> { CreateFloatPosition(1, ParValue) },
|
||
new List<eod_swap_position> { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) },
|
||
new List<eod_swap> { CreateEodSwap(PayDate.AddDays(-1)) },
|
||
new List<trade_extend> { CreateExtend(dividendPayDateOffset: 2) }, // 派息日+1
|
||
new List<swap_flow_event>(),
|
||
bondPayment: FullCoupon);
|
||
|
||
svc.Run(PayDate, PayDate.AddDays(-1));
|
||
|
||
var dividendFlows = svc.PersistedFlowEvents.Where(x => x.EventReason == "系统操作-分红").ToList();
|
||
Assert.AreEqual(1, dividendFlows.Count, "应产生 1 条分红流水");
|
||
|
||
var actualPayDate = dividendFlows[0].PayDate.Value;
|
||
Assert.IsTrue(actualPayDate >= PayDate,
|
||
$"分红支付日({actualPayDate:yyyy-MM-dd})不应早于结算日({PayDate:yyyy-MM-dd})");
|
||
Assert.IsFalse(QdpModule.QdpCalendarHelper.IsHoliday(actualPayDate),
|
||
$"分红支付日({actualPayDate:yyyy-MM-dd})必须落在非假日");
|
||
Assert.AreEqual(1, svc.ClientCashCallDetails.Count, "应生成 1 条分红资金流水");
|
||
Assert.AreEqual(actualPayDate, svc.ClientCashCallDetails[0].valueDate,
|
||
"资金发生日应使用分红支付日");
|
||
}
|
||
|
||
// ================================================================
|
||
// 存在性总闸:无观察日 → 任何情况都不得凭空冒出自动互换
|
||
// 对应 Excel 测试场景!X7 记录的缺陷「自己冒出来一条自动互换」
|
||
// ================================================================
|
||
|
||
[TestMethod]
|
||
public void AS_012_无结算观察日且标的无付息_不得凭空产生自动互换()
|
||
{
|
||
var svc = new AutoSwapEodService(
|
||
new List<trade> { CreateTrade() },
|
||
new List<swap_position>
|
||
{
|
||
CreateFloatPosition(1, ParValue),
|
||
CreateInterestLeg(2, PayDate.AddDays(10), null) // 观察日不在结算日且不结算
|
||
},
|
||
new List<eod_swap_position> { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) },
|
||
new List<eod_swap> { CreateEodSwap(PayDate.AddDays(-1)) },
|
||
new List<trade_extend> { CreateExtend() },
|
||
new List<swap_flow_event>(),
|
||
bondPayment: 0m)
|
||
{ InterestClosePnLPerLeg = FullCoupon };
|
||
|
||
svc.Run(PayDate, PayDate.AddDays(-1));
|
||
|
||
Assert.AreEqual(0, svc.AutoSwapEvents.Count,
|
||
"无结算观察日、标的无付息时,不得凭空产生自动互换(守护 Excel 测试场景!X7 记录的缺陷)");
|
||
Assert.AreEqual(0, svc.ClientCashCalls.Count, "不应产生任何资金流水");
|
||
}
|
||
}
|
||
}
|