Merge remote-tracking branch 'dest/glms/feature/1.4.2' into feature/p132_74-risk-engine
This commit is contained in:
@@ -21,7 +21,7 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
#region Stub
|
||||
|
||||
private sealed class StubService : SwapEodPositionService
|
||||
private sealed class StubService : TestableSwapEodPositionService
|
||||
{
|
||||
// 注入的内存数据
|
||||
public List<swap_flow_event> FlowEvents { get; set; } = new();
|
||||
@@ -30,7 +30,7 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
public List<ClientCashInCashOut> DeletedRecords { get; } = new();
|
||||
|
||||
public StubService() : base(new OptUserInfo(0, nameof(ClearSwapPositionsScenarioTest), OptUserFrom.UnitTest))
|
||||
public StubService() : base(nameof(ClearSwapPositionsScenarioTest))
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -22,13 +22,13 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
#region Stub
|
||||
|
||||
private sealed class StubEodService : SwapEodPositionService
|
||||
private sealed class StubEodService : TestableSwapEodPositionService
|
||||
{
|
||||
public List<eod_swap_position> CreatedEodPositions { get; } = new();
|
||||
public int ClientCashCallCount { get; private set; }
|
||||
private int _nextId = 1;
|
||||
// 输出别名(转发到基类捕获属性,保持测试断言不变)
|
||||
public List<eod_swap_position> CreatedEodPositions => PersistedPositions;
|
||||
public int ClientCashCallCount => ClientCashCalls.Count;
|
||||
|
||||
public StubEodService() : base(new OptUserInfo(0, nameof(ComposePageScenarioTest), OptUserFrom.UnitTest))
|
||||
public StubEodService() : base(nameof(ComposePageScenarioTest))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -56,13 +56,7 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason)
|
||||
{
|
||||
return new swap_event { id = _nextId++, SwapTradeId = swapTradeId, EventType = eventType, ValueDate = tradeDate, EventData = data };
|
||||
}
|
||||
|
||||
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
|
||||
{
|
||||
ClientCashCallCount++;
|
||||
return _nextId++;
|
||||
return new swap_event { id = 1, SwapTradeId = swapTradeId, EventType = eventType, ValueDate = tradeDate, EventData = data };
|
||||
}
|
||||
|
||||
protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate)
|
||||
@@ -75,17 +69,6 @@ namespace YLErp.Modules.SwapModule
|
||||
// 不做任何事(测试无历史事件需清理)
|
||||
}
|
||||
|
||||
protected override void PersistEodSwapPosition(eod_swap_position position)
|
||||
{
|
||||
if (position.id == 0) position.id = _nextId++;
|
||||
CreatedEodPositions.Add(position);
|
||||
}
|
||||
|
||||
protected override void SaveAllChanges() { }
|
||||
|
||||
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType)
|
||||
=> 1.0;
|
||||
|
||||
// override SaveEodPosition:捕获生成的 eod,绕过 UpdateSwapPosition 连库
|
||||
protected override decimal SaveEodPosition(eod_swap_position newEodPayPosition,
|
||||
trade td, swap_flow_event eventFlow,
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
#region Stub
|
||||
|
||||
private sealed class StubEodService : SwapEodPositionService
|
||||
private sealed class StubEodService : TestableSwapEodPositionService
|
||||
{
|
||||
// 可注入的外部数据
|
||||
public decimal UnderlyingPrice { get; set; } = 1.00m;
|
||||
@@ -34,7 +34,7 @@ namespace YLErp.Modules.SwapModule
|
||||
public decimal TaxRate { get; set; } = 0m;
|
||||
public string UnderlyingCode { get; set; } = "210210.IB";
|
||||
|
||||
public StubEodService() : base(new OptUserInfo(0, nameof(DealFloatPositionsScenarioTest), OptUserFrom.UnitTest))
|
||||
public StubEodService() : base(nameof(DealFloatPositionsScenarioTest))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -55,11 +55,6 @@ namespace YLErp.Modules.SwapModule
|
||||
return BondPayment;
|
||||
}
|
||||
|
||||
protected override void SaveAllChanges() { }
|
||||
|
||||
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType)
|
||||
=> 1.0;
|
||||
|
||||
// DealFloatPositions 和子方法都是 protected,通过 public 包装暴露
|
||||
public List<eod_swap_position> ExecuteDealFloatPositions(
|
||||
List<swap_position> posiList, List<swap_position> realPosiList,
|
||||
|
||||
@@ -26,29 +26,29 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
#region Stub(复用 DealInterestsScenarioTest 的模式)
|
||||
|
||||
private sealed class StubEodService : SwapEodPositionService
|
||||
private sealed class StubEodService : TestableSwapEodPositionService
|
||||
{
|
||||
public List<eod_swap_position> PersistedPositions { get; } = new();
|
||||
private int _nextId = 1;
|
||||
|
||||
public StubEodService() : base(new OptUserInfo(0, nameof(DealInterestsGoldenReplayTest), OptUserFrom.UnitTest))
|
||||
public StubEodService() : base(nameof(DealInterestsGoldenReplayTest))
|
||||
{
|
||||
}
|
||||
|
||||
protected override void PersistEodSwapPosition(eod_swap_position position)
|
||||
{
|
||||
if (position.id == 0) position.id = _nextId++;
|
||||
PersistedPositions.Add(position);
|
||||
}
|
||||
protected override void SaveAllChanges() { }
|
||||
protected override double GetCurrencyRate(string q, string s, DateTime d, bool p, CurrencyRateType t) => 1.0;
|
||||
|
||||
public void ExecuteSaveEodInterestPosition(
|
||||
eod_swap_position eodPayPosition, swap_position position, trade td,
|
||||
DateTime valueDate, List<swap_flow_event> flowEvents)
|
||||
{
|
||||
SaveEodInterestPosition(eodPayPosition, null, position, td, valueDate, flowEvents);
|
||||
}
|
||||
|
||||
// public 包装:直接调用 protected virtual DealInterests(录制场景2用)
|
||||
public void ExecuteDealInterestsForRecord(
|
||||
List<swap_position> interestList, List<eod_swap_position> eodPositions,
|
||||
DateTime settleDate, trade td,
|
||||
decimal posiLongNational, decimal grossPrice, decimal orginPv)
|
||||
{
|
||||
DealInterests(interestList, eodPositions, new List<eod_swap_position>(),
|
||||
settleDate, td, new List<swap_flow_event>(), new List<swap_flow_event>(), null,
|
||||
posiLongNational, 0m, 0m, grossPrice, orginPv);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -189,17 +189,11 @@ namespace YLErp.Modules.SwapModule
|
||||
};
|
||||
|
||||
var service = new StubEodService();
|
||||
// 通过反射调 DealInterests(copy 分支需要 CalcSwapInterests)
|
||||
var method = typeof(SwapEodPositionService).GetMethod("DealInterests",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
method.Invoke(service, new object[]
|
||||
{
|
||||
// 直接调用 protected virtual DealInterests(copy 分支需要 CalcSwapInterests)
|
||||
service.ExecuteDealInterestsForRecord(
|
||||
new List<swap_position> { position },
|
||||
new List<eod_swap_position> { preEod },
|
||||
new List<eod_swap_position>(),
|
||||
settleDate, td, new List<swap_flow_event>(), new List<swap_flow_event>(), null,
|
||||
Principal, 0m, 0m, 1m, Principal
|
||||
});
|
||||
settleDate, td, Principal, 1m, Principal);
|
||||
|
||||
if (service.PersistedPositions.Count == 0)
|
||||
{
|
||||
|
||||
@@ -40,31 +40,12 @@ namespace YLErp.Modules.SwapModule
|
||||
/// - PersistEodSwapPosition:收集到列表而非写库
|
||||
/// - GetCurrencyRate:返回 1.0(本币)
|
||||
/// </summary>
|
||||
private sealed class StubEodPositionService : SwapEodPositionService
|
||||
private sealed class StubEodPositionService : TestableSwapEodPositionService
|
||||
{
|
||||
public List<eod_swap_position> PersistedPositions { get; } = new();
|
||||
|
||||
public StubEodPositionService() : base(new OptUserInfo(0, nameof(DealInterestsScenarioTest), OptUserFrom.UnitTest))
|
||||
public StubEodPositionService() : base(nameof(DealInterestsScenarioTest))
|
||||
{
|
||||
}
|
||||
|
||||
protected override void PersistEodSwapPosition(eod_swap_position position)
|
||||
{
|
||||
// 收集到列表,不写库。如果 id=0 模拟新增。
|
||||
if (position.id == 0) position.id = PersistedPositions.Count + 1;
|
||||
PersistedPositions.Add(position);
|
||||
}
|
||||
|
||||
protected override void SaveAllChanges()
|
||||
{
|
||||
// 不做任何事(内存模式)
|
||||
}
|
||||
|
||||
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType)
|
||||
{
|
||||
return 1.0; // 本币,汇率=1
|
||||
}
|
||||
|
||||
// override CalcSwapInterests:用真实 SwapDealService 算(固定利率不需 mock 浮动利率)
|
||||
// 生产代码默认实现也是 new SwapDealService(this).GetInterests(...),这里保持一致
|
||||
// 但 SwapDealService 内部 TryGetFloatRate 会连库——固定利率(FloatRateUnderlyingCode=null)不会触发
|
||||
@@ -94,21 +75,16 @@ namespace YLErp.Modules.SwapModule
|
||||
return PersistedPositions.LastOrDefault();
|
||||
}
|
||||
|
||||
// public 包装:调用 DealInterests(通过反射,因为参数太多不好包)
|
||||
// public 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射)
|
||||
public void ExecuteDealInterests(
|
||||
List<swap_position> interestList, List<eod_swap_position> eodPositions,
|
||||
DateTime settleDate, trade td, List<swap_flow_event> flowEvents,
|
||||
decimal posiLongNational, decimal posiShortNational,
|
||||
decimal closeNational, decimal grossPrice, decimal orginPv)
|
||||
{
|
||||
var method = typeof(SwapEodPositionService).GetMethod("DealInterests",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
method.Invoke(this, new object[]
|
||||
{
|
||||
interestList, eodPositions, new List<eod_swap_position>(),
|
||||
DealInterests(interestList, eodPositions, new List<eod_swap_position>(),
|
||||
settleDate, td, flowEvents, new List<swap_flow_event>(), null,
|
||||
posiLongNational, posiShortNational, closeNational, grossPrice, orginPv
|
||||
});
|
||||
posiLongNational, posiShortNational, closeNational, grossPrice, orginPv);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -212,10 +212,10 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
#region 回放用 Stub
|
||||
|
||||
private sealed class ReplayStubService : SwapEodPositionService
|
||||
private sealed class ReplayStubService : TestableSwapEodPositionService
|
||||
{
|
||||
private readonly string _underlyingCode;
|
||||
public ReplayStubService(string underlyingCode) : base(new OptUserInfo(0, "Replay", OptUserFrom.UnitTest))
|
||||
public ReplayStubService(string underlyingCode) : base("Replay")
|
||||
{
|
||||
_underlyingCode = underlyingCode;
|
||||
}
|
||||
@@ -238,9 +238,6 @@ namespace YLErp.Modules.SwapModule
|
||||
return 0m;
|
||||
}
|
||||
|
||||
protected override void SaveAllChanges() { }
|
||||
protected override double GetCurrencyRate(string q, string s, DateTime d, bool p, CurrencyRateType t) => 1.0;
|
||||
|
||||
public eod_swap_position ExecuteUpdateEodPosition(
|
||||
swap_position swapPosition, eod_swap_position eod, trade td,
|
||||
DateTime valueDate, DateTime preSettleDate, List<swap_flow_event> unwindEvents)
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// GLMS-20260703-0002 债券TRS 平仓利息差异 - 仅调用系统函数复现
|
||||
/// ============================================================================
|
||||
/// 客户测试环境这笔债券TRS(截图确认):
|
||||
/// 成交/持仓名义本金 = 279,486,108.21
|
||||
/// 起息日(StartDate=SettleDate) = 2026-07-06,平仓日(UnwindDate) = 2026-07-20
|
||||
/// 年化天数 = 365,计息模式 = 算头不算尾("10"),计息天数 = 14 天
|
||||
/// 利率 = FR007-1.55%(浮动利率腿),重置频率 = 7 天,interest_rule = 0(当前营业日)
|
||||
/// 截图 FR007:07-06=1.42%, 07-13=1.425%(重置点取当前营业日)
|
||||
///
|
||||
/// 观察结果:
|
||||
/// 系统计算的平仓盈亏(利息端)= -13,667.85
|
||||
/// 实际应得平仓盈亏(利息端)= -13,668.02
|
||||
/// 差异 = 0.17 元
|
||||
///
|
||||
/// 代码走查结论(SwapDealService):
|
||||
/// 平仓路径:GetInterests → CalcSwapDealInterest → CalcUnwindInterest
|
||||
/// → InitSwapDealInterest → CalcDailyCompoundInterest / CalcDailySimpleInterest
|
||||
/// FR007 取数规则:只在"重置日"(i % interest_rest_days == 0) 取一次 FR007,非重置日沿用上一重置日。
|
||||
/// 本例 07-06~07-20 跨 2 个重置周期,取 2 次 FR007:
|
||||
/// - 第一段(07-06~07-12):取 07-06 当前营业日 FR007 = 1.42%
|
||||
/// - 第二段(07-13~07-19):取 07-13 当前营业日 FR007 = 1.425%
|
||||
/// 差异根因:系统配置走 <复利>,导致 07-13 重置日把前 7 天累计利息并入本金,
|
||||
/// 第二段计息本金降为 279,479,140.20,最终利息绝对值比单利少 0.17 元。
|
||||
/// 若按业务口径走 <单利>,则本金全程保持 279,486,108.21,利息 = -13,668.02。
|
||||
///
|
||||
/// 本测试不再做任何手工计算(手算/验算见同目录 Excel:GLMS20260703_平仓利息验算.xlsx),
|
||||
/// 只做:构造输入 → 调用系统真实函数 GetInterests → 与截图已知结果断言。
|
||||
/// ============================================================================
|
||||
[TestClass]
|
||||
public class GLMS20260703CloseInterestTest
|
||||
{
|
||||
#region 内部类:固定利率模拟服务(不触碰 DB,但按日期返回 FR007 截图值)
|
||||
|
||||
private sealed class StubSwapDealService : SwapDealService
|
||||
{
|
||||
public StubSwapDealService(OptUserInfo optUser) : base(optUser) { }
|
||||
|
||||
/// <summary>单元测试记录:生产代码每次调用 TryGetFloatRate 的日期与结果。</summary>
|
||||
public readonly List<(DateTime RequestDate, double Rate)> FloatRateCalls = new();
|
||||
|
||||
/// <summary>
|
||||
/// 按截图 eod_commodity_future_price.ValueDate 返回 FR007 ReferencePrice(小数)。
|
||||
/// 生产环境由 TryGetFloatRate 去行情/DB 取数;单元测试用截图硬编码快照替代。
|
||||
/// </summary>
|
||||
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
|
||||
{
|
||||
rate = 0d;
|
||||
if (underlyingCode != "FR007") return false;
|
||||
|
||||
var fr007 = new Dictionary<DateTime, double>
|
||||
{
|
||||
[new DateTime(2026, 7, 3)] = 0.0143,
|
||||
[new DateTime(2026, 7, 6)] = 0.0142,
|
||||
[new DateTime(2026, 7, 7)] = 0.0143,
|
||||
[new DateTime(2026, 7, 8)] = 0.0143,
|
||||
[new DateTime(2026, 7, 9)] = 0.0143,
|
||||
[new DateTime(2026, 7, 10)] = 0.0142,
|
||||
[new DateTime(2026, 7, 13)] = 0.01425,
|
||||
[new DateTime(2026, 7, 14)] = 0.0143,
|
||||
[new DateTime(2026, 7, 15)] = 0.0144,
|
||||
[new DateTime(2026, 7, 16)] = 0.0144,
|
||||
[new DateTime(2026, 7, 17)] = 0.0144,
|
||||
[new DateTime(2026, 7, 20)] = 0.0143,
|
||||
};
|
||||
|
||||
if (fr007.TryGetValue(valueDate.Date, out rate))
|
||||
{
|
||||
FloatRateCalls.Add((valueDate.Date, rate));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 若请求日期不在硬编码表(如 interest_rule=-1 调到周末),返回最近有值日的 FR007
|
||||
var nearest = fr007.Keys.OrderByDescending(d => d)
|
||||
.FirstOrDefault(d => d <= valueDate.Date);
|
||||
if (nearest != default)
|
||||
{
|
||||
rate = fr007[nearest];
|
||||
FloatRateCalls.Add((valueDate.Date, rate));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 测试常量(来自客户测试环境截图 GLMS-20260703-0002)
|
||||
|
||||
/// <summary>成交/持仓名义本金</summary>
|
||||
private const decimal Notional = 279486108.21m;
|
||||
|
||||
/// <summary>年化天数</summary>
|
||||
private const int AnnualDays = 365;
|
||||
|
||||
/// <summary>固定利差 -1.55%(FR007-1.55%)</summary>
|
||||
private const decimal Spread = -0.0155m;
|
||||
|
||||
/// <summary>起息日(td.StartDate = SettleDate,不是成交日)</summary>
|
||||
private static readonly DateTime StartDate = new(2026, 7, 6);
|
||||
|
||||
/// <summary>成交日(td.TradeDate,仅作对照;不参与计息起点)</summary>
|
||||
private static readonly DateTime TradeDate = new(2026, 7, 3);
|
||||
|
||||
/// <summary>平仓日(valueDate / unwindDate)</summary>
|
||||
private static readonly DateTime CloseDate = new(2026, 7, 20);
|
||||
|
||||
/// <summary>系统实际计算的利息绝对值</summary>
|
||||
private const decimal SystemInterestAmount = 13667.85m;
|
||||
|
||||
/// <summary>实际应得利息绝对值</summary>
|
||||
private const decimal ActualInterestAmount = 13668.02m;
|
||||
|
||||
#endregion
|
||||
|
||||
private SwapDealService _service;
|
||||
|
||||
[TestInitialize]
|
||||
public void Init()
|
||||
{
|
||||
_service = new StubSwapDealService(
|
||||
new OptUserInfo(0, nameof(GLMS20260703CloseInterestTest), OptUserFrom.UnitTest));
|
||||
}
|
||||
|
||||
#region 测试数据构建器(仅构造输入,不计算利息)
|
||||
|
||||
private static trade CreateTrade()
|
||||
{
|
||||
var extend = new trade_extend
|
||||
{
|
||||
TradeId = 1,
|
||||
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||||
{
|
||||
AnnualDays = AnnualDays,
|
||||
InterestCalcMode = "10", // 算头不算尾
|
||||
SettlementRules = 0
|
||||
})
|
||||
};
|
||||
return new trade
|
||||
{
|
||||
id = 1,
|
||||
TradeNumber = "GLMS-20260703-0002",
|
||||
ClientId = 999998,
|
||||
TradeType = "债券TRS",
|
||||
TradeDate = TradeDate,
|
||||
StartDate = StartDate,
|
||||
ExerciseDate = CloseDate.AddDays(1), // 必须 > valueDate,否则 InitInterestDate 会多减一天
|
||||
TradeStatus = "已平仓",
|
||||
ValidState = "Valid",
|
||||
trade_extend = extend
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造债券TRS浮动利率本金腿。FloatRateUnderlyingCode=FR007,利率=FR007-1.55%。
|
||||
/// 仅设置输入字段;利息由系统函数 CalcDailyCompoundInterest / CalcDailySimpleInterest 计算。
|
||||
/// </summary>
|
||||
private static swap_position CreateBondPosition(InterestTypeEnum interestType, int interestRule = 0)
|
||||
{
|
||||
var intervalModels = new List<IntervalModel>
|
||||
{
|
||||
new IntervalModel { Date = CloseDate, Rate = Spread, Settlement = 0 }
|
||||
};
|
||||
return new swap_position
|
||||
{
|
||||
id = 1001,
|
||||
SwapTradeId = 1,
|
||||
PositionType = (int)PositionTypeFlag.Unknown,
|
||||
// 债券本金腿:标的期初全价(closePrincipal = posiNotional × closePercent)
|
||||
InterestDirection = (int)SwapDirectionEnum.支付,
|
||||
InterestMode = (int)InterestModeEnum.标的期初全价,
|
||||
InterestRateDefault = Spread,
|
||||
InterestPrincipalFix = Notional,
|
||||
PosiStartDate = StartDate,
|
||||
PosiMatuirityDate = CloseDate,
|
||||
IsInitial = true,
|
||||
Invalid = false,
|
||||
InterestType = (int)interestType, // 复利 or 单利
|
||||
IsAnnualized = true,
|
||||
interest_rest_days = 7, // 7天重置
|
||||
interest_rule = interestRule, // 0=当前营业日, -1=前一营业日
|
||||
FloatRateUnderlyingCode = "FR007", // 真实浮动利率腿
|
||||
FloatRate = 0m,
|
||||
PosiNotionalValue = Notional,
|
||||
UnderlyingCode = "2500002.IB",
|
||||
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 驱动真实平仓利息路径(settment=false → CalcUnwindInterest → InitSwapDealInterest
|
||||
/// → CalcDailyCompoundInterest / CalcDailySimpleInterest)。
|
||||
/// eodPositions 传空 → 等效"无前日日终快照",日循环从起始日重算。
|
||||
/// </summary>
|
||||
private swap_flow_event CalcCloseInterest(InterestTypeEnum interestType, int interestRule = 0)
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var position = CreateBondPosition(interestType, interestRule);
|
||||
// 每次计算前清空取数记录,避免同一测试中多次调用互相污染
|
||||
((StubSwapDealService)_service).FloatRateCalls.Clear();
|
||||
var interests = _service.GetInterests(
|
||||
td, td.trade_extend,
|
||||
CloseDate, CloseDate, // valueDate / unwindDate
|
||||
new List<eod_swap_position>(), // eodPositions(空)
|
||||
new List<swap_position> { position },
|
||||
Notional, Notional, Notional, Notional, // posiNotional / long / short / closePosiNotional
|
||||
1m, // closePercent
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
false, false, 0m, Notional, // tdClose / needPrice / grossPrice / orginPv
|
||||
false, settment: false, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count);
|
||||
return interests[0];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 复现用例(仅调用系统函数 + 对照已知结果断言)
|
||||
|
||||
/// <summary>
|
||||
/// [GLMS20260703_REPRO_001] 系统路径:InterestType=复利 + 真实FR007 → 复现系统值 -13,667.85
|
||||
/// 与生产代码完全一致:7天重置,重置日取当前营业日 FR007,利息并入本金。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Reproduce_SystemValue_13667_85_WithCompoundInterest()
|
||||
{
|
||||
var interest = CalcCloseInterest(InterestTypeEnum.复利);
|
||||
|
||||
var amount2 = Math.Round(interest.InterestAmount, 2, MidpointRounding.AwayFromZero);
|
||||
var closePnl2 = Math.Round(interest.InterestClosePnL, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
Console.WriteLine("系统路径(复利):");
|
||||
Console.WriteLine($" 利息金额(2位显示)={amount2}");
|
||||
Console.WriteLine($" 平仓盈亏(利息端,2位)={closePnl2}");
|
||||
|
||||
// 截图给的是"平仓盈亏(利息端)"的绝对值口径,故比较绝对值。
|
||||
Assert.AreEqual(SystemInterestAmount, Math.Abs(amount2),
|
||||
$"系统复利路径应得到利息绝对值 {SystemInterestAmount},实际 {amount2}");
|
||||
Assert.AreEqual(SystemInterestAmount, Math.Abs(closePnl2),
|
||||
$"支付方向平仓盈亏绝对值应为 {SystemInterestAmount},实际 {closePnl2}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [GLMS20260703_REPRO_002] 实际口径:InterestType=单利 + 真实FR007 → 复现实际值 -13,668.02
|
||||
/// 同一 position、同一 FR007、同一计息天数,仅把 InterestType 改为单利,
|
||||
/// 利息不并入本金,全程用名义本金 279,486,108.21 计息。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Reproduce_ActualValue_13668_02_WithSimpleInterest()
|
||||
{
|
||||
var interest = CalcCloseInterest(InterestTypeEnum.单利);
|
||||
|
||||
var amount2 = Math.Round(interest.InterestAmount, 2, MidpointRounding.AwayFromZero);
|
||||
var closePnl2 = Math.Round(interest.InterestClosePnL, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
Console.WriteLine("实际口径(单利):");
|
||||
Console.WriteLine($" 利息金额(2位显示)={amount2}");
|
||||
Console.WriteLine($" 平仓盈亏(利息端,2位)={closePnl2}");
|
||||
|
||||
Assert.AreEqual(ActualInterestAmount, Math.Abs(amount2),
|
||||
$"单利路径应得到利息绝对值 {ActualInterestAmount},实际 {amount2}");
|
||||
Assert.AreEqual(ActualInterestAmount, Math.Abs(closePnl2),
|
||||
$"支付方向平仓盈亏绝对值应为 {ActualInterestAmount},实际 {closePnl2}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [GLMS20260703_REPRO_003] 差异定位:0.17 元 = 复利 vs 单利
|
||||
/// 同一笔交易、同一 FR007 取值、同一计息天数,唯一区别是 InterestType,
|
||||
/// 系统(复利)比实际(单利)少 0.17 元。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void PrecisionGap_Is_0_17_ComplexVsSimple()
|
||||
{
|
||||
var compound = CalcCloseInterest(InterestTypeEnum.复利).InterestAmount;
|
||||
var simple = CalcCloseInterest(InterestTypeEnum.单利).InterestAmount;
|
||||
|
||||
// 两者都是负数(支付方向),取绝对值差异
|
||||
var gap = Math.Round(Math.Abs(simple) - Math.Abs(compound), 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
Console.WriteLine($"复利 |利息|={Math.Abs(compound):F11}");
|
||||
Console.WriteLine($"单利 |利息|={Math.Abs(simple):F11}");
|
||||
Console.WriteLine($"差异(2位)={gap}");
|
||||
|
||||
Assert.AreEqual(0.17m, gap, "单利与复利的利息绝对值差异应为 0.17 元");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [GLMS20260703_REPRO_004] 验证真实函数确实在 07-06、07-13 两个重置点取了 FR007
|
||||
/// (不手算利息,仅检查系统函数实际发起了哪几次取数)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Trace_FloatRate_Taken_Dates()
|
||||
{
|
||||
var fe = CalcCloseInterest(InterestTypeEnum.复利, interestRule: 0);
|
||||
var calls = ((StubSwapDealService)_service).FloatRateCalls;
|
||||
|
||||
Console.WriteLine("TryGetFloatRate 实际调用记录(按调用顺序):");
|
||||
foreach (var (date, rate) in calls)
|
||||
{
|
||||
Console.WriteLine($" 请求日期={date:yyyy-MM-dd} 返回 FR007={rate:P4}");
|
||||
}
|
||||
|
||||
Assert.IsTrue(calls.Any(c => c.RequestDate == new DateTime(2026, 7, 6)), "应取 07-06 的 FR007");
|
||||
Assert.IsTrue(calls.Any(c => c.RequestDate == new DateTime(2026, 7, 13)), "应取 07-13 的 FR007");
|
||||
Assert.AreEqual(SystemInterestAmount, Math.Abs(Math.Round(fe.InterestAmount, 2, MidpointRounding.AwayFromZero)),
|
||||
"interest_rule=0 复利应复现系统值 -13,667.85");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using YLErp.BLL.Calculation.V2;
|
||||
|
||||
namespace UnitTestProject.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// GreeksBumpCalculator 纯数学契约测试(先写,锁定 ε / 差分逻辑)。
|
||||
/// 不碰 DB / QDP:用已知解析导数的函数(f(x)=x²,d/dx=2x,d²/dx²=2)验证中心差分本身正确。
|
||||
/// 业务侧集成测试(真实定价路径 + 真实 FR007)见后续 ValueCalculator 薄接入落地后再补。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class GreeksBumpCalculatorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 一阶中心差分对 f(x)=x² 在 x=3 应精确等于 2x=6。
|
||||
/// 二次多项式的中心差分对任意 ε 都精确(截断误差为 0),因此用相对步长 0.05% 仍得 6。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CentralDelta_of_x2_equals_2x()
|
||||
{
|
||||
var calc = new GreeksBumpCalculator();
|
||||
Func<decimal, decimal> f = x => x * x; // d/dx = 2x
|
||||
var delta = calc.Delta(f, 3m, BumpSpec.Relative(0.0005m));
|
||||
Assert.AreEqual(6m, delta);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 二阶中心差分对 f(x)=x² 在 x=3 应精确等于 2(二阶导数恒为 2)。
|
||||
/// 验证 Gamma 的二阶差分算子正确。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CentralGamma_of_x2_equals_2()
|
||||
{
|
||||
var calc = new GreeksBumpCalculator();
|
||||
Func<decimal, decimal> f = x => x * x; // d²/dx² = 2
|
||||
var gamma = calc.Gamma(f, 3m, BumpSpec.Relative(0.0005m));
|
||||
Assert.AreEqual(2m, gamma);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1BP 变体(Dollar Greek)对 f(x)=x² 在 x=3、bump 1bp 应等于 (3.0001)² - 3²。
|
||||
/// 验证 BumpPv1Bp 直接前向 bump 的 PV 差逻辑。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void BumpPv1Bp_of_x2_forward()
|
||||
{
|
||||
var calc = new GreeksBumpCalculator();
|
||||
Func<decimal, decimal> f = x => x * x;
|
||||
var bumped = calc.BumpPv1Bp(f, 3m);
|
||||
Assert.AreEqual(3.0001m * 3.0001m - 3m * 3m, bumped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 线性函数 f(x)=2x+1:一阶中心差分应精确等于斜率 2,二阶中心差分应精确为 0(线性无曲率)。
|
||||
/// 验证算子对"一次/零次"函数的精确性(二次之外另一种精确情形)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CentralDelta_of_linear_is_slope()
|
||||
{
|
||||
var calc = new GreeksBumpCalculator();
|
||||
Func<decimal, decimal> f = x => 2m * x + 1m; // d/dx = 2, d²/dx² = 0
|
||||
Assert.AreEqual(2m, calc.Delta(f, 5m, BumpSpec.Relative(0.0005m)));
|
||||
Assert.AreEqual(0m, calc.Gamma(f, 5m, BumpSpec.Relative(0.0005m)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 三次函数 f(x)=x³:中心差分对任意 ε 不精确(仅二次及以下精确),结果逼近解析导 3x² 但有 O(ε²) 误差。
|
||||
/// 验证 Layer B 集成测试必须带容差,不能 Assert.AreEqual 死等精确值。x=2 解析导=12。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CentralDelta_of_cubic_is_approx_analytic_with_tolerance()
|
||||
{
|
||||
var calc = new GreeksBumpCalculator();
|
||||
Func<decimal, decimal> f = x => x * x * x; // d/dx = 3x² = 12 at x=2
|
||||
var delta = calc.Delta(f, 2m, BumpSpec.Relative(0.0005m));
|
||||
Assert.IsTrue(Math.Abs(delta - 12m) < 0.001m, $"中心差分三次函数应有 O(ε²) 误差,实际={delta}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扭结点测试:看涨 payoff f(x)=max(x-3,0) 在行权价 x=3 处不可导。
|
||||
/// 中心差分跨扭结取到左右斜率的平均 (0+1)/2 = 0.5,说明对障碍/美式等扭结结构必须用单边差分。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CentralDelta_at_kink_is_average_of_one_sided()
|
||||
{
|
||||
var calc = new GreeksBumpCalculator();
|
||||
Func<decimal, decimal> f = x => x > 3m ? x - 3m : 0m; // call payoff K=3
|
||||
var delta = calc.Delta(f, 3m, BumpSpec.Relative(0.0005m));
|
||||
Assert.AreEqual(0.5m, delta); // 中心差分给出左右斜率平均,非真实单边 Greek
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 边界 x=0(相对步长会触到 Floor):f(x)=x² 在 0 处对称,Δ 应精确为 0,证明 Floor 兜底不产生噪声。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CentralDelta_at_zero_uses_floor_but_stays_correct()
|
||||
{
|
||||
var calc = new GreeksBumpCalculator();
|
||||
Func<decimal, decimal> f = x => x * x;
|
||||
Assert.AreEqual(0m, calc.Delta(f, 0m, BumpSpec.Relative(0.0005m)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利率量级小值 x=0.0001(1bp 量级)用相对步长:Resolve 返回 max(0.0001·0.0005, 1e-8)=5e-8,
|
||||
/// 远大于 Floor,证明利率类小量级不会被舍入噪声吞掉。f=x² 在 0.0001 解析导=2·0.0001=0.0002。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CentralDelta_of_tiny_rate_like_x_is_stable()
|
||||
{
|
||||
var calc = new GreeksBumpCalculator();
|
||||
Func<decimal, decimal> f = x => x * x;
|
||||
Assert.AreEqual(0.0002m, calc.Delta(f, 0.0001m, BumpSpec.Relative(0.0005m)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YLErp.BLL.Calculation.V2;
|
||||
using YLErp.BLL.Calculation.V2.Parameter;
|
||||
|
||||
namespace UnitTestProject.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// RiskFactor(② 风险因子抽象)+ ParameterBase.Clone 的纯单测(Layer A,无 DB / QDP)。
|
||||
/// 用假 reprice 委托验证:克隆类型保持、字典深拷、三类因子落点正确、波动率因子施于非期权参数抛异常,
|
||||
/// 以及经 BuildPvFunction 喂入 GreeksBumpCalculator 后 DeltaR / Delta / Vega / BumpPv1Bp 数值正确(线性函数精确)。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class GreeksRiskFactorTests
|
||||
{
|
||||
// —— Clone 行为与类型保持 ——
|
||||
|
||||
[TestMethod]
|
||||
public void Clone_PreservesRuntimeType_And_CopiesOptionFields()
|
||||
{
|
||||
var src = new VanillaOptionParameter
|
||||
{
|
||||
Volatility = 0.2,
|
||||
RiskFreeRate = 0.03,
|
||||
SpotPrices = new Dictionary<string, double> { { "X", 100 } }
|
||||
};
|
||||
ParameterBase clone = src.Clone();
|
||||
// MemberwiseClone 必须保留运行时类型,否则 ValueCalculator 内 parameter as VanillaOptionParameter 会 cast 成 null
|
||||
Assert.IsInstanceOfType(clone, typeof(VanillaOptionParameter));
|
||||
Assert.AreEqual(0.2, ((BaseOptionParameter)clone).Volatility);
|
||||
Assert.AreEqual(100, clone.SpotPrices["X"]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Clone_DeepCopiesSpotPrices_So_Bump_Does_Not_Pollute_Original()
|
||||
{
|
||||
var src = new ParameterBase
|
||||
{
|
||||
SpotPrices = new Dictionary<string, double> { { "X", 100 } }
|
||||
};
|
||||
ParameterBase clone = src.Clone();
|
||||
clone.SpotPrices["X"] = 999; // 改克隆体
|
||||
Assert.AreEqual(100, src.SpotPrices["X"], "原参数的 SpotPrices 不应被克隆体的 bump 污染");
|
||||
}
|
||||
|
||||
// —— 三类因子 ApplyTo 落点正确 ——
|
||||
|
||||
[TestMethod]
|
||||
public void RateFactor_ApplyTo_Sets_RiskFreeRate()
|
||||
{
|
||||
var p = new ParameterBase();
|
||||
RiskFactor.Rate("CNY-OIS-2Y").ApplyTo(p, 0.025m);
|
||||
Assert.AreEqual(0.025, p.RiskFreeRate);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PriceFactor_ApplyTo_Sets_SpotPrices_By_TargetKey()
|
||||
{
|
||||
var p = new ParameterBase();
|
||||
RiskFactor.Price("000300.SH").ApplyTo(p, 3500m);
|
||||
Assert.AreEqual(3500, p.SpotPrices["000300.SH"]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VolFactor_ApplyTo_On_OptionParameter_Sets_Volatility()
|
||||
{
|
||||
var p = new BaseOptionParameter();
|
||||
RiskFactor.Volatility("X").ApplyTo(p, 0.18m);
|
||||
Assert.AreEqual(0.18, p.Volatility);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(System.InvalidOperationException))]
|
||||
public void VolFactor_ApplyTo_On_PlainParameter_Throws()
|
||||
{
|
||||
// 波动率不在 ParameterBase 基类上,只能施于期权参数
|
||||
RiskFactor.Volatility("X").ApplyTo(new ParameterBase(), 0.1m);
|
||||
}
|
||||
|
||||
// —— 端到端:假 reprice 验证 中心差分 / 1bp 数值正确(线性函数精确) ——
|
||||
|
||||
[TestMethod]
|
||||
public void RateFactor_Through_Engine_DeltaR_Equals_Slope()
|
||||
{
|
||||
var baseParam = new ParameterBase { RiskFreeRate = 0.03 };
|
||||
Func<ParameterBase, decimal> reprice = p => (decimal)((p.RiskFreeRate ?? 0) * 1000); // PV = 1000 * r
|
||||
var factor = RiskFactor.Rate("r"); // 标准步长:绝对 1bp
|
||||
Func<decimal, decimal> pv = factor.BuildPvFunction(reprice, baseParam);
|
||||
|
||||
var calc = new GreeksBumpCalculator();
|
||||
decimal deltaR = calc.DeltaR(pv, 0.03m, factor.Shift); // 中心差分对线性函数精确
|
||||
Assert.AreEqual(1000m, deltaR, 1e-4m);
|
||||
|
||||
decimal bump1bp = calc.BumpPv1Bp(pv, 0.03m); // 前向 1bp PV 差
|
||||
Assert.AreEqual(1000m * 0.0001m, bump1bp, 1e-9m);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PriceFactor_Through_Engine_Delta_Equals_One()
|
||||
{
|
||||
var baseParam = new ParameterBase
|
||||
{
|
||||
SpotPrices = new Dictionary<string, double> { { "X", 100 } }
|
||||
};
|
||||
Func<ParameterBase, decimal> reprice = p => (decimal)p.SpotPrices["X"]; // PV = S
|
||||
var factor = RiskFactor.Price("X"); // 标准步长:相对 1% → ε = 1
|
||||
Func<decimal, decimal> pv = factor.BuildPvFunction(reprice, baseParam);
|
||||
|
||||
var calc = new GreeksBumpCalculator();
|
||||
decimal delta = calc.Delta(pv, 100m, factor.Shift); // (101 - 99) / 2 = 1 精确
|
||||
Assert.AreEqual(1m, delta, 1e-6m);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VolFactor_Through_Engine_Vega_Equals_Slope()
|
||||
{
|
||||
var baseParam = new BaseOptionParameter { Volatility = 0.2 };
|
||||
Func<ParameterBase, decimal> reprice = p => (decimal)(((BaseOptionParameter)p).Volatility ?? 0) * 50; // PV = 50 * σ
|
||||
var factor = RiskFactor.Volatility("X"); // 标准步长:绝对 1bp vol
|
||||
Func<decimal, decimal> pv = factor.BuildPvFunction(reprice, baseParam);
|
||||
|
||||
var calc = new GreeksBumpCalculator();
|
||||
decimal vega = calc.Vega(pv, 0.2m, factor.Shift); // 中心差分对线性函数精确
|
||||
Assert.AreEqual(50m, vega, 1e-4m);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,11 @@ namespace YLErp.Modules.SwapModule
|
||||
/// CalcBondPayment 改为按天数 × 持仓线性函数,使 TdPosiDividend 真实随
|
||||
/// "天数 × 剩余持仓"变化——这是验证多日递推守恒的前提。
|
||||
/// </summary>
|
||||
private sealed class StubEodService : SwapEodPositionService
|
||||
private sealed class StubEodService : TestableSwapEodPositionService
|
||||
{
|
||||
private readonly decimal _dailyRatePerUnit;
|
||||
|
||||
public StubEodService(decimal dailyRatePerUnit) : base(new OptUserInfo(0, nameof(MultiUnwindDividendConservationTest), OptUserFrom.UnitTest))
|
||||
public StubEodService(decimal dailyRatePerUnit) : base(nameof(MultiUnwindDividendConservationTest))
|
||||
{
|
||||
_dailyRatePerUnit = dailyRatePerUnit;
|
||||
}
|
||||
@@ -66,14 +66,6 @@ namespace YLErp.Modules.SwapModule
|
||||
return 1.00m;
|
||||
}
|
||||
|
||||
protected override void SaveAllChanges() { }
|
||||
|
||||
// 注意:UpdateEodPosition.cs:1645 直接 new EodCurrencyRateService,不走此 seam;
|
||||
// 但 trade.QuoteCurrency == trade.SettlementCurrency == "CNY" 时,
|
||||
// EodCurrencyRateService.GetEodCurrencyRate 会在查库前短路返回 Rate=1(cs:268-281)
|
||||
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType)
|
||||
=> 1.0;
|
||||
|
||||
// 暴露 protected UpdateEodPosition(参考 GLMS20260105GoldenTest.ReplayStubService:244)
|
||||
public eod_swap_position ExecuteUpdateEodPosition(
|
||||
swap_position swapPosition, eod_swap_position eod, trade td,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using YLErp.DBModels;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// SwapEodPositionService.SetFixedLegRealizedPnl 的回归测试。
|
||||
/// -----------------------------------------------------------------
|
||||
/// 守卫提交 f4ffe710 "fix(swap): 修复期末头寸已实现盈亏计算问题"。
|
||||
///
|
||||
/// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径原本各自复制粘贴:
|
||||
/// newEodPayPosition.RealizedPnl = RealizedInterest + RealizedInterestFee
|
||||
/// 其中 L1296 还遗留了双分号笔误 ";;"。抽为单一纯函数后消除复制粘贴风险,
|
||||
/// 并锁定"固定利息腿累计已实现盈亏 = 累计利息 + 累计利息费用"口径。
|
||||
///
|
||||
/// 单测覆盖:正/负/零/混合符号、大额、InterestFee 为零等场景。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SwapFixedLegRealizedPnlTest
|
||||
{
|
||||
// ================================================================
|
||||
// 场景1:收取方向,利息与利息费用均为正
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 收取方向_利息与费用均为正_求和()
|
||||
{
|
||||
var pos = NewPosition(realizedInterest: 1000m, realizedInterestFee: 200m);
|
||||
|
||||
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(1200m, pos.RealizedPnl, 0.0001m,
|
||||
"1000 + 200 = 1200");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景2:支付方向,利息与利息费用均为负
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 支付方向_利息与费用均为负_求和()
|
||||
{
|
||||
var pos = NewPosition(realizedInterest: -1000m, realizedInterestFee: -200m);
|
||||
|
||||
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(-1200m, pos.RealizedPnl, 0.0001m,
|
||||
"-1000 + (-200) = -1200");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景3:利息费用为零 —— RealizedPnl = RealizedInterest
|
||||
// 回归场景:部分路径利息费用未发生,确保不误乘/不丢值
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 利息费用为零_盈亏等于利息()
|
||||
{
|
||||
var pos = NewPosition(realizedInterest: 500m, realizedInterestFee: 0m);
|
||||
|
||||
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(500m, pos.RealizedPnl, 0.0001m,
|
||||
"利息费用=0 时 RealizedPnl = RealizedInterest");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景4:利息为零 —— RealizedPnl = RealizedInterestFee
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 利息为零_盈亏等于利息费用()
|
||||
{
|
||||
var pos = NewPosition(realizedInterest: 0m, realizedInterestFee: 300m);
|
||||
|
||||
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(300m, pos.RealizedPnl, 0.0001m,
|
||||
"利息=0 时 RealizedPnl = RealizedInterestFee");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景5:两者均为零 —— RealizedPnl = 0
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 利息与费用均为零_盈亏为零()
|
||||
{
|
||||
var pos = NewPosition(realizedInterest: 0m, realizedInterestFee: 0m);
|
||||
|
||||
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(0m, pos.RealizedPnl, 0.0001m,
|
||||
"两者均为 0 时 RealizedPnl = 0");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景6:混合符号(利息负、利息费用正)—— 直接求和
|
||||
// 回归场景:避免有人误加 Math.Abs 或方向判断
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 混合符号_利息负费用正_直接求和()
|
||||
{
|
||||
var pos = NewPosition(realizedInterest: -800m, realizedInterestFee: 100m);
|
||||
|
||||
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(-700m, pos.RealizedPnl, 0.0001m,
|
||||
"-800 + 100 = -700,不引入 Abs/方向判断");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景7:大额 —— 验证 decimal 精度无溢出
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 大额_decimal精度无溢出()
|
||||
{
|
||||
var pos = NewPosition(realizedInterest: 279_486_108.21m, realizedInterestFee: 13_668.02m);
|
||||
|
||||
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(279_499_776.23m, pos.RealizedPnl, 0.0001m,
|
||||
"大额 decimal 求和精度保持");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景8:null 入参 —— 抛 NullReferenceException(现状锚点)
|
||||
// 生产代码未加 null 检查,直接解引用 position 抛 NRE。
|
||||
// 若未来改为 ArgumentNullException,此处需同步更新。
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void Null入参_抛NullReferenceException()
|
||||
{
|
||||
Assert.ThrowsException<NullReferenceException>(() =>
|
||||
SwapEodPositionService.SetFixedLegRealizedPnl(null!));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景9:覆盖原值 —— 验证是赋值而非累加
|
||||
// 回归场景:防止有人误改为 += 导致重复计算
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 原有RealizedPnl被覆盖_非累加()
|
||||
{
|
||||
var pos = NewPosition(realizedInterest: 100m, realizedInterestFee: 50m);
|
||||
pos.RealizedPnl = 9999m; // 预置一个非零旧值
|
||||
|
||||
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
|
||||
|
||||
Assert.AreEqual(150m, pos.RealizedPnl, 0.0001m,
|
||||
"应直接覆盖为 150,而非累加旧值 9999");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helper:构造 eod_swap_position(只设置参与计算的 2 个字段)
|
||||
// ================================================================
|
||||
private static eod_swap_position NewPosition(
|
||||
decimal realizedInterest,
|
||||
decimal realizedInterestFee)
|
||||
{
|
||||
return new eod_swap_position
|
||||
{
|
||||
RealizedInterest = realizedInterest,
|
||||
RealizedInterestFee = realizedInterestFee
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
using YLErp.Helpers;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// SwapFrontendPnlValidator.BuildFrontendValidationDiffs 的回归测试。
|
||||
/// -----------------------------------------------------------------
|
||||
/// 守卫提交 41553970 "fix(swap): 修复债券结息价差盈亏计算逻辑"。
|
||||
///
|
||||
/// 背景:ValidateFrontendPnL 用 FrontendCalcReference 重算盈亏与前端值比对,
|
||||
/// 原为 private void + 吞异常,无法单测。拆出 BuildFrontendValidationDiffs 纯函数:
|
||||
/// - 入参:UnwindData + isIncome
|
||||
/// - 返回:null(前置条件不满足)或 List<FrontendPnlDiff>(超阈值的差异项)
|
||||
/// - 副作用:无(日志留在 ValidateFrontendPnL 外层)
|
||||
///
|
||||
/// 本测试锁定:
|
||||
/// 1) 前端值与后端重算一致 → 返回空列表
|
||||
/// 2) 前端值与后端重算不一致 → 返回对应字段差异
|
||||
/// 3) 无浮动腿 → 返回 null
|
||||
/// 4) PosiGrossPrice=0 → 返回 null
|
||||
/// 5) PositionQty/CloseQty 口径(41553970 修复点)正确传入
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SwapFrontendPnlValidateTest
|
||||
{
|
||||
// ================================================================
|
||||
// 场景1:前端值与后端重算一致 → 返回空列表
|
||||
// 用 FC_001 同款输入:债券多头,PosiGrossPrice=1.02, TradingAmountAvg=105,
|
||||
// CloseQty=1000, PayDirection=1, PositionType=1, TradingFee="20"
|
||||
// 后端重算:MarkClosePnl=30, FloatPnlSum=50, SwapRealizedPnL=50, SwapCloseAmount=50
|
||||
// 前端也填这些值 → 无差异
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 前后端一致_返回空差异列表()
|
||||
{
|
||||
var unwindData = BuildBaseUnwindData(
|
||||
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
|
||||
closeQty: 1000, positionQty: 1000,
|
||||
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
|
||||
payDirection: 1, positionType: 1,
|
||||
swapRealizedPnL: 50m, swapCloseAmount: 50m, markClosePnl: 30m);
|
||||
|
||||
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
|
||||
|
||||
Assert.IsNotNull(diffs, "前置条件满足应返回列表而非 null");
|
||||
Assert.AreEqual(0, diffs.Count,
|
||||
$"前后端一致应无差异,实际 {diffs.Count} 条:{string.Join(",", diffs.Select(d => d.Field))}");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景2:SwapRealizedPnL 前端填错 → 返回该字段差异
|
||||
// 后端重算 SwapRealizedPnL=50, SwapCloseAmount=50;
|
||||
// 前端 SwapRealizedPnL 故意填 60(SwapCloseAmount 保持 50 一致)→ 只 SwapRealizedPnL 有差异
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void SwapRealizedPnL前端填错_返回该字段差异()
|
||||
{
|
||||
var unwindData = BuildBaseUnwindData(
|
||||
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
|
||||
closeQty: 1000, positionQty: 1000,
|
||||
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
|
||||
payDirection: 1, positionType: 1,
|
||||
swapRealizedPnL: 60m, // 故意填错(正确=50)
|
||||
swapCloseAmount: 50m, // 保持一致
|
||||
markClosePnl: 30m); // 保持一致
|
||||
|
||||
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
|
||||
|
||||
Assert.IsNotNull(diffs);
|
||||
CollectionAssert.AreEquivalent(
|
||||
new[] { "SwapRealizedPnL" },
|
||||
diffs.Select(d => d.Field).ToArray(),
|
||||
"应只捕获 SwapRealizedPnL 的差异");
|
||||
var diff = diffs.Single(d => d.Field == "SwapRealizedPnL");
|
||||
Assert.AreEqual(60m, diff.FrontendValue, "前端值=60");
|
||||
Assert.AreEqual(50m, diff.BackendValue, 0.01m, "后端重算=50");
|
||||
Assert.AreEqual(10m, diff.Delta, 0.01m, "Delta=10");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景3:MarkClosePnl 前端填错 → 返回该字段差异
|
||||
// 后端重算 MarkClosePnl=30;前端故意填 25(其他保持一致)→ 只 MarkClosePnl 有差异
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void MarkClosePnl前端填错_返回该字段差异()
|
||||
{
|
||||
var unwindData = BuildBaseUnwindData(
|
||||
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
|
||||
closeQty: 1000, positionQty: 1000,
|
||||
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
|
||||
payDirection: 1, positionType: 1,
|
||||
swapRealizedPnL: 50m,
|
||||
swapCloseAmount: 50m,
|
||||
markClosePnl: 25m); // 故意填错(正确=30)
|
||||
|
||||
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
|
||||
|
||||
Assert.IsNotNull(diffs);
|
||||
CollectionAssert.AreEquivalent(
|
||||
new[] { "MarkClosePnl" },
|
||||
diffs.Select(d => d.Field).ToArray(),
|
||||
"应只捕获 MarkClosePnl 的差异");
|
||||
var diff = diffs.Single(d => d.Field == "MarkClosePnl");
|
||||
Assert.AreEqual(25m, diff.FrontendValue);
|
||||
Assert.AreEqual(30m, diff.BackendValue, 0.01m);
|
||||
Assert.AreEqual(-5m, diff.Delta, 0.01m);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景4:无浮动腿(FlowEvents 为空)→ 返回 null
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 无浮动腿_返回null()
|
||||
{
|
||||
var unwindData = new UnwindData
|
||||
{
|
||||
SwapTradeId = 1,
|
||||
CloseQty = 1000,
|
||||
PositionQty = 1000,
|
||||
FlowEvents = new List<swap_flow_event>() // 完全空
|
||||
};
|
||||
|
||||
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
|
||||
|
||||
Assert.IsNull(diffs, "无浮动腿应返回 null(跳过校验)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景5:浮动腿 PosiGrossPrice=0 → 返回 null(避免误报)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 浮动腿PosiGrossPrice为零_返回null()
|
||||
{
|
||||
var unwindData = BuildBaseUnwindData(
|
||||
posiGrossPrice: 0m, // 前端未传 → 0
|
||||
tradingAmountAvg: 105m,
|
||||
closeQty: 1000, positionQty: 1000,
|
||||
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
|
||||
payDirection: 1, positionType: 1,
|
||||
swapRealizedPnL: 50m, swapCloseAmount: 0m, markClosePnl: 30m);
|
||||
|
||||
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
|
||||
|
||||
Assert.IsNull(diffs, "PosiGrossPrice=0 应返回 null(避免误报)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景6:41553970 修复点 —— PositionQty 必须正确传入后端重算
|
||||
// 旧 bug:PositionQty 未传入,导致部分平仓时盈亏口径错误。
|
||||
// 验证:PositionQty != CloseQty 时,后端重算仍按真实 PositionQty 走
|
||||
// (本场景构造部分平仓:CloseQty=500, PositionQty=1000)
|
||||
// 平仓页 unwind 用 CloseQty 算 MarkClosePnl:
|
||||
// MarkClosePnl = 500×(1.05−1.02)×1×1 = 15
|
||||
// FloatPnlSum = 15 + 20 + 0 + 0 = 35
|
||||
// SwapRealizedPnL = SwapCloseAmount = 35
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 部分平仓_PositionQty正确传入后端重算()
|
||||
{
|
||||
var unwindData = BuildBaseUnwindData(
|
||||
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
|
||||
closeQty: 500, positionQty: 1000,
|
||||
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
|
||||
payDirection: 1, positionType: 1,
|
||||
swapRealizedPnL: 35m, // 与后端重算一致
|
||||
swapCloseAmount: 35m, // 与后端重算一致
|
||||
markClosePnl: 15m); // 与后端重算一致
|
||||
|
||||
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
|
||||
|
||||
Assert.IsNotNull(diffs);
|
||||
Assert.AreEqual(0, diffs.Count,
|
||||
$"部分平仓 PositionQty 正确传入应无差异,实际 {diffs.Count} 条:" +
|
||||
string.Join(",", diffs.Select(d => $"{d.Field}(fe={d.FrontendValue},be={d.BackendValue})")));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景7:阈值边界 —— 差异恰好等于阈值(0.01)不报,超过才报
|
||||
// 后端重算 SwapRealizedPnL=50, SwapCloseAmount=50;
|
||||
// 前端 SwapRealizedPnL 填 50.01 → 差异 0.01 不> 0.01 → 不报
|
||||
// 前端 SwapRealizedPnL 填 50.02 → 差异 0.02 > 0.01 → 报
|
||||
// (SwapCloseAmount 保持 50 一致,不参与本场景断言)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 阈值边界_差异等于阈值不报_超过才报()
|
||||
{
|
||||
// 差异 = 0.01,不 > 0.01,不报
|
||||
var unwindDataEq = BuildBaseUnwindData(
|
||||
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
|
||||
closeQty: 1000, positionQty: 1000,
|
||||
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
|
||||
payDirection: 1, positionType: 1,
|
||||
swapRealizedPnL: 50.01m, swapCloseAmount: 50m, markClosePnl: 30m);
|
||||
var diffsEq = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindDataEq, isIncome: false);
|
||||
Assert.IsNotNull(diffsEq);
|
||||
Assert.IsFalse(diffsEq.Any(d => d.Field == "SwapRealizedPnL"),
|
||||
"差异=0.01 不> 阈值,不应报 SwapRealizedPnL");
|
||||
|
||||
// 差异 = 0.02 > 0.01,报
|
||||
var unwindDataOver = BuildBaseUnwindData(
|
||||
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
|
||||
closeQty: 1000, positionQty: 1000,
|
||||
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
|
||||
payDirection: 1, positionType: 1,
|
||||
swapRealizedPnL: 50.02m, swapCloseAmount: 50m, markClosePnl: 30m);
|
||||
var diffsOver = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindDataOver, isIncome: false);
|
||||
Assert.IsNotNull(diffsOver);
|
||||
Assert.IsTrue(diffsOver.Any(d => d.Field == "SwapRealizedPnL"),
|
||||
"差异=0.02 > 阈值,应报 SwapRealizedPnL");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景8:isIncome=true 走 CalcIncome 路径 —— 确保分支选择正确
|
||||
// 结息页公式与平仓页不同,构造一致场景验证不抛异常且返回列表
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void IsIncome为true_走CalcIncome分支_返回列表()
|
||||
{
|
||||
// income 页 MarkClosePnl = PositionQty × ContractSize × (ExitPrice×scale − EntryPrice) × floatRatio
|
||||
// = 1000 × 1 × (105×0.01 − 1.02) × 1 = 30
|
||||
// FloatPnlSum = 30 + 20 = 50;SwapRealizedPnL = SwapCloseAmount = 50
|
||||
var unwindData = BuildBaseUnwindData(
|
||||
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
|
||||
closeQty: 1000, positionQty: 1000,
|
||||
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
|
||||
payDirection: 1, positionType: 1,
|
||||
swapRealizedPnL: 50m, swapCloseAmount: 50m, markClosePnl: 30m);
|
||||
|
||||
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: true);
|
||||
|
||||
Assert.IsNotNull(diffs, "isIncome=true 也应返回列表(可能为空或有差异)");
|
||||
// 不锁死具体差异,只验证分支可达、不抛异常
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景9:自定义阈值 —— threshold=1.0 时小差异不报
|
||||
// 后端 SwapRealizedPnL=50, SwapCloseAmount=50;
|
||||
// 前端 SwapRealizedPnL=50.5(差异 0.5 < 1.0 不报),SwapCloseAmount=50 一致
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 自定义大阈值_小差异不报()
|
||||
{
|
||||
var unwindData = BuildBaseUnwindData(
|
||||
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
|
||||
closeQty: 1000, positionQty: 1000,
|
||||
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
|
||||
payDirection: 1, positionType: 1,
|
||||
swapRealizedPnL: 50.5m, // 差异 0.5
|
||||
swapCloseAmount: 50m,
|
||||
markClosePnl: 30m);
|
||||
|
||||
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false, threshold: 1.0m);
|
||||
|
||||
Assert.IsNotNull(diffs);
|
||||
Assert.IsFalse(diffs.Any(d => d.Field == "SwapRealizedPnL"),
|
||||
"threshold=1.0 时差异 0.5 不应报");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helper:构造带一条浮动腿 + 一条利息腿的 UnwindData
|
||||
// 默认用债券(UnderlyingInstrumentType 走 IsBond=true → multiplier=100)
|
||||
// 字段值与 FrontendCalcCharacterizationTest.FC_001 对齐
|
||||
// ================================================================
|
||||
private static UnwindData BuildBaseUnwindData(
|
||||
decimal posiGrossPrice,
|
||||
decimal tradingAmountAvg,
|
||||
decimal closeQty,
|
||||
decimal positionQty,
|
||||
string tradingFee,
|
||||
string tradingFeePending,
|
||||
string dividendIn,
|
||||
int payDirection,
|
||||
int positionType,
|
||||
decimal swapRealizedPnL,
|
||||
decimal swapCloseAmount,
|
||||
decimal markClosePnl)
|
||||
{
|
||||
// 浮动腿(债券,有 UnderlyingCode)
|
||||
var floatLeg = new swap_flow_event
|
||||
{
|
||||
UnderlyingCode = "511160.SH",
|
||||
UnderlyingInstrumentType = "Bond",
|
||||
PosiGrossPrice = posiGrossPrice,
|
||||
TradingAmountAvg = tradingAmountAvg,
|
||||
ContractSize = 1m,
|
||||
PayDirection = payDirection,
|
||||
PositionType = positionType,
|
||||
TradingFee = decimal.Parse(tradingFee),
|
||||
TradingFeePending = decimal.Parse(tradingFeePending),
|
||||
DividendIn = decimal.Parse(dividendIn),
|
||||
MarkClosePnl = markClosePnl,
|
||||
InterestMode = (int)InterestModeEnum.标的期初全价
|
||||
};
|
||||
|
||||
// 利息腿(无 UnderlyingCode)
|
||||
var interestLeg = new swap_flow_event
|
||||
{
|
||||
InterestMode = (int)InterestModeEnum.固定值,
|
||||
InterestClosePnL = 0m
|
||||
};
|
||||
|
||||
return new UnwindData
|
||||
{
|
||||
SwapTradeId = 1,
|
||||
CloseQty = closeQty,
|
||||
PositionQty = positionQty,
|
||||
CloseNotionalValue = closeQty * 100m, // 债券面值 100
|
||||
SwapRealizedPnL = swapRealizedPnL,
|
||||
SwapCloseAmount = swapCloseAmount,
|
||||
FlowEvents = new List<swap_flow_event> { floatLeg, interestLeg }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ namespace YLErp.Modules.SwapModule
|
||||
/// 继承 SwapEodPositionService,override SwapPositionCompose 路径上的 seam。
|
||||
/// 适配当前分支 seam 签名(GetUnderlyingPrice 带 out、GetCurrencyRate 返回 double 等)。
|
||||
/// </summary>
|
||||
private sealed class TestableSwapEodService : SwapEodPositionService
|
||||
private sealed class TestableSwapEodService : TestableSwapEodPositionService
|
||||
{
|
||||
private readonly List<trade> _trades;
|
||||
private readonly List<swap_position> _positions;
|
||||
@@ -34,15 +34,15 @@ namespace YLErp.Modules.SwapModule
|
||||
private readonly decimal _price;
|
||||
private readonly decimal _vobp;
|
||||
|
||||
public List<eod_swap_position> CreatedEodPositions { get; } = new();
|
||||
public List<(double amount, string action)> ClientCashCalls { get; } = new();
|
||||
// 输出别名(转发到基类捕获属性)
|
||||
public List<eod_swap_position> CreatedEodPositions => PersistedPositions;
|
||||
|
||||
public TestableSwapEodService(
|
||||
List<trade> trades, List<swap_position> positions,
|
||||
List<eod_swap_position> eodPositions, List<eod_swap> eodSwaps,
|
||||
List<trade_extend> extends, List<swap_flow_event> flowEvents,
|
||||
decimal price = 100m, decimal vobp = 0m)
|
||||
: base(new OptUserInfo(0, nameof(SwapPositionComposeScenarioTest), OptUserFrom.UnitTest))
|
||||
: base(nameof(SwapPositionComposeScenarioTest))
|
||||
{
|
||||
_trades = trades; _positions = positions; _eodPositions = eodPositions;
|
||||
_eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents;
|
||||
@@ -67,13 +67,9 @@ namespace YLErp.Modules.SwapModule
|
||||
{ vobp = _vobp; return _price; }
|
||||
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m;
|
||||
|
||||
// 持久化/事务 seam override
|
||||
protected override void PersistEodSwapPosition(eod_swap_position position) { CreatedEodPositions.Add(position); }
|
||||
// 持久化/事务 seam override(PersistEodSwapPosition/SaveAllChanges/GetCurrencyRate/AddClientCash 由基类提供)
|
||||
protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { }
|
||||
protected override void SaveAllChanges() { }
|
||||
protected override void ExecuteInTransaction(Action action) => action();
|
||||
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
|
||||
{ ClientCashCalls.Add((amount, action)); return ClientCashCalls.Count; }
|
||||
protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List<int> eventTypes) { }
|
||||
public override void ClearSwapPositions(trade td, DateTime valueDate, List<int> eventTypes, bool delAfter) { }
|
||||
protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason)
|
||||
@@ -85,7 +81,6 @@ namespace YLErp.Modules.SwapModule
|
||||
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
|
||||
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
|
||||
List<swap_flow_event> closeList = null) => new List<swap_flow_event>();
|
||||
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) => 1.0;
|
||||
|
||||
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
|
||||
=> SwapPositionCompose(settleDate, preSettleDate, null);
|
||||
|
||||
@@ -247,7 +247,7 @@ namespace YLErp.Modules.SwapModule
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[TestCategory("DBRecording")]
|
||||
// [Ignore] // 有写文件副作用,手动跑时取消注释
|
||||
[Ignore] // 写文件副作用 + 依赖测试库样本(trade 1903),手动跑时取消注释;不进 CI(见类头注释)
|
||||
public void Step1_RecordAndDiagnoseDeleteBug()
|
||||
{
|
||||
int tradeId = SampleTradeId;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// SwapEodPositionService.NormalizeInterestSignForReport 的回归测试。
|
||||
/// -----------------------------------------------------------------
|
||||
/// 守卫提交 f4ffe710 中报表分支的符号归一化逻辑(原内联于 SearchEodSwapList)。
|
||||
///
|
||||
/// 业务口径:
|
||||
/// - 仅当 InterestDirection > 0 时执行(兼容历史 0 方向脏数据)
|
||||
/// - 普通利息腿:收取为正、支付为负(interestRatio = Direction==收取 ? 1 : -1)
|
||||
/// - 预付金腿(初始预付金/追加预付金):利息方向与保证金本金方向相反,interestRatio 取反
|
||||
/// - TdCloseInterest / RealizedInterest 统一按 Math.Abs × interestRatio 重写
|
||||
/// - RealizedPnl 重算为 RealizedInterest + RealizedInterestFee(兼容历史未同步落库)
|
||||
///
|
||||
/// 抽为 public static 纯函数以支持无库单测。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SwapReportInterestSignNormalizeTest
|
||||
{
|
||||
// ================================================================
|
||||
// 场景1:普通利息腿收取方向 → 利息维持正号
|
||||
// TdCloseInterest=-1000(历史脏数据符号错) → 归一化为 +1000
|
||||
// RealizedInterest=-2000 → +2000
|
||||
// RealizedPnl = 2000 + 100 = 2100
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 普通利息腿_收取方向_利息归一化为正()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.固定值,
|
||||
interestDirection: (int)SwapDirectionEnum.收取,
|
||||
tdCloseInterest: -1000m,
|
||||
realizedInterest: -2000m,
|
||||
realizedInterestFee: 100m);
|
||||
|
||||
SwapEodPositionService.NormalizeInterestSignForReport(pos);
|
||||
|
||||
Assert.AreEqual(1000m, pos.TdCloseInterest, 0.0001m, "TdCloseInterest 应归一化为 +1000");
|
||||
Assert.AreEqual(2000m, pos.RealizedInterest, 0.0001m, "RealizedInterest 应归一化为 +2000");
|
||||
Assert.AreEqual(2100m, pos.RealizedPnl, 0.0001m, "RealizedPnl = 2000 + 100 = 2100");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景2:普通利息腿支付方向 → 利息归一化为负
|
||||
// TdCloseInterest=1000(历史脏数据符号错) → 归一化为 -1000
|
||||
// RealizedInterest=2000 → -2000
|
||||
// RealizedPnl = -2000 + 100 = -1900
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 普通利息腿_支付方向_利息归一化为负()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.固定值,
|
||||
interestDirection: (int)SwapDirectionEnum.支付,
|
||||
tdCloseInterest: 1000m,
|
||||
realizedInterest: 2000m,
|
||||
realizedInterestFee: 100m);
|
||||
|
||||
SwapEodPositionService.NormalizeInterestSignForReport(pos);
|
||||
|
||||
Assert.AreEqual(-1000m, pos.TdCloseInterest, 0.0001m, "TdCloseInterest 应归一化为 -1000");
|
||||
Assert.AreEqual(-2000m, pos.RealizedInterest, 0.0001m, "RealizedInterest 应归一化为 -2000");
|
||||
Assert.AreEqual(-1900m, pos.RealizedPnl, 0.0001m, "RealizedPnl = -2000 + 100 = -1900");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景3:预付金腿收取方向 → 利息方向反向为负
|
||||
// 原因:预付金腿的利息方向与保证金本金方向相反
|
||||
// TdCloseInterest=1000 → -1000
|
||||
// RealizedInterest=2000 → -2000
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 预付金腿_收取方向_利息反向为负()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.初始预付金,
|
||||
interestDirection: (int)SwapDirectionEnum.收取,
|
||||
tdCloseInterest: 1000m,
|
||||
realizedInterest: 2000m,
|
||||
realizedInterestFee: 0m);
|
||||
|
||||
SwapEodPositionService.NormalizeInterestSignForReport(pos);
|
||||
|
||||
Assert.AreEqual(-1000m, pos.TdCloseInterest, 0.0001m,
|
||||
"预付金腿收取方向:利息反向为负");
|
||||
Assert.AreEqual(-2000m, pos.RealizedInterest, 0.0001m,
|
||||
"预付金腿收取方向:累计利息反向为负");
|
||||
Assert.AreEqual(-2000m, pos.RealizedPnl, 0.0001m,
|
||||
"RealizedPnl = -2000 + 0 = -2000");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景4:预付金腿支付方向 → 利息方向反向为正
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 预付金腿_支付方向_利息反向为正()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.追加预付金,
|
||||
interestDirection: (int)SwapDirectionEnum.支付,
|
||||
tdCloseInterest: -1000m,
|
||||
realizedInterest: -2000m,
|
||||
realizedInterestFee: 50m);
|
||||
|
||||
SwapEodPositionService.NormalizeInterestSignForReport(pos);
|
||||
|
||||
Assert.AreEqual(1000m, pos.TdCloseInterest, 0.0001m,
|
||||
"预付金腿支付方向:利息反向为正");
|
||||
Assert.AreEqual(2000m, pos.RealizedInterest, 0.0001m,
|
||||
"预付金腿支付方向:累计利息反向为正");
|
||||
Assert.AreEqual(2050m, pos.RealizedPnl, 0.0001m,
|
||||
"RealizedPnl = 2000 + 50 = 2050");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景5:InterestDirection=0 → 不处理(兼容历史 0 方向脏数据)
|
||||
// 所有字段保持原值不变
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 方向为零_不处理_字段保持原值()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.固定值,
|
||||
interestDirection: 0,
|
||||
tdCloseInterest: -999m,
|
||||
realizedInterest: -888m,
|
||||
realizedInterestFee: 77m);
|
||||
pos.RealizedPnl = 555m; // 预置旧值
|
||||
|
||||
SwapEodPositionService.NormalizeInterestSignForReport(pos);
|
||||
|
||||
Assert.AreEqual(-999m, pos.TdCloseInterest, 0.0001m, "方向=0:TdCloseInterest 不变");
|
||||
Assert.AreEqual(-888m, pos.RealizedInterest, 0.0001m, "方向=0:RealizedInterest 不变");
|
||||
Assert.AreEqual(555m, pos.RealizedPnl, 0.0001m, "方向=0:RealizedPnl 不重算");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景6:InterestDirection 为负 → 不处理(防御性,对应原 "> 0" 判断)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 方向为负_不处理_字段保持原值()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.固定值,
|
||||
interestDirection: -1,
|
||||
tdCloseInterest: -999m,
|
||||
realizedInterest: -888m,
|
||||
realizedInterestFee: 77m);
|
||||
pos.RealizedPnl = 555m;
|
||||
|
||||
SwapEodPositionService.NormalizeInterestSignForReport(pos);
|
||||
|
||||
Assert.AreEqual(-999m, pos.TdCloseInterest, 0.0001m, "方向<0:TdCloseInterest 不变");
|
||||
Assert.AreEqual(555m, pos.RealizedPnl, 0.0001m, "方向<0:RealizedPnl 不重算");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景7:利息为零 → Math.Abs(0)=0,归一化后仍为 0,RealizedPnl=费用
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 利息为零_归一化后仍为零_盈亏等于费用()
|
||||
{
|
||||
var pos = NewPosition(
|
||||
interestMode: (int)InterestModeEnum.固定值,
|
||||
interestDirection: (int)SwapDirectionEnum.收取,
|
||||
tdCloseInterest: 0m,
|
||||
realizedInterest: 0m,
|
||||
realizedInterestFee: 300m);
|
||||
|
||||
SwapEodPositionService.NormalizeInterestSignForReport(pos);
|
||||
|
||||
Assert.AreEqual(0m, pos.TdCloseInterest, 0.0001m);
|
||||
Assert.AreEqual(0m, pos.RealizedInterest, 0.0001m);
|
||||
Assert.AreEqual(300m, pos.RealizedPnl, 0.0001m, "RealizedPnl = 0 + 300 = 300");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景8:null 入参 —— 抛 NullReferenceException(现状锚点)
|
||||
// 生产代码未加 null 检查,InterestDirection 解引用即 NRE。
|
||||
// 若未来改为 ArgumentNullException,此处需同步更新。
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void Null入参_抛NullReferenceException()
|
||||
{
|
||||
Assert.ThrowsException<NullReferenceException>(() =>
|
||||
SwapEodPositionService.NormalizeInterestSignForReport(null!));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helper:构造 eod_swap_position
|
||||
// ================================================================
|
||||
private static eod_swap_position NewPosition(
|
||||
int interestMode,
|
||||
int interestDirection,
|
||||
decimal tdCloseInterest,
|
||||
decimal realizedInterest,
|
||||
decimal realizedInterestFee)
|
||||
{
|
||||
return new eod_swap_position
|
||||
{
|
||||
InterestMode = interestMode,
|
||||
InterestDirection = interestDirection,
|
||||
TdCloseInterest = tdCloseInterest,
|
||||
RealizedInterest = realizedInterest,
|
||||
RealizedInterestFee = realizedInterestFee
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,5 +237,26 @@ namespace YLErp.Modules.SwapModule
|
||||
Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变");
|
||||
Console.WriteLine($"UW_008: A=0.3→B={service.SaveSwapDealCalls[0].data.ClosePercent}, HasPartialUnWind={td.HasPartialUnWind} ✅");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UW_009_SwapUnwind_名义本金写入前舍入两位小数()
|
||||
{
|
||||
var td = SwapDealTestFactory.CreateTrade();
|
||||
td.StockEqvNotional = 1000000.006;
|
||||
var service = new TestableSwapDealService(td);
|
||||
var unwindData = SwapDealTestFactory.CreateUnwindData(
|
||||
swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m,
|
||||
closeQty: 5000m, closeNotionalValue: 500000.004m, positionQty: 10000m);
|
||||
unwindData.NotionalValue = 1000000.006m;
|
||||
unwindData.PosiNotionalValue = 1000000.006m;
|
||||
|
||||
service.SwapUnwind(unwindData);
|
||||
|
||||
var savedData = service.SaveSwapDealCalls[0].data;
|
||||
Assert.AreEqual(1000000.01m, savedData.NotionalValue, "期初名义本金应按两位小数写入事件");
|
||||
Assert.AreEqual(1000000.01m, savedData.PosiNotionalValue, "剩余名义本金应按两位小数写入事件");
|
||||
Assert.AreEqual(500000.00m, savedData.CloseNotionalValue, "平仓名义本金应按两位小数写入事件");
|
||||
Assert.AreEqual(500000.01, td.StockEqvNotional, 0.000001, "trade 剩余名义本金应在扣减后舍入两位小数");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// SwapEodPositionService.CalculateWeightedMarginInterest 的回归测试。
|
||||
/// -----------------------------------------------------------------
|
||||
/// 守卫提交 a0f8dc9d "refactor(SwapModule): 简化预付金利息计算逻辑"。
|
||||
///
|
||||
/// 旧实现:按 InterestPrincipalFix 绝对值加权平均利率 × 总本金,对方向不敏感,
|
||||
/// 当收取/支付双腿并存时会把支付方向的利息错误计为收益。
|
||||
/// 新实现:InterestIncomeSum 已是各腿利息金额,按方向(收取=+1,支付=-1)轧差求和。
|
||||
///
|
||||
/// 抽为 public static 纯函数以支持无库单测。本测试锁定方向轧差契约。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SwapWeightedMarginInterestTest
|
||||
{
|
||||
// ================================================================
|
||||
// 场景1:空集合 → 0(Sum 空序列默认值)
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 空集合_返回0()
|
||||
{
|
||||
var result = SwapEodPositionService.CalculateWeightedMarginInterest(
|
||||
Enumerable.Empty<eod_swap_position>());
|
||||
|
||||
Assert.AreEqual(0m, result, 0.0001m, "空集合轧差应为 0");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景2:单腿收取 → InterestIncomeSum 原值
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 单腿收取_利息原值计入()
|
||||
{
|
||||
var margins = new[]
|
||||
{
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 1000m)
|
||||
};
|
||||
|
||||
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
|
||||
|
||||
Assert.AreEqual(1000m, result, 0.0001m, "单腿收取:+1000");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景3:单腿支付 → InterestIncomeSum 取负
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 单腿支付_利息取负计入()
|
||||
{
|
||||
var margins = new[]
|
||||
{
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 1000m)
|
||||
};
|
||||
|
||||
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
|
||||
|
||||
Assert.AreEqual(-1000m, result, 0.0001m, "单腿支付:-1000");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景4:双腿轧差(收取 1000 + 支付 600 → 400)
|
||||
// 旧 bug:按本金加权会忽略方向,结果不是 400
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 双腿轧差_收取大于支付_净额为正()
|
||||
{
|
||||
var margins = new[]
|
||||
{
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 1000m),
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 600m)
|
||||
};
|
||||
|
||||
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
|
||||
|
||||
Assert.AreEqual(400m, result, 0.0001m, "双腿轧差:1000 - 600 = 400");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景5:本金为零但 InterestIncomeSum 非零 —— 回归旧 bug 关键场景
|
||||
// 旧实现:totalWeight=0 → 返回 0,丢失利息
|
||||
// 新实现:不看本金,按方向轧差 InterestIncomeSum
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 本金为零_利息仍按方向轧差_不丢失()
|
||||
{
|
||||
var margins = new[]
|
||||
{
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.收取,
|
||||
interestIncomeSum: 500m, interestPrincipalFix: 0m),
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.支付,
|
||||
interestIncomeSum: 200m, interestPrincipalFix: 0m)
|
||||
};
|
||||
|
||||
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
|
||||
|
||||
Assert.AreEqual(300m, result, 0.0001m,
|
||||
"本金为零时旧实现返回 0 丢失利息,新实现应按方向轧差 = 500 - 200 = 300");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景6:多腿混合方向 —— 收取 100+200,支付 50+80 → 170
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void 多腿混合方向_正确轧差()
|
||||
{
|
||||
var margins = new[]
|
||||
{
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 100m),
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 50m),
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 200m),
|
||||
NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 80m)
|
||||
};
|
||||
|
||||
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
|
||||
|
||||
Assert.AreEqual(170m, result, 0.0001m, "多腿轧差:(100+200) - (50+80) = 170");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景7:null 入参防御 —— Sum 对 null 抛 ArgumentNullException
|
||||
// 仅作行为锚点:若未来改为 null 安全,此处需同步更新
|
||||
// ================================================================
|
||||
[TestMethod]
|
||||
public void Null入参_抛ArgumentNullException()
|
||||
{
|
||||
Assert.ThrowsException<ArgumentNullException>(() =>
|
||||
SwapEodPositionService.CalculateWeightedMarginInterest(null!));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helper:构造 eod_swap_position(只设置参与计算的 2 个字段 + 可选本金)
|
||||
// ================================================================
|
||||
private static eod_swap_position NewMargin(
|
||||
int interestDirection,
|
||||
decimal interestIncomeSum,
|
||||
decimal interestPrincipalFix = 0m)
|
||||
{
|
||||
return new eod_swap_position
|
||||
{
|
||||
InterestDirection = interestDirection,
|
||||
InterestIncomeSum = interestIncomeSum,
|
||||
InterestPrincipalFix = interestPrincipalFix
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
using YLErp.Model;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// SwapEodPositionService 的可测试化基类(纯内存,不连库)。
|
||||
/// ============================================================================
|
||||
/// 收敛各 ScenarioTest 中 Stub 子类的重复 override:
|
||||
/// - PersistEodSwapPosition:收集到列表,不写库
|
||||
/// - SaveAllChanges:no-op
|
||||
/// - GetCurrencyRate:返回 1.0(本币)
|
||||
/// - 统一构造函数(注入 OptUserInfo,标记 UnitTest 来源)
|
||||
///
|
||||
/// 暴露 PersistedPositions / SaveChangesCount / ClientCashCalls 等输出捕获属性,
|
||||
/// 供断言使用。各测试子类按需再 override 业务 seam(FindTrade/GetUnderlyingPrice 等)。
|
||||
///
|
||||
/// 设计原则:
|
||||
/// - 只收敛 8/8 Stub 都重复的高频 override,不预设业务数据注入方式
|
||||
/// (ComposePage 用属性字典、SwapPositionCompose 用构造函数 List,差异留给子类)
|
||||
/// - 不提供 Execute* 包装器(签名各异且大多只出现 1-2 次,留在各子类避免基类膨胀)
|
||||
/// ============================================================================
|
||||
/// </summary>
|
||||
public class TestableSwapEodPositionService : SwapEodPositionService
|
||||
{
|
||||
/// <summary>捕获所有持久化的 eod 持仓(按调用顺序)</summary>
|
||||
public List<eod_swap_position> PersistedPositions { get; } = new();
|
||||
|
||||
/// <summary>SaveAllChanges 调用次数</summary>
|
||||
public int SaveChangesCount { get; private set; }
|
||||
|
||||
/// <summary>AddClientCash 调用记录(金额, 操作)</summary>
|
||||
public List<(double amount, string action)> ClientCashCalls { get; } = new();
|
||||
|
||||
/// <summary>自增 id 模拟器(新增 eod 时分配 id)</summary>
|
||||
private int _nextId = 1;
|
||||
|
||||
protected TestableSwapEodPositionService(string testName)
|
||||
: base(new OptUserInfo(0, testName ?? nameof(TestableSwapEodPositionService), OptUserFrom.UnitTest))
|
||||
{
|
||||
}
|
||||
|
||||
// ===== 高频 seam override(8/8 Stub 都重复,收敛到基类)=====
|
||||
|
||||
protected override void PersistEodSwapPosition(eod_swap_position position)
|
||||
{
|
||||
if (position.id == 0) position.id = _nextId++;
|
||||
PersistedPositions.Add(position);
|
||||
}
|
||||
|
||||
protected override void SaveAllChanges()
|
||||
{
|
||||
SaveChangesCount++;
|
||||
}
|
||||
|
||||
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType)
|
||||
{
|
||||
return 1.0; // 本币,汇率=1
|
||||
}
|
||||
|
||||
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
|
||||
{
|
||||
ClientCashCalls.Add((amount, action));
|
||||
return _nextId++;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user