Merge remote-tracking branch 'dest/glms/feature/1.4.2' into test
This commit is contained in:
@@ -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.IB(2006年国债,2026估值日已无剩余现金流)——
|
||||
/// jquantlib 对空现金流求解得 ytm=0、净/全价均为面值100,errCode=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=2,GiveShareAmount 不参与该拆分。
|
||||
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<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 = 3:100 份/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-N:L=(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.9,1000 份应调整为 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user