Merge remote-tracking branch 'dest/glms/feature/1.4.2' into test

This commit is contained in:
lisong
2026-08-21 16:00:48 +08:00
81 changed files with 5362 additions and 136 deletions
@@ -0,0 +1,61 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.Helpers;
using YLErp.Model;
namespace UnitTestProject.Modules
{
/// <summary>
/// EQD-6953 疑似到期债券值域闸门:IsMaturedDegenerate / IsResultAbsurd 分支覆盖。
/// 场景来源:UAT 060203.IB2006年国债,2026估值日已无剩余现金流)——
/// jquantlib 对空现金流求解得 ytm=0、净/全价均为面值100errCode=0"成功但退化"。
/// 与前端 swapCalc.js::getBondCalcErrorMessage 的同款闸门保持一致口径。
/// </summary>
[TestClass]
public class BondCalcHeplerTest
{
[TestMethod]
public void 退_三条件同时成立_命中()
{
var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100m, ytm = 0m };
Assert.IsTrue(BondCalcHepler.IsMaturedDegenerate(r));
}
[TestMethod]
public void _不命中_按UAT实测180205IB()
{
var r = new CalBondResult { cleanPrice = 97.43300000000002m, dirtyPrice = 100.00001369863016m, ytm = 6.738278242318886m };
Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r));
}
[TestMethod]
public void ytm为0但净价非面值_不命中_真实零息平价券场景()
{
var r = new CalBondResult { cleanPrice = 99.5m, dirtyPrice = 100m, ytm = 0m };
Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r));
}
[TestMethod]
public void ytm非0_不命中_正常息票平价券场景()
{
var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100.5m, ytm = 3.2m };
Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r));
}
[TestMethod]
public void ytm为null_不命中()
{
var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100m, ytm = null };
Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r));
}
[TestMethod]
public void IsResultAbsurd_到期退化值_命中并带原因()
{
var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100m, ytm = 0m };
var ok = typeof(BondCalcHepler)
.GetMethod("IsResultAbsurd", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)
.Invoke(null, new object[] { r, null });
Assert.IsTrue((bool)ok);
}
}
}
@@ -0,0 +1,51 @@
using YLErp.DBModels;
namespace YLErp.Modules.EodModule
{
[TestClass]
public class BondPaymentServiceCalculationTest
{
private static BondPaymentService CreateService()
{
return new BondPaymentService(
new OptUserInfo(0, nameof(BondPaymentServiceCalculationTest), OptUserFrom.UnitTest));
}
[TestMethod]
public void CalcPayment_BondCoupon_KeepsPerHundredScale()
{
var payments = new List<BondPayment>
{
new BondPayment { payment_interest = 1m }
};
// 债券票息 1 表示每 100 元面值付 1 元:1 * 1000 / 100 = 10。
var actual = CreateService().CalcPayment(
payments,
qty: 1000m,
longRatio: 1m,
payDirection: 1m);
Assert.AreEqual(10m, actual);
}
[TestMethod]
public void CalcPayment_StockOrFundDividend_DoesNotApplyBondScale()
{
var payments = new List<BondPayment>
{
new BondPayment { payment_interest = 10m }
};
// GiveCashAmount=10(每 10 份派 10)时,payment_interest 直接存 10
// 持仓 1000 份的现金分红 = 10 * 1000 / 10 = 1000,不能再套债券报价的 /100 换算。
var actual = CreateService().CalcPayment(
payments,
qty: 1000m,
longRatio: 1m,
payDirection: 1m,
useBondPriceScale: false);
Assert.AreEqual(1000m, actual);
}
}
}
@@ -69,7 +69,10 @@ namespace YLErp.Modules.SwapModule
public List<(DateTime valueDate, int eventType, string reason, UnwindData data)> SwapEvents { get; } = new();
/// <summary>捕获落库的互换流水明细</summary>
public List<swap_flow_event> PersistedFlowEvents { get; } = new();
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,
@@ -112,6 +115,13 @@ namespace YLErp.Modules.SwapModule
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)
{
@@ -520,6 +530,9 @@ namespace YLErp.Modules.SwapModule
$"分红支付日({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,
"资金发生日应使用分红支付日");
}
// ================================================================
@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.DBModels;
using YLErp.DBModels.Consts;
namespace YLErp.Modules.SwapModule
{
[TestClass]
public class CorporateActionEventLifecycleTest
{
// 8/14 登记日只创建 Applied=false 的待生效事件;8/17 真实生效日补齐
// 同一事件的调整前后快照并标记 Applied=true。
private static readonly DateTime RecordDate = new DateTime(2026, 8, 14);
private static readonly DateTime EffectiveDate = new DateTime(2026, 8, 17);
[TestMethod]
public void RegistrationSnapshot_IsPending_AndKeepsBeforeFields()
{
var info = CreateAction(77, ConsGlobal.InstrumentType.Stock);
var before = CreateEodPosition(9, info.UnderlyingCode, 1000m, 100m);
var snapshot = SwapEodPositionService.BuildCorporateActionEventData(
info,
before,
null,
applied: false);
Assert.AreEqual(77, snapshot.ExDividendInfoId);
Assert.AreEqual(9L, snapshot.PositionId);
Assert.AreEqual(1000m, snapshot.BeforeQuantity);
Assert.AreEqual(100m, snapshot.BeforePrice);
Assert.AreEqual(100000m, snapshot.BeforeNotional);
Assert.AreEqual(0m, snapshot.AfterQuantity);
Assert.IsFalse(snapshot.Applied);
var reason = SwapEventService.BuildCorporateActionEventReason(snapshot);
StringAssert.Contains(reason, "BeforeQuantity=1000");
StringAssert.Contains(reason, "AfterQuantity=0");
}
[TestMethod]
public void EffectiveSnapshot_ContainsAfterFields_AndSupportsStockAndFund()
{
var info = CreateAction(78, ConsGlobal.InstrumentType.Fund);
var before = CreateEodPosition(10, info.UnderlyingCode, 1000m, 100m);
var after = CreateEodPosition(10, info.UnderlyingCode, 2000m, 50m);
var snapshot = SwapEodPositionService.BuildCorporateActionEventData(
info,
before,
after,
applied: true);
Assert.AreEqual(1000m, snapshot.BeforeQuantity);
Assert.AreEqual(100m, snapshot.BeforePrice);
Assert.AreEqual(2000m, snapshot.AfterQuantity);
Assert.AreEqual(50m, snapshot.AfterPrice);
Assert.AreEqual(100000m, snapshot.AfterNotional);
Assert.IsTrue(snapshot.Applied);
Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Stock));
Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Fund));
Assert.IsFalse(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.TBonds));
}
[TestMethod]
public void Rerun_DoesNotCreateDuplicateCorporateActionEvent()
{
var info = CreateAction(79, ConsGlobal.InstrumentType.Stock);
var snapshot = SwapEodPositionService.BuildCorporateActionEventData(
info,
CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m),
null,
applied: false);
var existing = new swap_event
{
SwapTradeId = 100,
EventType = (int)SwapEventTypeEnum.,
EventData = JsonConvert.SerializeObject(snapshot),
Invalid = false
};
Assert.IsFalse(SwapEodPositionService.ShouldCreateCorporateActionEvent(
new[] { existing },
info,
11L));
}
[TestMethod]
public void LegacyEventWithoutExDividendInfoId_DoesNotBlockCurrentEvent()
{
var info = CreateAction(79, ConsGlobal.InstrumentType.Stock);
var legacySnapshot = SwapEodPositionService.BuildCorporateActionEventData(
info,
CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m),
null,
applied: false);
legacySnapshot.ExDividendInfoId = 0;
var legacyEvent = new swap_event
{
SwapTradeId = 100,
EventType = (int)SwapEventTypeEnum.,
EventData = JsonConvert.SerializeObject(legacySnapshot),
Invalid = false
};
Assert.IsTrue(SwapEodPositionService.ShouldCreateCorporateActionEvent(
new[] { legacyEvent },
info,
11L));
}
[TestMethod]
public void OperationHistory_PreservesPendingCorporateActionForAudit()
{
var info = CreateAction(80, ConsGlobal.InstrumentType.Stock);
var pendingData = SwapEodPositionService.BuildCorporateActionEventData(
info,
CreateEodPosition(12, info.UnderlyingCode, 1000m, 100m),
null,
applied: false);
var appliedData = SwapEodPositionService.BuildCorporateActionEventData(
info,
CreateEodPosition(13, info.UnderlyingCode, 1000m, 100m),
CreateEodPosition(13, info.UnderlyingCode, 2000m, 50m),
applied: true);
var events = new List<swap_event>
{
new swap_event { id = 1, EventType = (int)SwapEventTypeEnum., EventData = JsonConvert.SerializeObject(pendingData) },
new swap_event { id = 2, EventType = (int)SwapEventTypeEnum., EventData = JsonConvert.SerializeObject(appliedData) },
new swap_event { id = 3, EventType = (int)SwapEventTypeEnum., EventData = "{}" }
};
// 操作历史不再隐藏登记日待生效事件;Applied=false 是事件状态,不是展示过滤条件。
Assert.AreEqual(3, events.Count);
Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[0], out var pendingSnapshot));
Assert.IsFalse(pendingSnapshot.Applied);
Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[1], out var appliedSnapshot));
Assert.IsTrue(appliedSnapshot.Applied);
}
[TestMethod]
public void EffectiveCorporateAction_AdjustsStockQuantityAndPrice()
{
var position = new swap_position
{
PositionId = 14,
PosiDirection = 1,
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock,
UnderlyingCode = "STOCK.TEST",
PosiQuantity = 1000m,
PosiGrossPrice = 100m,
PosiNetPrice = 100m,
ContractSize = 1m
};
var info = CreateAction(81, ConsGlobal.InstrumentType.Stock);
info.GiveShareAmount = 10m;
var applied = SwapEodPositionService.ApplyCorporateActionToPosition(
position,
info,
100m,
0m);
Assert.IsTrue(applied);
Assert.AreEqual(2000m, position.PosiQuantity);
Assert.AreEqual(50m, position.PosiGrossPrice);
Assert.AreEqual(100000m, position.PosiNotionalValue);
}
[TestMethod]
public void Lifecycle_RegistrationIsIdempotent_ThenEffectiveUpdatesSameEvent()
{
var info = CreateAction(82, ConsGlobal.InstrumentType.Stock);
var before = CreateEodPosition(15, info.UnderlyingCode, 1000m, 100m);
var after = CreateEodPosition(15, info.UnderlyingCode, 2000m, 50m);
var service = new EventRecordingService();
var trade = new trade { id = 100 };
service.Record(
trade,
new[] { before },
Array.Empty<eod_swap_position>(),
new[] { info },
Array.Empty<ex_dividend_info>(),
RecordDate);
service.Record(
trade,
new[] { before },
Array.Empty<eod_swap_position>(),
new[] { info },
Array.Empty<ex_dividend_info>(),
RecordDate);
Assert.AreEqual(1, service.Events.Count);
Assert.AreEqual(1000m, before.PosiQuantity, "登记日不能改持仓数量");
Assert.AreEqual(100m, before.PosiGrossPrice, "登记日不能改持仓价格");
var pending = JsonConvert.DeserializeObject<CorporateActionEventData>(service.Events[0].EventData);
Assert.IsFalse(pending.Applied);
Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date);
service.Record(
trade,
new[] { after },
new[] { before },
Array.Empty<ex_dividend_info>(),
new[] { info },
EffectiveDate);
Assert.AreEqual(1, service.Events.Count, "生效日应更新原事件而非新增事件");
Assert.AreEqual(1, service.UpdateCount);
var applied = JsonConvert.DeserializeObject<CorporateActionEventData>(service.Events[0].EventData);
Assert.IsTrue(applied.Applied);
Assert.AreEqual(1000m, applied.BeforeQuantity);
Assert.AreEqual(2000m, applied.AfterQuantity);
Assert.AreEqual(50m, applied.AfterPrice);
Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date);
}
private sealed class EventRecordingService : TestableSwapEodPositionService
{
public List<swap_event> Events { get; } = new List<swap_event>();
public int UpdateCount { get; private set; }
public EventRecordingService()
: base(nameof(CorporateActionEventLifecycleTest))
{
}
protected override List<swap_event> FindCorporateActionEvents(int swapTradeId)
{
return Events;
}
protected override swap_event AddSwapEvent(
DateTime tradeDate,
int swapTradeId,
int eventType,
string data,
int clientCashId,
bool save,
string reason)
{
return new swap_event { id = Events.Count + 1 };
}
protected override void UpdateCorporateActionEventRecord(swap_event swapEvent)
{
UpdateCount++;
}
public void Record(
trade trade,
IReadOnlyCollection<eod_swap_position> current,
IReadOnlyCollection<eod_swap_position> previous,
IReadOnlyCollection<ex_dividend_info> registration,
IReadOnlyCollection<ex_dividend_info> effective,
DateTime settleDate)
{
RecordCorporateActionEvents(
trade,
current,
previous,
registration,
effective,
settleDate);
}
}
private static ex_dividend_info CreateAction(int id, string instrumentType)
{
return new ex_dividend_info
{
id = id,
UnderlyingCode = instrumentType == ConsGlobal.InstrumentType.Fund ? "FUND.TEST" : "STOCK.TEST",
ExDividendDate = RecordDate,
EffectiveDate = EffectiveDate,
GiveShareAmount = 0m,
GiveCashAmount = 0m,
ValidStatus = true
};
}
private static eod_swap_position CreateEodPosition(long positionId, string code, decimal quantity, decimal price)
{
return new eod_swap_position
{
PositionId = positionId,
UnderlyingCode = code,
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock,
PosiQuantity = quantity,
PosiGrossPrice = price,
PosiNotionalValue = quantity * price,
PosiNetPrice = price,
ContractSize = 1m,
PosiDirection = 1,
PositionType = 1
};
}
}
}
@@ -0,0 +1,395 @@
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_009_登记日跨非交易日到生效日按范围恢复基金基线()
{
var eodDate = new DateTime(2026, 7, 12);
var effectiveDate = new DateTime(2026, 7, 13);
var unwindDate = new DateTime(2026, 7, 17);
var realtime = CreateRealtimeFundPosition();
var eod = CreateEod(eodDate, 1000m, 100m);
var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false);
service.ExDividendInfos.Add(new ex_dividend_info
{
id = 1,
UnderlyingCode = "FUND.TEST",
// 7/10 登记,7/13 生效;7/11、7/12 虽无交易但仍可能存在未除权 EOD 快照。
ExDividendDate = new DateTime(2026, 7, 10),
EffectiveDate = effectiveDate,
// 生产数据口径:1 拆 2 直接存 Split=2GiveShareAmount 不参与该拆分。
GiveShareAmount = 0m,
Split = 2m,
ValidStatus = true
});
var unwindData = CreateFullCloseUnwindData();
unwindData.ValueDate = unwindDate;
unwindData.UnwindDate = unwindDate;
Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, unwindDate));
Assert.AreEqual(2000m, realtime.PosiQuantity,
"7 月 17 日平仓应补应用 7 月 13 日生效的 Split=2,公司行为不能只按平仓日命中");
Assert.AreEqual(50m, realtime.PosiGrossPrice);
Assert.AreEqual(2000m, unwindData.PositionQty);
Assert.AreEqual(2000m, unwindData.CloseQty);
Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice);
}
[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;
}
}
}
@@ -11,7 +11,9 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
/// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天);
/// ③ 到期日恰为重置日(末段 [到期,到期] 1 天);
/// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同);
/// ⑤ 起息日当天平仓(无 preEod)
/// ⑤ 起息日当天平仓(无 preEod)
/// ⑥ 部分平仓 share&lt;1 + 无 preEod 兜底——钉 merger 复刻 GetInterests 本金口径的接缝
/// (现有用例全部 closePercent=1m,重放基数与复刻本金的口径偏差在 share=1 下不可见)。
///
/// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率,
/// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。
@@ -49,14 +51,14 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid"
};
private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread)
private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread, int periodDays = 7)
=> new()
{
id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1,
InterestMode = (int)InterestModeEnum., InterestRateDefault = spread,
InterestPrincipalFix = Notional, PosiStartDate = posiStart, PosiMatuirityDate = maturity,
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
IsAnnualized = true, interest_rest_days = periodDays, interest_rule = 0,
FloatRateUnderlyingCode = null, InterestSwapInterval = "[]"
};
@@ -66,17 +68,20 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
TdInterestPrincipal = rollingBasis, InterestIncomeSum = incomeSum };
private static decimal RunFee(trade td, swap_position p, decimal settledAmount,
eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread)
eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread,
decimal interestPrincipal = 0m, bool maturityCalcLast = true, decimal closePercent = 1m)
{
var e = new swap_flow_event
{
PositionId = p.id, InterestAmount = settledAmount, InterestFee = 0m,
InterestDirection = 1, InterestClosePnL = settledAmount
InterestDirection = 1, InterestClosePnL = settledAmount,
InterestPrincipal = interestPrincipal // 复利主路径下=重放末次并本金后基数(=被平份额本金+①)
};
PenaltyInterestFeeMerger.Merge(
td, new List<swap_position> { p }, new List<swap_flow_event> { e },
unwind, AnnualDays, settled, maturityCalcLast: true,
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast,
posiNotionalValue: Notional, closePosiNotionalValue: Notional * closePercent,
closePercent: closePercent,
getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread);
return e.InterestFee;
}
@@ -170,6 +175,50 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
"锚点偏离:罚息分段/重置日判定必须用 position.PosiStartDate 网格(误用 td.StartDate 网格必挂)");
}
[TestMethod]
public void preEod且此前已有重置_经事件基数兜底_恒等式精确成立()
{
// UAT 实测场景(tradeId=2447):环境无日终快照、起息后已发生 8/19 重置并本。
// 兜底① = normalEvent.InterestPrincipal 本金(复利重放末次并本金后基数);
// 修复前 ①=0 少算 ≈3.17 元(并入额×冻结利率×段尾天数),本用例钉死兜底路径的精确性。
var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30);
var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结)14 天重置
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, period: 14); // 已结 [8/5..8/19]
var replayFinalBasis = Notional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, period: 14); // 8/19 重置并本后基数
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed,
preEod: null, unwind: unwind, settled: false, spread: hist[^1],
interestPrincipal: replayFinalBasis, maturityCalcLast: false); // 不算尾合约、14天重置(对应 UAT tradeId=2447 口径)
var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, period: 14);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)");
}
[TestMethod]
public void preEod兜底_share对齐本金口径_恒等式成立()
{
// 接缝守卫:merger 的 closePrincipal 走 CalcNotional 复刻 GetInterests 口径
// (标的期初全价 = posiNotional×closePercent),而重放基数由调用方以
// closePosiNotionalValue 缩放——两处口径若有偏差,share=1 时不可见、
// share<1 时 ① 里会混入本金差。本用例以 50% 平仓钉死该对齐。
var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30);
var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结)14 天重置
var share = 0.5m;
var closedNotional = Notional * share;
// 被平份额的实结与重放基数:复利对 notional 线性,直接按半额本金重放
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14);
var replayFinalBasis = closedNotional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, notional: closedNotional, period: 14);
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed,
preEod: null, unwind: unwind, settled: false, spread: hist[^1],
interestPrincipal: replayFinalBasis, maturityCalcLast: false, closePercent: share);
var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"部分平仓+无preEod:兜底①按被平份额缩放精确,全期(被平份额)=实结+罚息(口径漂移时此式必挂)");
}
[TestMethod]
public void _无preEod_恒等式成立()
{
@@ -50,7 +50,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
private static void RunMerge(
swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod,
Func<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null)
Func<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null,
AccrualTrace? trace = null)
{
getSpread ??= _ => Rate;
tryGetFixing ??= (d, code) => Rate;
@@ -61,7 +62,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
getSpread: getSpread,
getPreEod: _ => preEod,
tryGetFixing: tryGetFixing);
tryGetFixing: tryGetFixing,
trace: trace);
}
/// <summary>复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。</summary>
@@ -152,6 +154,45 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息");
}
[TestMethod]
public void preEod复利段中兜底为零且账龄超重置周期_留退化告警trace()
{
// 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底已并复利本金=0。
// 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应>0,已并复利本金=0 属退化——
// 典型成因=interestWindowEmpty(当日已结息)早退未重放覆盖种子、或日终归档缺失。
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional; // GetInterests 种子值:interestWindowEmpty 早退路径不会用重放基数覆盖它
var trace = new AccrualTrace();
RunMerge(Leg(InterestTypeEnum.), e, preEod: null, trace: trace);
StringAssert.Contains(trace.ToString(), "无preEod兜底已并复利本金=0",
"已并复利本金=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因");
}
[TestMethod]
public void preEod兜底为正_不留退化告警()
{
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → 已并复利本金=100000 正常路径
var trace = new AccrualTrace();
RunMerge(Leg(InterestTypeEnum.), e, preEod: null, trace: trace);
Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "已并复利本金>0 是正常兜底路径,不得告警");
}
[TestMethod]
public void preEod真首日兜底为零_不留退化告警()
{
var p = Leg(InterestTypeEnum.);
p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,已并复利本金=0 是设计内约定(类头注)
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional;
var trace = new AccrualTrace();
RunMerge(p, e, preEod: null, trace: trace);
Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "真首日 已并复利本金=0 合法,不得告警");
}
[TestMethod]
public void _跳过该腿不阻断()
{
@@ -0,0 +1,197 @@
using System.Reflection;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.TradeModule.DealModule;
namespace YLErp.Modules.SwapModule
{
[TestClass]
public class SplitCorporateActionTddTest
{
[TestMethod]
public void SplitTenScalesQuantityAndPriceByTen()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(split: 10m);
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(1000m, position.PosiQuantity);
Assert.AreEqual(10m, position.PosiGrossPrice);
}
[TestMethod]
public void SplitPointOneScalesQuantityAndPriceByPointOne()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(split: 0.1m);
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(10m, position.PosiQuantity);
Assert.AreEqual(1000m, position.PosiGrossPrice);
}
[TestMethod]
public void GiveShareTenWithNullSplitUsesCompatibleFactorTwo()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(giveShare: 10m, split: null);
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(200m, position.PosiQuantity);
Assert.AreEqual(50m, position.PosiGrossPrice);
}
[TestMethod]
public void GiveShareFiveAndSplitTwoHaveCombinedFactorThree()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(giveShare: 5m, split: 2m);
// (1 + 5 / 10) * 2 = 3100 份/100 元变为 300 份/约 33.333333333 元。
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(300m, position.PosiQuantity);
Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 33.333333333m) < 0.000000001m);
}
[TestMethod]
public void CashAmountDoesNotChangeTrsFundInitialPriceFactor()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(cash: 10m);
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(100m, position.PosiQuantity);
Assert.AreEqual(100m, position.PosiGrossPrice);
}
[TestMethod]
public void RationedSharesUseExcelPriceRatioForTrsQuantity()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(
rationedSharesAmount: 1m,
rationedSharesPrice: 50m);
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
// Excel L-NL=(100*10+1*50)/(10+1)=95.4545...M=100/L
// 因此数量和价格分别按 Q'=Q*M、P'=P/M 调整。
Assert.IsTrue(Math.Abs(position.PosiQuantity - 104.761904761905m) < 0.000000000001m);
Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 95.454545455m) < 0.000000001m);
}
[TestMethod]
public void ZeroSplitIsRejected()
{
var info = CreateCorporateAction(split: 0m);
Assert.ThrowsException<ArgumentOutOfRangeException>(() =>
SwapEodPositionService.ApplyCorporateActionToPosition(
CreateFundPosition(), info, 100m, 0m));
}
[TestMethod]
public void NegativeSplitIsRejected()
{
var info = CreateCorporateAction(split: -1m);
Assert.ThrowsException<ArgumentOutOfRangeException>(() =>
SwapEodPositionService.ApplyCorporateActionToPosition(
CreateFundPosition(), info, 100m, 0m));
}
[TestMethod]
public void MissingSplitDoesNotClearExistingSplitDuringMerge()
{
var target = CreateCorporateAction(split: 10m);
var source = CreateCorporateAction(split: null);
InvokeMerge(target, source);
Assert.AreEqual(10m, GetSplit(target));
}
[TestMethod]
public void ExplicitSplitOneOverridesExistingSplitDuringMerge()
{
var target = CreateCorporateAction(split: 10m);
var source = CreateCorporateAction(split: 1m);
InvokeMerge(target, source);
Assert.AreEqual(1m, GetSplit(target));
}
private static ex_dividend_info CreateCorporateAction(
decimal cash = 0m,
decimal giveShare = 0m,
decimal? split = null,
decimal rationedSharesAmount = 0m,
decimal rationedSharesPrice = 0m)
{
var info = new ex_dividend_info
{
UnderlyingCode = "FUND.TEST",
ExDividendDate = new DateTime(2026, 8, 14),
EffectiveDate = new DateTime(2026, 8, 17),
GiveCashAmount = cash,
GiveShareAmount = giveShare,
RationedSharesAmount = rationedSharesAmount,
RationedSharesPrice = rationedSharesPrice,
ValidStatus = true
};
SetSplit(info, split);
return info;
}
private static swap_position CreateFundPosition()
{
return new swap_position
{
PosiDirection = 1,
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund,
UnderlyingCode = "FUND.TEST",
PosiQuantity = 100m,
PosiGrossPrice = 100m,
PosiNetPrice = 100m,
PosiNetFeePrice = 100m,
PosiNetNoFeePrice = 100m,
ContractSize = 1m
};
}
private static void SetSplit(ex_dividend_info info, decimal? value)
{
var property = typeof(ex_dividend_info).GetProperty("Split");
Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现");
property.SetValue(info, value);
}
private static decimal? GetSplit(ex_dividend_info info)
{
var property = typeof(ex_dividend_info).GetProperty("Split");
Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现");
return (decimal?)property.GetValue(info);
}
private static void InvokeMerge(ex_dividend_info target, ex_dividend_info source)
{
var method = typeof(DividendService).GetMethod(
"MergeNonZeroDividendValues",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.IsNotNull(method, "公司行为存量合并方法不存在");
method.Invoke(null, new object[] { target, source });
}
}
}
@@ -57,6 +57,7 @@ namespace YLErp.Modules.SwapModule
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)
@@ -93,6 +94,16 @@ namespace YLErp.Modules.SwapModule
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
@@ -144,7 +155,7 @@ namespace YLErp.Modules.SwapModule
PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false,
PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m,
PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m,
UnderlyingCode = "220205.IB", ContractSize = 1m,
UnderlyingCode = "220205.IB", UnderlyingPrice = grossPrice, ContractSize = 1m,
InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty
};
}
@@ -161,6 +172,41 @@ namespace YLErp.Modules.SwapModule
};
}
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
// ================================================================
@@ -238,6 +284,275 @@ namespace YLErp.Modules.SwapModule
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:未收盘抛异常
// ================================================================
@@ -24,6 +24,13 @@ namespace YLErp.Modules.SwapModule
public int SaveAllChangesCount;
public int CloseReCheckCallCount;
/// <summary>Fund 盤中基线测试输入;生产服务通过数据库查询同名 seam。</summary>
public swap_position RealtimeFloatPosition { get; set; }
public eod_swap_position LatestFundEodPosition { get; set; }
public bool HasCompletedFlowAfterLatestFundEod { get; set; }
public List<swap_position> ActiveSwapPositions { get; set; } = new();
public List<ex_dividend_info> ExDividendInfos { get; } = new();
public TestableSwapDealService(trade td,
Dictionary<int, swap_event> swapEvents = null,
Dictionary<long, List<swap_flow_event>> flowEventsByEventId = null)
@@ -36,6 +43,45 @@ namespace YLErp.Modules.SwapModule
protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null;
protected override List<swap_position> FindActiveSwapPositions(int tradeId)
=> ActiveSwapPositions;
protected override swap_position FindRealtimeFloatPosition(UnwindData unwindData)
=> RealtimeFloatPosition;
protected override eod_swap_position FindLatestFundEodPosition(int tradeId, long positionId, DateTime valueDate)
=> LatestFundEodPosition;
protected override bool HasCompletedFlowAfterFundEod(int tradeId, long positionId, DateTime eodDate, DateTime valueDate)
=> HasCompletedFlowAfterLatestFundEod;
protected override ex_dividend_info FindFundCorporateAction(string underlyingCode, DateTime valueDate)
=> ExDividendInfos.FirstOrDefault(x => x.ValidStatus
&& x.UnderlyingCode == underlyingCode
&& x.EffectiveDate == valueDate.Date);
protected override List<ex_dividend_info> FindFundCorporateActions(
string underlyingCode,
DateTime eodDate,
DateTime valueDate)
=> ExDividendInfos
.Where(x => x.ValidStatus
&& x.UnderlyingCode == underlyingCode
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value.Date > eodDate.Date
&& x.EffectiveDate.Value.Date <= valueDate.Date)
.OrderBy(x => x.EffectiveDate)
.ThenBy(x => x.id)
.ToList();
protected override decimal GetFundCorporateActionClosePrice(
ex_dividend_info dividendInfo,
decimal fallbackPrice)
=> fallbackPrice;
public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate)
=> TryRestoreAndValidateUnwindData(unwindData, valueDate);
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
{
ClientCashCalls.Add((amount, action, valueDate));
@@ -42,6 +42,12 @@ namespace YLErp.Modules.SwapModule
/// <summary>AddClientCash 调用记录(金额, 操作)</summary>
public List<(double amount, string action)> ClientCashCalls { get; } = new();
/// <summary>SwapPositionCompose 使用的公司行为内存数据;默认空,避免测试访问数据库。</summary>
public List<ex_dividend_info> ExDividendInfos { get; } = new();
/// <summary>捕获公司行为生命周期事件,避免事件测试访问真实 swap_event 表。</summary>
public List<swap_event> CorporateActionEvents { get; } = new();
/// <summary>自增 id 模拟器(新增 eod 时分配 id</summary>
private int _nextId = 1;
@@ -78,6 +84,28 @@ namespace YLErp.Modules.SwapModule
return 1.0; // 本币,汇率=1
}
protected override List<ex_dividend_info> FindCorporateActionInfos(DateTime settleDate)
{
return ExDividendInfos
.Where(x => x.ValidStatus
&& (x.ExDividendDate?.Date == settleDate.Date
|| x.EffectiveDate?.Date == settleDate.Date))
.ToList();
}
protected override List<swap_event> FindCorporateActionEvents(int swapTradeId)
{
return CorporateActionEvents
.Where(x => x.SwapTradeId == swapTradeId && !x.Invalid
&& x.EventType == (int)SwapEventTypeEnum.)
.ToList();
}
protected override decimal GetFundCorporateActionClosePrice(
ex_dividend_info dividendInfo,
decimal fallbackPrice)
=> fallbackPrice;
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
{
ClientCashCalls.Add((amount, action));
@@ -0,0 +1,20 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
namespace YLErp.UnitTestProject.Modules.UnderlyingModule
{
[TestClass]
public class UnderlyingFundManagerMappingTest
{
[TestMethod]
public void InvestAdvisorName_MapsExistingFundManagerColumn()
{
var property = typeof(underlying_manager).GetProperty("InvestAdvisorName");
Assert.IsNotNull(property, "underlying_manager 应公开基金管理人属性 InvestAdvisorName");
Assert.AreEqual("investadvisorname", property.GetCustomAttribute<ColumnAttribute>()?.Name);
Assert.AreEqual("基金管理人", property.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName);
}
}
}