Merge branch 'glms/feature/1.4.2' of http://git.yiliantech.com/gitlab/otc-dev/zszq-trs into glms/feature/1.4.2

This commit is contained in:
锦麟 王
2026-08-26 10:23:16 +08:00
39 changed files with 1542 additions and 170 deletions
@@ -0,0 +1,47 @@
using System;
using System.IO;
namespace YLErp.Modules.EodModule
{
[TestClass]
public class BondPaymentListQueryBoundaryTest
{
[TestMethod]
public void SearchList_OnlyQueriesBondPaymentTable()
{
var source = ReadBondPaymentServiceSource();
var searchList = ExtractMethod(source, "public SearchListResult<BondPaymentDto> SearchList", "public BondPayment SaveBondPayment");
Assert.IsFalse(
searchList.Contains("ex_dividend_info", StringComparison.Ordinal),
"债券付息列表只能查询 bond_payment_info,不能把公司行为除权表拼入展示结果。");
StringAssert.Contains(searchList, "DbContext.bondPayment");
}
private static string ExtractMethod(string source, string startMarker, string endMarker)
{
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.IsTrue(start >= 0, $"Could not find method: {startMarker}");
var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
Assert.IsTrue(end >= 0, $"Could not find method end: {endMarker}");
return source.Substring(start, end - start);
}
private static string ReadBondPaymentServiceSource()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null)
{
var path = Path.Combine(directory.FullName, "YLErpDAL", "Modules", "EodModule", "BondPaymentService.cs");
if (File.Exists(path))
{
return File.ReadAllText(path);
}
directory = directory.Parent;
}
Assert.Fail("Could not locate BondPaymentService.cs from the test output directory.");
return string.Empty;
}
}
}
@@ -0,0 +1,59 @@
using System;
using System.IO;
namespace YLErp.Modules.EodModule
{
[TestClass]
public class DividendInfoEditGuardMessageTest
{
[TestMethod]
public void AddDividendInfos_ReportsTheReferencingTradeNumberWhenAnExecutedActionIsEdited()
{
var source = ReadDividendServiceSource();
var addDividendInfos = ExtractMethod(source, "public bool AddDividendInfos", "public bool checkDividendInfoExecuteStatus");
var controllerSource = ReadExDividendInfoControllerSource();
var deleteDividend = ExtractMethod(controllerSource, "public JsonResult deleteDividend", "public JsonResult ImportDividendInfo");
Assert.IsTrue(addDividendInfos.Contains("不可修改,有交易【", StringComparison.Ordinal));
Assert.IsTrue(addDividendInfos.Contains("使用了该条除权除息数据", StringComparison.Ordinal));
Assert.IsTrue(deleteDividend.Contains("不可修改,有交易【", StringComparison.Ordinal));
Assert.IsTrue(deleteDividend.Contains("使用了该条除权除息数据", StringComparison.Ordinal));
}
private static string ExtractMethod(string source, string startMarker, string endMarker)
{
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.IsTrue(start >= 0, $"Could not find method: {startMarker}");
var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
Assert.IsTrue(end >= 0, $"Could not find method end: {endMarker}");
return source.Substring(start, end - start);
}
private static string ReadDividendServiceSource()
{
return ReadSource("YLErpDAL", "Modules", "TradeModule", "DealModule", "DividendService.cs");
}
private static string ReadExDividendInfoControllerSource()
{
return ReadSource("YLErpWeb", "Controllers", "ex_dividend_infoController.cs");
}
private static string ReadSource(params string[] relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null)
{
var path = Path.Combine(new[] { directory.FullName }.Concat(relativePath).ToArray());
if (File.Exists(path))
{
return File.ReadAllText(path);
}
directory = directory.Parent;
}
Assert.Fail("Could not locate DividendService.cs from the test output directory.");
return string.Empty;
}
}
}
@@ -0,0 +1,210 @@
using YLErp.Abstract;
using YLErp.Helpers;
using YLErp.Modules.EodModule;
namespace YLErp.Modules.EodModuleTests
{
[TestClass]
public class TrsContractKafkaPushServiceTest
{
[TestMethod]
public void Push_空日快照_发送一条空消息并使用业务日期作为Key()
{
var valueDate = new DateTime(2026, 8, 24);
var producer = new RecordingKafkaProducer();
var service = CreateService(producer, valueDate);
service.Push(valueDate);
Assert.AreEqual(1, producer.Messages.Count);
Assert.AreEqual("onederiv.trs.contract.v1", producer.Messages[0].Topic);
Assert.AreEqual("2026-08-24", producer.Messages[0].Key);
var payload = JsonHelper.Deserialize<TrsContractSnapshot>(producer.Messages[0].Message);
Assert.AreEqual("2026-08-24", payload.ValueDate);
Assert.AreEqual(0, payload.ContractCount);
Assert.AreEqual(0, payload.Contracts.Count);
}
[TestMethod]
public void Push_区间内每天分别调用_每个日期各发送一条快照()
{
var valueDates = new[]
{
new DateTime(2026, 8, 20),
new DateTime(2026, 8, 21),
new DateTime(2026, 8, 24)
};
var producer = new RecordingKafkaProducer();
var service = new TestableTrsContractKafkaPushService(producer, valueDates.ToDictionary(x => x, CreateEmptySnapshot));
foreach (var valueDate in valueDates)
{
service.Push(valueDate);
}
CollectionAssert.AreEqual(
new[] { "2026-08-20", "2026-08-21", "2026-08-24" },
producer.Messages.Select(x => x.Key).ToArray());
CollectionAssert.AreEqual(
new[] { "2026-08-20", "2026-08-21", "2026-08-24" },
producer.Messages.Select(x => JsonHelper.Deserialize<TrsContractSnapshot>(x.Message).ValueDate).ToArray());
}
[TestMethod]
public void Push_首次失败后成功_停止重试且不记录最终失败()
{
var valueDate = new DateTime(2026, 8, 24);
var producer = new RecordingKafkaProducer { FailuresBeforeSuccess = 1 };
var service = CreateService(producer, valueDate);
service.Push(valueDate);
Assert.AreEqual(2, producer.AttemptCount);
Assert.AreEqual(1, producer.Messages.Count);
Assert.AreEqual(0, service.FailureRecords.Count);
}
[TestMethod]
public void Push_连续失败三次_记录最终失败和三次尝试()
{
var valueDate = new DateTime(2026, 8, 24);
var producer = new RecordingKafkaProducer { FailuresBeforeSuccess = int.MaxValue };
var service = CreateService(producer, valueDate);
service.Push(valueDate);
Assert.AreEqual(3, producer.AttemptCount);
Assert.AreEqual(0, producer.Messages.Count);
Assert.AreEqual(1, service.FailureRecords.Count);
Assert.AreEqual(valueDate, service.FailureRecords[0].ValueDate);
Assert.AreEqual(3, service.FailureRecords[0].RetryCount);
Assert.IsInstanceOfType(service.FailureRecords[0].Exception, typeof(InvalidOperationException));
}
[TestMethod]
public void BuildContract_字段使用日终快照和约定来源()
{
var valueDate = new DateTime(2026, 8, 24);
var eodSwap = new eod_swap
{
id = 10,
ValueDate = valueDate,
SwapTradeId = 7,
SwapTradeNo = "TRS-001",
BookId = 3,
ClientId = 8,
NotionalValue = 1000000m,
dv01 = 12.34m,
InitMarginGain = 100m,
InitMarginLoss = 0m
};
var trade = new trade
{
id = 7,
UnderlyingCode = "600000.SH",
UnderlyingAssetName = "浦发银行",
UnderlyingInstrumentType = "Stock",
StartDate = new DateTime(2026, 8, 1),
ExerciseDate = new DateTime(2027, 8, 1)
};
var positions = new List<eod_swap_position>
{
new() { SwapTradeId = 7, PositionId = 101, UnderlyingCode = "600000.SH", PositionType = 1 },
new() { SwapTradeId = 7, PositionId = 102, InterestMode = (int)InterestModeEnum., InterestRateDefault = 0.0123m, InterestDirection = 2 }
};
var swapPositions = new Dictionary<long, swap_position>
{
[101] = new() { id = 101, category_tag = "互换利率" },
[102] = new() { id = 102, category_tag = "互换利率" }
};
var item = TrsContractKafkaPushService.BuildContract(
eodSwap,
new Dictionary<int, trade> { [7] = trade },
positions,
swapPositions);
Assert.AreEqual("2026-08-24", item.TradeDate);
Assert.AreEqual(3, item.BookId);
Assert.AreEqual("TRS-001", item.SwapTradeNo);
Assert.AreEqual(8, item.ClientId);
Assert.AreEqual("600000.SH", item.UnderlyingCode);
Assert.AreEqual("浦发银行", item.UnderlyingName);
Assert.AreEqual("Stock", item.UnderlyingInstrumentType);
Assert.AreEqual(1000000m, item.NotionalValue);
Assert.AreEqual("2026-08-01", item.StartDate);
Assert.AreEqual("2027-08-01", item.MaturityDate);
Assert.AreEqual(12.34m, item.Dv01);
Assert.AreEqual(0.0123m, item.FixedRate);
Assert.AreEqual(2, item.InterestDirection);
Assert.AreEqual(1, item.FloatingDirection);
Assert.AreEqual(100m, item.InitMarginGain);
Assert.AreEqual(0m, item.InitMarginLoss);
}
private static TestableTrsContractKafkaPushService CreateService(RecordingKafkaProducer producer, DateTime valueDate)
{
return new TestableTrsContractKafkaPushService(
producer,
new Dictionary<DateTime, TrsContractSnapshot> { [valueDate] = CreateEmptySnapshot(valueDate) });
}
private static TrsContractSnapshot CreateEmptySnapshot(DateTime valueDate)
{
return new TrsContractSnapshot
{
SchemaVersion = "v1",
ValueDate = valueDate.ToString("yyyy-MM-dd"),
PushTime = "2026-08-24 12:00:00",
ContractCount = 0,
Contracts = new List<TrsContractSnapshotItem>()
};
}
private sealed class TestableTrsContractKafkaPushService : TrsContractKafkaPushService
{
private readonly IReadOnlyDictionary<DateTime, TrsContractSnapshot> _snapshots;
public List<(DateTime ValueDate, int RetryCount, Exception Exception)> FailureRecords { get; } = new();
public TestableTrsContractKafkaPushService(IKafkaProduce producer, IReadOnlyDictionary<DateTime, TrsContractSnapshot> snapshots)
: base(new YLContext(), producer, "onederiv.trs.contract.v1")
{
_snapshots = snapshots;
}
protected override TrsContractSnapshot BuildSnapshot(DateTime valueDate)
{
return _snapshots[valueDate];
}
protected override void RecordFailures(DateTime valueDate, int retryCount, Exception exception)
{
FailureRecords.Add((valueDate, retryCount, exception));
}
}
private sealed class RecordingKafkaProducer : IKafkaProduce
{
public int FailuresBeforeSuccess { get; set; }
public int AttemptCount { get; private set; }
public List<(string Topic, string Key, string Message)> Messages { get; } = new();
public void Produce(string topic, string message)
{
throw new NotSupportedException();
}
public void Produce(string topic, string key, string message)
{
AttemptCount++;
if (AttemptCount <= FailuresBeforeSuccess)
{
throw new InvalidOperationException("Kafka unavailable");
}
Messages.Add((topic, key, message));
}
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.DBModels;
@@ -20,6 +21,7 @@ namespace YLErp.Modules.SwapModule
public void RegistrationSnapshot_IsPending_AndKeepsBeforeFields()
{
var info = CreateAction(77, ConsGlobal.InstrumentType.Stock);
info.GiveShareAmount = 1m;
var before = CreateEodPosition(9, info.UnderlyingCode, 1000m, 100m);
var snapshot = SwapEodPositionService.BuildCorporateActionEventData(
@@ -37,8 +39,9 @@ namespace YLErp.Modules.SwapModule
Assert.IsFalse(snapshot.Applied);
var reason = SwapEventService.BuildCorporateActionEventReason(snapshot);
StringAssert.Contains(reason, "BeforeQuantity=1000");
StringAssert.Contains(reason, "AfterQuantity=0");
StringAssert.Contains(reason, "股权登记日:2026-08-14 发生公司行为(送股)");
StringAssert.Contains(reason, "调整前:名义本金:100000 期初标的价格:100 持仓数量:1000");
StringAssert.Contains(reason, "调整后:名义本金:0 期初标的价格:0 持仓数量:0");
}
[TestMethod]
@@ -65,6 +68,23 @@ namespace YLErp.Modules.SwapModule
Assert.IsFalse(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.TBonds));
}
[TestMethod]
public void CorporateActionReason_ShowsOneActionTypeOrActualCashDividend()
{
AssertActionDescription(
new CorporateActionEventData { ExDividendDate = RecordDate, RationedSharesAmount = 1m, GiveShareAmount = 1m, Split = 2m, GiveCashAmount = 10m, CashFlowChange = 1000m },
"发生公司行为(配股)");
AssertActionDescription(
new CorporateActionEventData { ExDividendDate = RecordDate, GiveShareAmount = 1m, Split = 2m, GiveCashAmount = 10m, CashFlowChange = 1000m },
"发生公司行为(送股)");
AssertActionDescription(
new CorporateActionEventData { ExDividendDate = RecordDate, Split = 2m, GiveCashAmount = 10m, CashFlowChange = 1000m },
"发生公司行为(拆分)");
AssertActionDescription(
new CorporateActionEventData { ExDividendDate = RecordDate, GiveCashAmount = 11m, CashFlowChange = 220000m },
"发生公司行为(产生分红:220000)");
}
[TestMethod]
public void Rerun_DoesNotCreateDuplicateCorporateActionEvent()
{
@@ -113,7 +133,7 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
public void OperationHistory_PreservesPendingCorporateActionForAudit()
public void OperationHistory_HidesPendingCorporateActionUntilItIsApplied()
{
var info = CreateAction(80, ConsGlobal.InstrumentType.Stock);
var pendingData = SwapEodPositionService.BuildCorporateActionEventData(
@@ -133,12 +153,17 @@ namespace YLErp.Modules.SwapModule
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);
var filter = typeof(SwapEventService).GetMethod(
"FilterOperationHistory",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(filter, "操作历史必须过滤登记日创建的待生效公司行为事件。");
var visibleEvents = (List<swap_event>)filter.Invoke(null, new object[] { events });
Assert.AreEqual(2, visibleEvents.Count);
Assert.IsFalse(visibleEvents.Any(x => x.id == 1));
Assert.IsTrue(visibleEvents.Any(x => x.id == 2));
Assert.IsTrue(visibleEvents.Any(x => x.id == 3));
}
[TestMethod]
@@ -283,6 +308,11 @@ namespace YLErp.Modules.SwapModule
};
}
private static void AssertActionDescription(CorporateActionEventData data, string expected)
{
StringAssert.Contains(SwapEventService.BuildCorporateActionEventReason(data), expected);
}
private static eod_swap_position CreateEodPosition(long positionId, string code, decimal quantity, decimal price)
{
return new eod_swap_position
@@ -0,0 +1,178 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule;
/// <summary>
/// EQD-7084 新“框架合约”Tab 的口径测试。
/// 纯计算测试不依赖数据库,直接锁定 EodPnlCalculator 的新口径。
/// </summary>
[TestClass]
public class SwapEodPositionRiskNewTabTest
{
[TestMethod]
public void _普通利息排除保证金_浮动收益剔除费用并保留估值总额()
{
var floating = new[]
{
// EOD 归一后,费用已经按我方收益视角落库;支付费用为负数。
FloatingLeg("600000", 1, 100m, 0m, -12m, "普通收益互换")
};
var interests = new[]
{
InterestLeg(1, (int)InterestModeEnum., 80m, 0.02m, 0.02m),
InterestLeg(1, (int)InterestModeEnum., 20m, 0.01m, 0.01m)
};
var fields = InvokeCalculation(
floating,
interests,
structureType: "普通收益互换",
notionalValue: 1_000m,
startDate: new DateTime(2026, 1, 1),
maturityDate: new DateTime(2026, 12, 31),
periodAmount: 5m,
dividendPayDate: 0);
Assert.AreEqual(100m, GetDecimal(fields, "FloatingUnrealizedPnl"), 0.0001m,
"新浮动端待实现收益应排除 PosiFeePendingPosiProfitSum(88) - PosiFeePending(-12) = 100");
Assert.AreEqual(-12m, GetDecimal(fields, "OpeningClosingFee"), 0.0001m,
"开平仓费用直接使用 EOD 已归一的 PosiFeePending");
Assert.AreEqual(80m, GetDecimal(fields, "OrdinaryInterestPnl"), 0.0001m,
"利息端待实现收益应排除初始/维持保证金腿");
Assert.AreEqual(-20m, GetDecimal(fields, "MarginInterestAmount"), 0.0001m,
"保证金利息仍应按保证金腿方向计入估值");
Assert.AreEqual(153m, GetDecimal(fields, "MaturityNettingValuation"), 0.0001m,
"估值应保持旧口径:100 - 12 + 80 - 20 + 5 = 153;费用只计一次");
}
[TestMethod]
public void _当前利率合计使用普通利息腿TdInterestRate_并识别FR007()
{
var fr007Leg = InterestLeg(2, (int)InterestModeEnum., 20m, 0.03m, 0.018m);
fr007Leg.FloatRateUnderlyingCode = "FR007";
fr007Leg.FloatRate = 0.015m;
var fields = InvokeCalculation(
new[] { FloatingLeg("600001", 2, 100m, 0m, 0m, "普通收益互换") },
new[]
{
InterestLeg(1, (int)InterestModeEnum., 10m, 0.02m, 0.0125m),
fr007Leg
},
structureType: "普通收益互换",
notionalValue: 100m,
startDate: new DateTime(2026, 2, 1),
maturityDate: new DateTime(2026, 8, 1),
periodAmount: 0m,
dividendPayDate: 1);
Assert.AreEqual(0.0305m, GetDecimal(fields, "InterestRatePrice"), 0.0000001m,
"利率端价格应为普通利息腿当前 TdInterestRate 合计,而非默认利差合计");
Assert.AreEqual("FR007", GetString(fields, "InterestBenchmark"));
}
[TestMethod]
public void _普通利息腿无FR007时基准为固定利率()
{
var fields = InvokeCalculation(
new[] { FloatingLeg("600002", 1, 100m, 0m, 0m, "普通收益互换") },
new[] { InterestLeg(1, (int)InterestModeEnum., 10m, 0.02m, 0.0125m) },
structureType: "普通收益互换",
notionalValue: 100m,
startDate: new DateTime(2026, 2, 1),
maturityDate: new DateTime(2026, 8, 1),
periodAmount: 0m,
dividendPayDate: 1);
Assert.AreEqual("固定利率", GetString(fields, "InterestBenchmark"));
}
[TestMethod]
public void _债券期初价格按风险页约定放大百分价格_并保留合同字段()
{
var fields = InvokeCalculation(
new[] { FloatingLeg("110000", 1, 99.12m, 0m, 0m, "普通债券类收益互换", "Bond") },
new[] { InterestLeg(1, (int)InterestModeEnum., 1m, 0.01m, 0.01m) },
structureType: "普通债券类收益互换",
notionalValue: 9_900m,
startDate: new DateTime(2026, 3, 1),
maturityDate: new DateTime(2027, 3, 1),
periodAmount: 0m,
dividendPayDate: 1);
Assert.AreEqual(99.12m, GetDecimal(fields, "InitialPrice"), 0.0001m,
"债券日终 PosiGrossPrice 已由 SetPosiPrice 按风险页口径缩放,新接口不能再次乘 100");
Assert.AreEqual(9_900m, GetDecimal(fields, "NotionalQuantity"), 0.0001m);
Assert.AreEqual("多头", GetString(fields, "UnderlyingDirection"));
Assert.AreEqual("110000", GetString(fields, "UnderlyingCode"));
Assert.AreEqual("Bond", GetString(fields, "UnderlyingInstrumentType"));
Assert.AreEqual(new DateTime(2026, 3, 1), GetDate(fields, "ContractStartDate"));
Assert.AreEqual(new DateTime(2027, 3, 1), GetDate(fields, "ContractMaturityDate"));
}
private static object InvokeCalculation(
IEnumerable<eod_swap_position> floating,
IEnumerable<eod_swap_position> interests,
string structureType,
decimal notionalValue,
DateTime startDate,
DateTime maturityDate,
decimal periodAmount,
int dividendPayDate)
{
return EodPnlCalculator.CalculateEodSwapRiskNewFields(
floating,
interests,
structureType,
notionalValue,
startDate,
maturityDate,
periodAmount,
dividendPayDate);
}
private static decimal GetDecimal(object fields, string name)
=> Convert.ToDecimal(fields.GetType().GetProperty(name)!.GetValue(fields));
private static string GetString(object fields, string name)
=> (string)fields.GetType().GetProperty(name)!.GetValue(fields)!;
private static DateTime GetDate(object fields, string name)
=> (DateTime)fields.GetType().GetProperty(name)!.GetValue(fields)!;
private static eod_swap_position FloatingLeg(
string code,
int positionType,
decimal mtm,
decimal dividend,
decimal fee,
string structureType,
string instrumentType = null)
=> new()
{
UnderlyingCode = code,
UnderlyingInstrumentType = instrumentType ?? structureType,
PositionType = positionType,
PosiGrossPrice = mtm,
PosiMtmPnL = mtm,
PosiDividendSum = dividend,
PosiFeePending = fee,
PosiProfitSum = mtm + dividend + fee,
PosiNotionalValue = 100m
};
private static eod_swap_position InterestLeg(
int direction,
int mode,
decimal profit,
decimal defaultRate,
decimal currentRate)
=> new()
{
InterestDirection = direction,
InterestMode = mode,
InterestProfitSum = profit,
InterestRateDefault = defaultRate,
TdInterestRate = currentRate
};
}