- 实现股票/基金现金分红数据从 ex_dividend_info 同步到 BondPayment - 新增公司行为去重机制,避免镜像任务完成后重复计息 - 统一现金分红存储口径为"每 10 份派现金额",保持与同步任务一致性 - 修改 CalcPayment 方法,股票/基金分红需除以 10 转换实际现金金额 - 添加单元测试验证债券票息和股票/基金分红的不同计算方式 - 更新文档注释说明"每 10 份派现金额"存储规范 - 修复公司行为生效日处理逻辑,确保正确应用除权系数 - 扩展测试覆盖股票类证券的公司行为处理场景
361 lines
16 KiB
C#
361 lines
16 KiB
C#
using YLErp.DBModels;
|
||
using YLErp.DBModels.Enums;
|
||
|
||
namespace YLErp.Modules.SwapModule
|
||
{
|
||
[TestClass]
|
||
public class FundCorporateActionRollbackAndUnwindTest
|
||
{
|
||
// 生产恢复范围已从原 Fund-only 扩展到 TRS Fund/Stock;本组继续使用 Fund 夹具,
|
||
// 验证共享的登记日/EffectiveDate 边界和回退、平仓基线。
|
||
private static readonly DateTime ExDate = new(2026, 8, 17);
|
||
|
||
[TestMethod]
|
||
public void FCA_RB_001_回退选择最近实际Eod并遵守除权日边界()
|
||
{
|
||
var friday = CreateEod(new DateTime(2026, 8, 14), 1000m, 100m);
|
||
var exDate = CreateEod(ExDate, 2000m, 50m);
|
||
var invalidSunday = CreateEod(new DateTime(2026, 8, 16), 9999m, 1m);
|
||
invalidSunday.Invalid = true;
|
||
var snapshots = new[] { friday, invalidSunday, exDate };
|
||
|
||
var rollbackToExDate = SwapEodPositionService.SelectLatestEodPositionsBefore(
|
||
snapshots,
|
||
ExDate);
|
||
var rollbackAfterExDate = SwapEodPositionService.SelectLatestEodPositionsBefore(
|
||
snapshots,
|
||
ExDate.AddDays(1));
|
||
|
||
Assert.AreEqual(friday.ValueDate, rollbackToExDate.Single().ValueDate,
|
||
"回退到除权日应恢复除权前最近实际 EOD,不能用周日自然日或除权日自身");
|
||
Assert.AreEqual(1000m, rollbackToExDate.Single().PosiQuantity);
|
||
Assert.AreEqual(exDate.ValueDate, rollbackAfterExDate.Single().ValueDate,
|
||
"回退到除权日之后应保留已经生效的除权 EOD");
|
||
Assert.AreEqual(2000m, rollbackAfterExDate.Single().PosiQuantity);
|
||
}
|
||
|
||
[TestMethod]
|
||
public void FCA_UW_001_最近FundEod恢复价格数量且重复恢复不重复除权()
|
||
{
|
||
var realtime = CreateRealtimeFundPosition();
|
||
var eod = CreateEod(ExDate, 2000m, 50m);
|
||
|
||
Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(realtime, eod));
|
||
Assert.AreEqual(2000m, realtime.PosiQuantity);
|
||
Assert.AreEqual(50m, realtime.PosiGrossPrice);
|
||
Assert.AreEqual(100000m, realtime.PosiNotionalValue);
|
||
|
||
Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(realtime, eod));
|
||
Assert.AreEqual(2000m, realtime.PosiQuantity,
|
||
"恢复 EOD 是复制快照,不是再次套 10 送 10 系数,不能变成 4000");
|
||
Assert.AreEqual(50m, realtime.PosiGrossPrice,
|
||
"重复恢复不能把价格再次调整为 25");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void FCA_UW_002_股票与最新Eod后已有完成流水时保持实时持仓()
|
||
{
|
||
var nonFund = CreateRealtimeFundPosition();
|
||
nonFund.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock;
|
||
var eod = CreateEod(ExDate, 2000m, 50m);
|
||
|
||
Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod));
|
||
Assert.AreEqual(2000m, nonFund.PosiQuantity);
|
||
Assert.AreEqual(50m, nonFund.PosiGrossPrice);
|
||
|
||
var td = SwapDealTestFactory.CreateTrade();
|
||
var realtime = CreateRealtimeFundPosition();
|
||
realtime.PosiQuantity = 1500m;
|
||
realtime.PosiGrossPrice = 50m;
|
||
var service = CreateService(td, realtime, eod, hasCompletedFlow: true);
|
||
var unwindData = CreateFullCloseUnwindData();
|
||
|
||
Assert.IsFalse(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate.AddDays(1)));
|
||
Assert.AreEqual(1500m, realtime.PosiQuantity,
|
||
"EOD 后已有部分平仓流水时不能用 2000 份 EOD 覆盖实时剩余 1500 份");
|
||
Assert.AreEqual(1000m, unwindData.CloseQty,
|
||
"未恢复基线时不得擅自改写前端请求,沿用既有当日实时流程");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void FCA_UW_008_股票TRS平仓恢复有效Eod基线()
|
||
{
|
||
var realtime = CreateRealtimeFundPosition();
|
||
realtime.UnderlyingCode = "STOCK.TEST";
|
||
realtime.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock;
|
||
realtime.PosiQuantity = 1000m;
|
||
realtime.PosiGrossPrice = 100m;
|
||
realtime.PosiNetPrice = 100m;
|
||
realtime.PosiNetFeePrice = 100m;
|
||
realtime.PosiNetNoFeePrice = 100m;
|
||
realtime.PosiNotionalValue = 100000m;
|
||
|
||
var eod = CreateEod(ExDate, 2000m, 50m);
|
||
eod.UnderlyingCode = "STOCK.TEST";
|
||
eod.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock;
|
||
|
||
var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false);
|
||
var unwindData = CreateFullCloseUnwindData();
|
||
unwindData.ValueDate = ExDate;
|
||
unwindData.UnwindDate = ExDate.AddDays(1);
|
||
|
||
Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate));
|
||
Assert.AreEqual(2000m, realtime.PosiQuantity,
|
||
"Stock TRS 生效日盘中平仓应使用有效 EOD 数量,不能继续使用除权前实时数量");
|
||
Assert.AreEqual(50m, realtime.PosiGrossPrice,
|
||
"Stock TRS 生效日盘中平仓应使用有效 EOD 价格");
|
||
Assert.AreEqual(2000m, unwindData.PositionQty);
|
||
Assert.AreEqual(2000m, unwindData.CloseQty);
|
||
Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice);
|
||
}
|
||
|
||
[TestMethod]
|
||
public void FCA_UW_005_生效日盘中恢复前一Eod后再套除权()
|
||
{
|
||
var recordDate = new DateTime(2026, 8, 14);
|
||
var realtime = CreateRealtimeFundPosition();
|
||
var eod = CreateEod(recordDate, 1000m, 100m);
|
||
var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false);
|
||
service.ExDividendInfos.Add(new ex_dividend_info
|
||
{
|
||
UnderlyingCode = "FUND.TEST",
|
||
ExDividendDate = recordDate,
|
||
EffectiveDate = ExDate,
|
||
GiveShareAmount = 10m,
|
||
ValidStatus = true
|
||
});
|
||
var unwindData = CreateFullCloseUnwindData();
|
||
|
||
Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate));
|
||
|
||
Assert.AreEqual(2000m, realtime.PosiQuantity,
|
||
"8 月 17 日盘中应先从 8 月 14 日 EOD 恢复,再按 10 送 10 变为 2000 份");
|
||
Assert.AreEqual(50m, realtime.PosiGrossPrice,
|
||
"真实除权生效日盘中应使用 50 元基准,不能继续使用登记日 100 元");
|
||
Assert.AreEqual(2000m, unwindData.CloseQty);
|
||
Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice);
|
||
}
|
||
|
||
[TestMethod]
|
||
public void FCA_UW_006_登记日盘中平仓不提前应用除权()
|
||
{
|
||
var recordDate = new DateTime(2026, 8, 14);
|
||
var realtime = CreateRealtimeFundPosition();
|
||
// 8 月 14 日盘中尚未生成当日 EOD,最近可用快照应是 8 月 13 日。
|
||
var eod = CreateEod(recordDate.AddDays(-1), 1000m, 100m);
|
||
var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false);
|
||
service.ExDividendInfos.Add(new ex_dividend_info
|
||
{
|
||
UnderlyingCode = "FUND.TEST",
|
||
ExDividendDate = recordDate,
|
||
EffectiveDate = ExDate,
|
||
GiveShareAmount = 10m,
|
||
ValidStatus = true
|
||
});
|
||
var unwindData = CreateFullCloseUnwindData();
|
||
unwindData.ValueDate = recordDate;
|
||
unwindData.UnwindDate = recordDate.AddDays(1);
|
||
|
||
service.SwapUnwind(unwindData);
|
||
|
||
Assert.AreEqual(1000m, unwindData.PositionQty,
|
||
"登记日仍使用除权前 EOD 基线,不能提前变为 2000 份");
|
||
Assert.AreEqual(1000m, unwindData.CloseQty);
|
||
Assert.AreEqual(100m, unwindData.FlowEvents.Single().PosiGrossPrice,
|
||
"登记日盘中平仓价格仍应为 100 元,除权生效日才切换为 50 元");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void FCA_UW_007_基金直接拆合股比例零点零一_平仓按新数量价格()
|
||
{
|
||
var recordDate = new DateTime(2026, 8, 14);
|
||
var realtime = CreateRealtimeFundPosition();
|
||
var eod = CreateEod(recordDate, 1000m, 100m);
|
||
var td = SwapDealTestFactory.CreateTrade();
|
||
td.StockEqvNotional = 100000d;
|
||
td.TradeAmount = 1000d;
|
||
var service = CreateService(td, realtime, eod, hasCompletedFlow: false);
|
||
service.ExDividendInfos.Add(new ex_dividend_info
|
||
{
|
||
UnderlyingCode = "FUND.TEST",
|
||
ExDividendDate = recordDate,
|
||
EffectiveDate = ExDate,
|
||
// 上游 splitratio=0.01 必须先转换为 10 * (0.01 - 1)=-9.9;
|
||
// 直接写 0.01 会按当前字段公式得到 1.001 倍,无法表达缩小为 0.01 倍。
|
||
GiveShareAmount = -9.9m,
|
||
ValidStatus = true
|
||
});
|
||
var unwindData = CreateFullCloseUnwindData();
|
||
unwindData.ValueDate = ExDate;
|
||
unwindData.UnwindDate = ExDate.AddDays(1);
|
||
|
||
service.SwapUnwind(unwindData);
|
||
|
||
Assert.AreEqual(10m, unwindData.PositionQty,
|
||
"Fund splitratio=0.01 时,有效平仓基线应为 1000 * 0.01 = 10 份");
|
||
Assert.AreEqual(10m, unwindData.CloseQty);
|
||
Assert.AreEqual(10000m, unwindData.FlowEvents.Single().PosiGrossPrice,
|
||
"Fund 份额缩小为 0.01 倍时,直接平仓期初价应为 100 / 0.01 = 10000");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void FCA_UW_003_正式平仓按FundEod基线重算PnL和现金()
|
||
{
|
||
var td = SwapDealTestFactory.CreateTrade();
|
||
td.StockEqvNotional = 100000d;
|
||
td.TradeAmount = 1000d;
|
||
var realtime = CreateRealtimeFundPosition();
|
||
var eod = CreateEod(ExDate, 2000m, 50m);
|
||
var service = CreateService(td, realtime, eod, hasCompletedFlow: false);
|
||
var unwindData = CreateFullCloseUnwindData();
|
||
var floatEvent = unwindData.FlowEvents.Single();
|
||
|
||
service.SwapUnwind(unwindData);
|
||
|
||
Assert.AreEqual(2000m, unwindData.PositionQty);
|
||
Assert.AreEqual(2000m, unwindData.CloseQty);
|
||
Assert.AreEqual(100000m, unwindData.CloseNotionalValue);
|
||
Assert.AreEqual(50m, floatEvent.PosiGrossPrice);
|
||
Assert.AreEqual(20000m, floatEvent.MarkClosePnl,
|
||
"平仓价 60 - 除权后期初价 50,乘 2000 份,应为 20000");
|
||
Assert.AreEqual(20000m, unwindData.SwapRealizedPnL);
|
||
Assert.AreEqual(-20000d, service.ClientCashCalls.Single().amount, 0.001d,
|
||
"客户现金必须使用后台按有效 EOD 重算后的平仓金额");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void FCA_UW_004_现金分红后部分平仓从Eod名义本金扣减()
|
||
{
|
||
var td = SwapDealTestFactory.CreateTrade();
|
||
td.StockEqvNotional = 100000d;
|
||
td.TradeAmount = 1000d;
|
||
var realtime = CreateRealtimeFundPosition();
|
||
var eod = CreateEod(ExDate, 1000m, 99m);
|
||
var service = CreateService(td, realtime, eod, hasCompletedFlow: false);
|
||
var unwindData = SwapDealTestFactory.CreateUnwindData(
|
||
swapRealizedPnL: -500m,
|
||
closeMethod: (int)CloseMethodEnum.部分平仓,
|
||
closePercent: 0.5m,
|
||
closeQty: 500m,
|
||
closeNotionalValue: 50000m,
|
||
positionQty: 1000m);
|
||
unwindData.NotionalValue = 100000m;
|
||
unwindData.PosiNotionalValue = 100000m;
|
||
unwindData.FlowEvents.Add(new swap_flow_event
|
||
{
|
||
PositionId = 101,
|
||
EventType = (int)SwapEventTypeEnum.平仓,
|
||
UnderlyingCode = "FUND.TEST",
|
||
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund,
|
||
PositionType = (int)PositionTypeFlag.Long,
|
||
PayDirection = 1,
|
||
PosiGrossPrice = 100m,
|
||
PosiNetPrice = 100m,
|
||
TradingAmountAvg = 99m,
|
||
Quantity = 500m,
|
||
PositionQty = 500m,
|
||
ContractSize = 1m,
|
||
MarkClosePnl = -500m
|
||
});
|
||
|
||
service.SwapUnwind(unwindData);
|
||
|
||
Assert.AreEqual(99000m, unwindData.PosiNotionalValue);
|
||
Assert.AreEqual(49500m, unwindData.CloseNotionalValue);
|
||
Assert.AreEqual(0m, unwindData.SwapRealizedPnL,
|
||
"市场价和除权后期初价同为 99 时不应产生额外盯市损益");
|
||
Assert.AreEqual(49500d, td.StockEqvNotional, 0.001d,
|
||
"应从 EOD 有效名义本金 99000 扣除 49500,不能从旧 trade 值 100000 扣减");
|
||
Assert.AreEqual(500d, td.TradeAmount, 0.001d);
|
||
}
|
||
|
||
private static TestableSwapDealService CreateService(
|
||
trade td,
|
||
swap_position realtime,
|
||
eod_swap_position eod,
|
||
bool hasCompletedFlow)
|
||
{
|
||
return new TestableSwapDealService(td)
|
||
{
|
||
RealtimeFloatPosition = realtime,
|
||
LatestFundEodPosition = eod,
|
||
HasCompletedFlowAfterLatestFundEod = hasCompletedFlow,
|
||
ActiveSwapPositions = new List<swap_position> { realtime }
|
||
};
|
||
}
|
||
|
||
private static swap_position CreateRealtimeFundPosition()
|
||
{
|
||
return new swap_position
|
||
{
|
||
SwapTradeId = SwapDealTestFactory.SwapTradeId,
|
||
PositionId = 101,
|
||
IsInitial = false,
|
||
PosiDirection = 1,
|
||
PositionType = (int)PositionTypeFlag.Long,
|
||
UnderlyingCode = "FUND.TEST",
|
||
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund,
|
||
PosiQuantity = 1000m,
|
||
PosiGrossPrice = 100m,
|
||
PosiNetPrice = 100m,
|
||
PosiNetFeePrice = 100m,
|
||
PosiNetNoFeePrice = 100m,
|
||
PosiNotionalValue = 100000m,
|
||
ContractSize = 1m
|
||
};
|
||
}
|
||
|
||
private static eod_swap_position CreateEod(DateTime valueDate, decimal quantity, decimal price)
|
||
{
|
||
return new eod_swap_position
|
||
{
|
||
SwapTradeId = SwapDealTestFactory.SwapTradeId,
|
||
PositionId = 101,
|
||
ValueDate = valueDate,
|
||
PosiDirection = 1,
|
||
PositionType = (int)PositionTypeFlag.Long,
|
||
UnderlyingCode = "FUND.TEST",
|
||
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund,
|
||
PosiQuantity = quantity,
|
||
PosiGrossPrice = price,
|
||
PosiNetPrice = price,
|
||
PosiNetFeePrice = price,
|
||
PosiNetNoFeePrice = price,
|
||
UnderlyingPrice = price,
|
||
PosiNotionalValue = quantity * price,
|
||
ContractSize = 1m
|
||
};
|
||
}
|
||
|
||
private static UnwindData CreateFullCloseUnwindData()
|
||
{
|
||
var data = SwapDealTestFactory.CreateUnwindData(
|
||
swapRealizedPnL: -40000m,
|
||
closeMethod: (int)CloseMethodEnum.全部平仓,
|
||
closePercent: 1m,
|
||
closeQty: 1000m,
|
||
closeNotionalValue: 100000m,
|
||
positionQty: 1000m);
|
||
data.NotionalValue = 100000m;
|
||
data.PosiNotionalValue = 100000m;
|
||
data.FlowEvents.Add(new swap_flow_event
|
||
{
|
||
PositionId = 101,
|
||
EventType = (int)SwapEventTypeEnum.平仓,
|
||
UnderlyingCode = "FUND.TEST",
|
||
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund,
|
||
PositionType = (int)PositionTypeFlag.Long,
|
||
PayDirection = 1,
|
||
PosiGrossPrice = 100m,
|
||
PosiNetPrice = 100m,
|
||
TradingAmountAvg = 60m,
|
||
Quantity = 1000m,
|
||
PositionQty = 0m,
|
||
ContractSize = 1m,
|
||
MarkClosePnl = -40000m
|
||
});
|
||
return data;
|
||
}
|
||
}
|
||
}
|