Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs
张名锐 582cb2c7c5 Merge branch 'glms/feature/1.4.2' into glms/feature/0812_zmr_divPower
# Conflicts:
#	YLErpDAL/Modules/EodModule/BondPaymentService.cs
#	YLErpDAL/Modules/SwapModule/SwapDealService.cs
2026-08-20 16:21:13 +08:00

827 lines
42 KiB
C#
Raw Permalink 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 YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapPositionCompose 日终归档端到端测试
/// ============================================================================
/// 借鉴 testable 分支 SwapPositionComposeScenarioTest,基于当前分支 seam 重写。
/// 覆盖 DealFloatPositions 的首次归档/Copy/Update/异常路径。
/// 利息腿场景(自动互换)因 CalcSwapInterests 参数适配复杂留后续。
/// ============================================================================
[TestClass]
public class SwapPositionComposeScenarioTest
{
private const int SwapTradeId = 100;
private static readonly DateTime SettleDate = new(2025, 4, 24);
private static readonly DateTime PreSettleDate = new(2025, 4, 23);
#region 可测试化子类
/// <summary>
/// 继承 SwapEodPositionServiceoverride SwapPositionCompose 路径上的 seam。
/// 适配当前分支 seam 签名(GetUnderlyingPrice 带 out、GetCurrencyRate 返回 double 等)。
/// </summary>
private sealed class TestableSwapEodService : 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 _price;
private readonly decimal _vobp;
// 输出别名(转发到基类捕获属性)
public List<eod_swap_position> CreatedEodPositions => PersistedPositions;
public List<swap_position> LastInterestCalculationPositions { get; private set; }
public TestableSwapEodService(
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 price = 100m, decimal vobp = 0m)
: base(nameof(SwapPositionComposeScenarioTest))
{
_trades = trades; _positions = positions; _eodPositions = eodPositions;
_eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents;
_price = price; _vobp = vobp;
}
// SwapPositionCompose 路径 seam override
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;
public override DateTime? GetPreDealDate(int tradeId, DateTime valueDate, List<int> eventTypes) => null;
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();
// DealFloatPositions 路径 seam override
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 = _vobp; return _price; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m;
// 持久化/事务 seam overridePersistEodSwapPosition/SaveAllChanges/GetCurrencyRate/AddClientCash 由基类提供)
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 }; }
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)
{
LastInterestCalculationPositions = positions;
return base.CalcSwapInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
=> SwapPositionCompose(settleDate, preSettleDate, null);
public void ExecuteFundCorporateActions(
IReadOnlyCollection<eod_swap_position> positions,
IReadOnlyCollection<ex_dividend_info> dividendInfos)
{
ApplyCorporateActions(
positions,
dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase),
SettleDate);
}
}
#endregion
#region 工厂方法
private static trade CreateTrade(DateTime? startDate = null)
{
var date = startDate ?? SettleDate;
return new trade
{
id = SwapTradeId, TradeNumber = "TEST-COMPOSE-001", ClientId = 10,
TradeType = "收益互换", TradeDate = date, StartDate = date,
ExerciseDate = SettleDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
QuoteCurrency = "CNY", SettlementCurrency = "CNY", StructureType = "普通债券类收益互换",
OriginalStockEqvNotional = 100000, TradePrice = 0
};
}
private static trade_extend CreateExtend()
{
return new trade_extend
{
TradeId = SwapTradeId,
ExtendJson = @"{""NeedOpenFee"":false,""AnnualDays"":365,""SettlementRules"":0,""Direction"":1,""FlowBookMode"":0}"
};
}
private static swap_position CreateFloatPosition(long positionId, decimal qty)
{
return new swap_position
{
id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId,
PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = "220205.IB", UnderlyingInstrumentType = "TBonds",
ContractSize = 1m, CountRatio = 1m, IsInitial = true, Invalid = false,
PosiQuantity = qty, PosiNotionalValue = qty,
PosiNetPrice = 1.0050m, PosiGrossPrice = 1.0020m,
PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 0.9970m,
InterestDirection = 0
};
}
private static eod_swap_position CreateFloatEodPosition(long positionId, decimal qty, decimal grossPrice)
{
return new eod_swap_position
{
SwapTradeId = SwapTradeId, PositionId = positionId, ValueDate = PreSettleDate,
PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false,
PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m,
PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m,
UnderlyingCode = "220205.IB", UnderlyingPrice = grossPrice, ContractSize = 1m,
InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty
};
}
private static swap_flow_event CreateCloseFlowEvent(long positionId, decimal qty)
{
return new swap_flow_event
{
SwapTradeId = SwapTradeId, PositionId = positionId,
EventType = (int)SwapFlowEventTypeEnum.平仓,
Quantity = qty, EventDate = SettleDate, UnwindDate = SettleDate,
MarkClosePnl = 500m, CloseFee = 10m, DividendIn = 5m,
TradingAmountAvg = 1.0030m, DataState = (int)SwapFlowDateStateEnum.完成
};
}
private static ex_dividend_info CreateFundCorporateAction(
decimal cashAmount = 0m,
decimal shareAmount = 0m)
{
return new ex_dividend_info
{
UnderlyingCode = "FUND.TEST",
ExDividendDate = SettleDate,
EffectiveDate = SettleDate,
GiveCashAmount = cashAmount,
GiveShareAmount = shareAmount,
ValidStatus = true
};
}
private static void SetFundLeg(swap_position position, eod_swap_position previousEod)
{
position.UnderlyingCode = "FUND.TEST";
position.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund;
position.PosiGrossPrice = 100m;
position.PosiNetPrice = 102m;
position.PosiNetFeePrice = 104m;
position.PosiNetNoFeePrice = 106m;
previousEod.UnderlyingCode = position.UnderlyingCode;
previousEod.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
previousEod.PosiGrossPrice = position.PosiGrossPrice;
previousEod.PosiNetPrice = position.PosiNetPrice;
previousEod.PosiNetFeePrice = position.PosiNetFeePrice;
previousEod.PosiNetNoFeePrice = position.PosiNetNoFeePrice;
previousEod.PosiNotionalValue = previousEod.PosiGrossPrice
* previousEod.PosiQuantity
* previousEod.ContractSize;
}
#endregion
// ================================================================
// 场景1:首次归档(无前日eod,交易首日)
// ================================================================
[TestMethod]
public void SPC_001_首次归档_无前日Eod_直接取初始持仓()
{
var td = CreateTrade();
var extend = CreateExtend();
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
var service = new TestableSwapEodService(
new List<trade> { td }, positions,
new List<eod_swap_position>(), new List<eod_swap>(),
new List<trade_extend> { extend }, new List<swap_flow_event>());
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
Assert.IsTrue(service.CreatedEodPositions.Count >= 1, "应创建至少1条eod");
var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1);
Assert.IsNotNull(floatEod, "应创建浮动腿持仓");
Assert.AreEqual(1000m, floatEod.PosiQuantity, "首次归档 PosiQuantity=初始持仓数量");
Console.WriteLine($"SPC_001: PosiQuantity={floatEod.PosiQuantity} ✅");
}
// ================================================================
// 场景2:有前日eod无事件 → Copy
// ================================================================
[TestMethod]
public void SPC_002_Copy分支_有前日Eod无事件_价格原样复制()
{
var td = CreateTrade();
var extend = CreateExtend();
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
var prevEod = new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m) };
var service = new TestableSwapEodService(
new List<trade> { td }, positions,
prevEod, new List<eod_swap>(),
new List<trade_extend> { extend }, new List<swap_flow_event>());
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1);
Assert.IsNotNull(floatEod);
Assert.AreEqual(1000m, floatEod.PosiQuantity, "Copy分支 PosiQuantity不变");
Assert.AreEqual(1.0020m, floatEod.PosiGrossPrice, "Copy分支 PosiGrossPrice从前日eod复制");
Console.WriteLine($"SPC_002: PosiQuantity={floatEod.PosiQuantity}, PosiGrossPrice={floatEod.PosiGrossPrice} ✅");
}
// ================================================================
// 场景3:有平仓事件 → Update(持仓扣减)
// ================================================================
[TestMethod]
public void SPC_003_Update分支_有平仓事件_持仓扣减()
{
var td = CreateTrade();
var extend = CreateExtend();
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
var prevEod = new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m) };
var flowEvents = new List<swap_flow_event> { CreateCloseFlowEvent(1, 400) };
var service = new TestableSwapEodService(
new List<trade> { td }, positions,
prevEod, new List<eod_swap>(),
new List<trade_extend> { extend }, flowEvents);
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1);
Assert.IsNotNull(floatEod);
Assert.AreEqual(600m, floatEod.PosiQuantity, "Update分支 PosiQuantity=1000-400=600");
Assert.AreEqual(400m, floatEod.TdCloseQty, "TdCloseQty=平仓数量400");
Console.WriteLine($"SPC_003: PosiQuantity={floatEod.PosiQuantity}, TdCloseQty={floatEod.TdCloseQty} ✅");
}
[TestMethod]
public void SPC_FUND_001_送股除权_调整价格数量并重算持仓结果()
{
var td = CreateTrade();
var position = CreateFloatPosition(1, 1000m);
var previousEod = CreateFloatEodPosition(1, 1000m, 100m);
SetFundLeg(position, previousEod);
var service = new TestableSwapEodService(
new List<trade> { td },
new List<swap_position> { position },
new List<eod_swap_position> { previousEod },
new List<eod_swap>(),
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event>(),
price: 100m);
service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m));
var actual = previousEod.Clone();
actual.ValueDate = SettleDate;
actual.UnderlyingPrice = 100m;
service.ExecuteFundCorporateActions(
new[] { actual },
service.ExDividendInfos);
Assert.AreEqual(2000m, actual.PosiQuantity);
Assert.AreEqual(1000m, actual.TdChangedQty);
Assert.AreEqual(50m, actual.PosiGrossPrice);
Assert.AreEqual(51m, actual.PosiNetPrice);
Assert.AreEqual(52m, actual.PosiNetFeePrice);
Assert.AreEqual(53m, actual.PosiNetNoFeePrice);
Assert.AreEqual(100000m, actual.PosiNotionalValue);
Assert.AreEqual(200000m, actual.UnderlyingMarketValue);
Assert.AreEqual(100000m, actual.PosiMtmPnL);
Assert.AreEqual(100000m, actual.PosiProfitSum);
}
[TestMethod]
public void SPC_FUND_002_现金分红_登记日不直接入账()
{
var td = CreateTrade();
var position = CreateFloatPosition(1, 1000m);
var previousEod = CreateFloatEodPosition(1, 1000m, 100m);
SetFundLeg(position, previousEod);
var service = new TestableSwapEodService(
new List<trade> { td },
new List<swap_position> { position },
new List<eod_swap_position> { previousEod },
new List<eod_swap>(),
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event>(),
price: 100m);
service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 10m));
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
var actual = service.CreatedEodPositions.Single(x => x.PositionId == 1);
// 现金分红改由同步任务写入 bond_payment_info,并以 EffectiveDate 进入债券付息
// 链路;登记日 EOD 不直接读取 ex_dividend_info,因此此处不应提前产生现金。
Assert.AreEqual(1000m, actual.PosiQuantity);
Assert.AreEqual(0m, actual.TdChangedQty);
Assert.AreEqual(100m, actual.PosiGrossPrice);
Assert.AreEqual(0m, actual.TdPosiDividend);
Assert.AreEqual(0m, actual.PosiDividendSum);
Assert.AreEqual(100000m, actual.PosiNotionalValue);
Assert.AreEqual(0m, actual.PosiMtmPnL);
Assert.AreEqual(0m, actual.PosiProfitSum);
Assert.AreEqual(0m, actual.RealizedDividend);
Assert.AreEqual(0m, actual.RealizedPnl);
}
[TestMethod]
public void SPC_FUND_003_同日重跑_从前日基线重算不重复除权()
{
var td = CreateTrade();
var position = CreateFloatPosition(1, 1000m);
var previousEod = CreateFloatEodPosition(1, 1000m, 100m);
SetFundLeg(position, previousEod);
var service = new TestableSwapEodService(
new List<trade> { td },
new List<swap_position> { position },
new List<eod_swap_position> { previousEod },
new List<eod_swap>(),
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event>(),
price: 100m);
service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m));
// 生产重收盘每次都会从上一日 EOD clone 出新的当日基线,再应用一次公司行为;
// 底层 ApplyCorporateActions 只负责处理调用方提供的未调整基线,不再承担恢复旧基线的测试兼容职责。
var firstRunEod = previousEod.Clone();
firstRunEod.ValueDate = SettleDate;
firstRunEod.UnderlyingPrice = 100m;
service.ExecuteFundCorporateActions(new[] { firstRunEod }, service.ExDividendInfos);
var rerunEod = previousEod.Clone();
rerunEod.ValueDate = SettleDate;
rerunEod.UnderlyingPrice = 100m;
service.ExecuteFundCorporateActions(new[] { rerunEod }, service.ExDividendInfos);
Assert.AreEqual(2000m, firstRunEod.PosiQuantity);
Assert.AreEqual(1000m, firstRunEod.TdChangedQty);
Assert.AreEqual(50m, firstRunEod.PosiGrossPrice);
Assert.AreEqual(100000m, firstRunEod.PosiNotionalValue);
Assert.AreEqual(firstRunEod.PosiQuantity, rerunEod.PosiQuantity);
Assert.AreEqual(firstRunEod.PosiGrossPrice, rerunEod.PosiGrossPrice);
}
[TestMethod]
public void SPC_FUND_004_非Fund标的_即使命中公司行为也不调整()
{
var td = CreateTrade();
var position = CreateFloatPosition(1, 1000m);
position.UnderlyingCode = "FUND.TEST";
position.PosiGrossPrice = 100m;
var previousEod = CreateFloatEodPosition(1, 1000m, 100m);
previousEod.UnderlyingCode = position.UnderlyingCode;
previousEod.UnderlyingInstrumentType = "TBonds";
var service = new TestableSwapEodService(
new List<trade> { td },
new List<swap_position> { position },
new List<eod_swap_position> { previousEod },
new List<eod_swap>(),
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event>(),
price: 100m);
service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m));
var actual = previousEod.Clone();
actual.ValueDate = SettleDate;
actual.UnderlyingPrice = 100m;
service.ExecuteFundCorporateActions(
new[] { actual },
service.ExDividendInfos);
Assert.AreEqual(1000m, actual.PosiQuantity);
Assert.AreEqual(100m, actual.PosiGrossPrice);
Assert.AreEqual(0m, actual.TdChangedQty);
}
[TestMethod]
public void SPC_FUND_005_同日同代码多条有效记录_明确失败()
{
var service = new TestableSwapEodService(
new List<trade> { CreateTrade() },
new List<swap_position>(),
new List<eod_swap_position>(),
new List<eod_swap>(),
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event>());
service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 1m));
service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 1m));
var exception = Assert.ThrowsException<InvalidOperationException>(() =>
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate));
StringAssert.Contains(exception.Message, "存在多条有效除权记录");
}
[TestMethod]
public void SPC_FUND_006_登记日Eod保持除权前数量价格_生效日才调整()
{
var recordDate = SettleDate;
var effectiveDate = recordDate.AddDays(3);
var td = CreateTrade();
var position = CreateFloatPosition(1, 1000m);
var previousEod = CreateFloatEodPosition(1, 1000m, 100m);
SetFundLeg(position, previousEod);
var service = new TestableSwapEodService(
new List<trade> { td },
new List<swap_position> { position },
new List<eod_swap_position> { previousEod },
new List<eod_swap>(),
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event>(),
price: 100m);
service.ExDividendInfos.Add(new ex_dividend_info
{
UnderlyingCode = "FUND.TEST",
ExDividendDate = recordDate,
EffectiveDate = effectiveDate,
GiveShareAmount = 10m,
ValidStatus = true
});
service.ExecuteSwapPositionCompose(recordDate, PreSettleDate);
var recordEod = service.CreatedEodPositions.First(x => x.PositionId == 1);
Assert.AreEqual(1000m, recordEod.PosiQuantity,
"登记日 EOD 仍展示除权前数量,不能提前变成 2000");
Assert.AreEqual(100m, recordEod.PosiGrossPrice,
"登记日 EOD 仍展示除权前价格,不能提前变成 50");
}
[TestMethod]
public void SPC_FUND_007_生效日先以除权后基线处理平仓_1000平300得到1700份50元()
{
var recordDate = SettleDate;
var effectiveDate = recordDate.AddDays(3);
var td = CreateTrade();
var initialPosition = CreateFloatPosition(1, 1000m);
var realtimePosition = initialPosition.Clone();
realtimePosition.id = 2;
realtimePosition.IsInitial = false;
realtimePosition.PositionId = initialPosition.id;
var previousEod = CreateFloatEodPosition(1, 1000m, 100m);
previousEod.ValueDate = recordDate;
SetFundLeg(initialPosition, previousEod);
SetFundLeg(realtimePosition, previousEod);
var closeFlow = CreateCloseFlowEvent(initialPosition.id, 300m);
closeFlow.UnderlyingCode = "FUND.TEST";
closeFlow.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund;
closeFlow.DividendIn = 0m;
var service = new TestableSwapEodService(
new List<trade> { td },
new List<swap_position> { initialPosition, realtimePosition },
new List<eod_swap_position> { previousEod },
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = recordDate } },
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event> { closeFlow },
price: 100m);
service.ExDividendInfos.Add(new ex_dividend_info
{
UnderlyingCode = "FUND.TEST",
ExDividendDate = recordDate,
EffectiveDate = effectiveDate,
GiveShareAmount = 10m,
ValidStatus = true
});
service.ExecuteSwapPositionCompose(effectiveDate, recordDate);
var effectiveEod = service.CreatedEodPositions.First(x => x.PositionId == 1);
Assert.AreEqual(1700m, effectiveEod.PosiQuantity,
"生效日先把 1000 份变为 2000 份,再平仓 300 份,应剩 1700 而非 1400");
Assert.AreEqual(50m, effectiveEod.PosiGrossPrice,
"10 送 10 后期初价格应为 50");
}
[TestMethod]
public void SPC_FUND_008_上游splitratio零点零一映射GiveShareAmount负九点九_Eod数量价格调整()
{
var td = CreateTrade();
var position = CreateFloatPosition(1, 1000m);
var previousEod = CreateFloatEodPosition(1, 1000m, 100m);
SetFundLeg(position, previousEod);
var service = new TestableSwapEodService(
new List<trade> { td },
new List<swap_position> { position },
new List<eod_swap_position> { previousEod },
new List<eod_swap>(),
new List<trade_extend> { CreateExtend() },
new List<swap_flow_event>(),
price: 100m);
// 上游 splitratio=sharesafter/sharesbefore=0.01,落库前按
// GiveShareAmount=10*(splitratio-1) 转换为 -9.9;现有公式因此得到 0.01 倍。
service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: -9.9m));
var actual = previousEod.Clone();
actual.ValueDate = SettleDate;
actual.UnderlyingPrice = 100m;
service.ExecuteFundCorporateActions(
new[] { actual },
service.ExDividendInfos);
Assert.AreEqual(10m, actual.PosiQuantity,
"上游 splitratio=0.01 映射为 GiveShareAmount=-9.91000 份应调整为 10 份");
Assert.AreEqual(10000m, actual.PosiGrossPrice,
"上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,期初价格应反向放大 100 倍");
}
// ================================================================
// 场景4:未收盘抛异常
// ================================================================
[TestMethod]
public void SPC_004_未收盘_非交易首日无前日Eod_抛异常()
{
// 交易起始日早于收盘日(非交易首日),且无前日eod
var td = CreateTrade(startDate: SettleDate.AddDays(-10));
var extend = CreateExtend();
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
var service = new TestableSwapEodService(
new List<trade> { td }, positions,
new List<eod_swap_position>(), new List<eod_swap>(),
new List<trade_extend> { extend }, new List<swap_flow_event>());
var ex = Assert.ThrowsException<Exception>(() =>
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate));
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>
{
CreateCloseFlowEvent(1, 300),
new swap_flow_event
{
SwapTradeId = SwapTradeId, PositionId = initialPrepayId,
EventType = (int)SwapEventTypeEnum.平仓,
EventDate = SettleDate,
DataState = (int)SwapFlowDateStateEnum.完成
}
});
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, InterestRateDefault = 0.01m,
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 prepayCloseFlow = new swap_flow_event
{
SwapTradeId = SwapTradeId, PositionId = initialPrepayId,
EventType = (int)SwapEventTypeEnum.平仓,
EventDate = SettleDate, DataState = (int)SwapFlowDateStateEnum.完成,
InterestMode = (int)InterestModeEnum.初始预付金,
InterestPrincipal = 300m,
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, prepayCloseFlow });
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
var persistedPrepay = service.CreatedEodPositions
.Single(x => x.PositionId == initialPrepayId);
Assert.AreEqual(700m, persistedPrepay.InterestPrincipalFix,
"实时腿已经扣减到700,日终不得再次按平仓比例扣减");
Assert.AreEqual(700m, persistedPrepay.TdInterestPrincipal,
"平仓日预付金计息本金应立即切换为实时剩余本金");
var expectedDailyInterest = Math.Round(700m * 0.01m / 365m,
12, MidpointRounding.AwayFromZero);
Assert.AreEqual(expectedDailyInterest, 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),
UnwindDate = new DateTime(2026, 7, 10),
InterestMode = (int)InterestModeEnum.初始预付金,
InterestPrincipal = 3000m
};
var floatClose = new swap_flow_event
{
PositionId = 1,
PositionType = 1,
EventType = (int)SwapEventTypeEnum.平仓,
EventDate = new DateTime(2026, 7, 9),
UnwindDate = new DateTime(2026, 7, 10),
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);
}
/// <summary>
/// [SPC_008] EventDate ≠ UnwindDate 时,ResolveInterestLegPositionsAsOf 按 EventDate(事件日期)分桶。
/// ----------------------------------------------------------------------------
/// 锁定事件日期作为历史重放的生效边界:
/// - settleDate &lt; EventDate → 平仓"未发生"as-of=原始本金
/// - settleDate &gt;= EventDate → 平仓"已生效"as-of=实时剩余本金
/// 本测试构造 EventDate=7/9、UnwindDate=7/10,验证 settleDate=7/9 时已按 EventDate 生效。
/// </summary>
[TestMethod]
public void SPC_008_EventDateDiffersFromUnwindDate_BucketsByEventDate()
{
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
};
// 关键:EventDate 为 7/9as-of 应按事件日期判断。
var close = new swap_flow_event
{
PositionId = originalPositionId,
PositionType = 0,
EventType = (int)SwapEventTypeEnum.平仓,
EventDate = new DateTime(2026, 7, 9),
UnwindDate = new DateTime(2026, 7, 10),
InterestMode = (int)InterestModeEnum.初始预付金,
InterestPrincipal = 3000m
};
var floatClose = new swap_flow_event
{
PositionId = 1,
PositionType = 1,
EventType = (int)SwapEventTypeEnum.平仓,
EventDate = new DateTime(2026, 7, 9),
UnwindDate = new DateTime(2026, 7, 10),
TradingAmount = 3000000m
};
var originalWithFloat = new List<swap_position>
{
original,
new swap_position { id = 1, PosiDirection = 1, PosiNotionalValue = 10000000m }
};
var flows = new[] { close, floatClose };
// settleDate=7/8(事件日期前)→ as-of=原始 10000
var beforeEffective = SwapDealService.ResolveInterestLegPositionsAsOf(
originalWithFloat, new List<swap_position> { realtime }, flows, new DateTime(2026, 7, 8))
.Single(x => x.id == originalPositionId);
Assert.AreEqual(10000m, beforeEffective.InterestPrincipalFix,
"7/8(事件日期前):平仓未发生,as-of 本金应=原始 10000");
// settleDate=7/9(事件日期当天)→ as-of=实时剩余 7000
var onEffectiveDate = SwapDealService.ResolveInterestLegPositionsAsOf(
originalWithFloat, new List<swap_position> { realtime }, flows, new DateTime(2026, 7, 9))
.Single(x => x.id == originalPositionId);
Assert.AreEqual(7000m, onEffectiveDate.InterestPrincipalFix,
"7/9(事件日期):平仓已生效,as-of 本金应=实时剩余 7000");
// settleDate=7/10(事件日期后)→ 仍为实时剩余 7000
var afterEffectiveBeforeBook = SwapDealService.ResolveInterestLegPositionsAsOf(
originalWithFloat, new List<swap_position> { realtime }, flows, new DateTime(2026, 7, 10))
.Single(x => x.id == originalPositionId);
Assert.AreEqual(7000m, afterEffectiveBeforeBook.InterestPrincipalFix,
"7/10(事件日期后):必须按 EventDate 判已生效 → 7000。");
}
}
}