- 添加 EffectiveDate 字段用于标识真实除权生效日 - 实现公司行为价格系数和数量系数统一计算方法 - 增加基金除权回退和平仓基线恢复功能 - 完善除权日验证逻辑,确保真实除权日不早于股权登记日 - 重构平仓流程,支持按有效EOD基线重新计算损益和现金 - 添加基金拆合股和现金分红的特殊处理逻辑 - 增加单元测试验证各种公司行为场景下的正确性
838 lines
42 KiB
C#
838 lines
42 KiB
C#
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>
|
||
/// 继承 SwapEodPositionService,override 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 override(PersistEodSwapPosition/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 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)
|
||
{
|
||
LastInterestCalculationPositions = positions;
|
||
return base.CalcSwapInterests(td, tradeExtend, valueDate, unwindDate,
|
||
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
|
||
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
|
||
grossPrice, 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<eod_swap_position> previousEodPositions,
|
||
IReadOnlyCollection<swap_flow_event> flowEvents,
|
||
IReadOnlyCollection<ex_dividend_info> dividendInfos)
|
||
{
|
||
ApplyFundCorporateActions(
|
||
positions,
|
||
previousEodPositions,
|
||
flowEvents,
|
||
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 },
|
||
new[] { previousEod },
|
||
Array.Empty<swap_flow_event>(),
|
||
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));
|
||
var actual = previousEod.Clone();
|
||
actual.ValueDate = SettleDate;
|
||
actual.UnderlyingPrice = 100m;
|
||
|
||
service.ExecuteFundCorporateActions(
|
||
new[] { actual },
|
||
new[] { previousEod },
|
||
Array.Empty<swap_flow_event>(),
|
||
service.ExDividendInfos);
|
||
|
||
Assert.AreEqual(1000m, actual.PosiQuantity);
|
||
Assert.AreEqual(0m, actual.TdChangedQty);
|
||
Assert.AreEqual(99m, actual.PosiGrossPrice);
|
||
Assert.AreEqual(0m, actual.TdPosiDividend);
|
||
Assert.AreEqual(0m, actual.PosiDividendSum);
|
||
Assert.AreEqual(99000m, actual.PosiNotionalValue);
|
||
}
|
||
|
||
[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));
|
||
var todayEod = previousEod.Clone();
|
||
todayEod.ValueDate = SettleDate;
|
||
todayEod.UnderlyingPrice = 100m;
|
||
|
||
service.ExecuteFundCorporateActions(
|
||
new[] { todayEod },
|
||
new[] { previousEod },
|
||
Array.Empty<swap_flow_event>(),
|
||
service.ExDividendInfos);
|
||
service.ExecuteFundCorporateActions(
|
||
new[] { todayEod },
|
||
new[] { previousEod },
|
||
Array.Empty<swap_flow_event>(),
|
||
service.ExDividendInfos);
|
||
|
||
Assert.AreEqual(2000m, todayEod.PosiQuantity);
|
||
Assert.AreEqual(1000m, todayEod.TdChangedQty);
|
||
Assert.AreEqual(50m, todayEod.PosiGrossPrice);
|
||
Assert.AreEqual(100000m, todayEod.PosiNotionalValue);
|
||
}
|
||
|
||
[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 },
|
||
new[] { previousEod },
|
||
Array.Empty<swap_flow_event>(),
|
||
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 },
|
||
new[] { previousEod },
|
||
Array.Empty<swap_flow_event>(),
|
||
service.ExDividendInfos);
|
||
|
||
Assert.AreEqual(10m, actual.PosiQuantity,
|
||
"上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,1000 份应调整为 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 < EventDate → 平仓"未发生",as-of=原始本金
|
||
/// - settleDate >= 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/9,as-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。");
|
||
}
|
||
}
|
||
}
|