- 添加了SetFloatingRealizedPnl方法统一计算已实现盈亏 - 已实现盈亏现在正确包含盯市、分红和费用三个组成部分 - 修复了多处RealizedPnl计算错误,确保数值准确性 - 为DF_008测试场景添加了专门的单元测试验证 - 移除了重复的盈亏计算代码,提高代码可维护性
421 lines
20 KiB
C#
421 lines
20 KiB
C#
using YLErp.DBModels;
|
||
using YLErp.DBModels.Enums;
|
||
|
||
namespace YLErp.Modules.SwapModule
|
||
{
|
||
/// <summary>
|
||
/// DealFloatPositions 浮动腿收盘归档 - 合成单元测试
|
||
/// ============================================================================
|
||
/// DealFloatPositions 处理浮动腿(标的持仓)的日终归档,三个分支:
|
||
/// ① 无前日eod → SaveCurrentEodInitalPosi(首日初始化,纯计算)
|
||
/// ② 有eod无平仓 → CopyEodPosition(复制+更新价格,依赖外部数据源)
|
||
/// ③ 有eod有平仓 → UpdateEodPosition(更新持仓,依赖外部数据源)
|
||
///
|
||
/// 当前可测范围:
|
||
/// - 分支选择逻辑(DealFloatPositions 调度层,纯内存)
|
||
/// - SaveCurrentEodInitalPosi(首日初始化,纯计算,无外部依赖)
|
||
/// CopyEodPosition/UpdateEodPosition 需额外接缝(UnderlyingCodePrice等),留后续。
|
||
/// ============================================================================
|
||
[TestClass]
|
||
public class DealFloatPositionsScenarioTest
|
||
{
|
||
private const int SwapTradeId = 200;
|
||
private static readonly DateTime TradeDate = new(2026, 4, 28);
|
||
private static readonly DateTime PreSettleDate = new(2026, 4, 27);
|
||
|
||
#region Stub
|
||
|
||
private sealed class StubEodService : SwapEodPositionService
|
||
{
|
||
// 可注入的外部数据
|
||
public decimal UnderlyingPrice { get; set; } = 1.00m;
|
||
public decimal Vobp { get; set; } = 0m;
|
||
public decimal BondPayment { get; set; } = 0m;
|
||
public decimal TaxRate { get; set; } = 0m;
|
||
public string UnderlyingCode { get; set; } = "210210.IB";
|
||
|
||
public StubEodService() : base(new OptUserInfo(0, nameof(DealFloatPositionsScenarioTest), OptUserFrom.UnitTest))
|
||
{
|
||
}
|
||
|
||
// override 外部依赖
|
||
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
|
||
{
|
||
vobp = Vobp;
|
||
return UnderlyingPrice;
|
||
}
|
||
|
||
protected override underlying_manager GetUnderlyingData(string underlyingCode)
|
||
{
|
||
return new underlying_manager { ValueAddedTax = TaxRate };
|
||
}
|
||
|
||
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
|
||
{
|
||
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,
|
||
List<eod_swap_position> eodPositions, List<eod_swap_position> todyEodPositions,
|
||
DateTime settleDate, trade td, DateTime preSettleDate, List<swap_flow_event> flowEvents)
|
||
{
|
||
return DealFloatPositions(posiList, realPosiList, eodPositions, todyEodPositions,
|
||
settleDate, td, preSettleDate, flowEvents);
|
||
}
|
||
|
||
public eod_swap_position ExecuteSaveCurrentEodInitalPosi(
|
||
swap_position position, trade td, DateTime settleDate, DateTime preSettleDate,
|
||
List<swap_flow_event> unwindEvents)
|
||
{
|
||
return SaveCurrentEodInitalPosi(position, td, settleDate, preSettleDate, unwindEvents);
|
||
}
|
||
|
||
public eod_swap_position ExecuteCopyEodPosition(
|
||
eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate)
|
||
{
|
||
return CopyEodPosition(eod, curretEod, td, valueDate, preSettleDate);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 数据构建
|
||
|
||
private static trade CreateTrade()
|
||
{
|
||
return new trade
|
||
{
|
||
id = SwapTradeId, TradeNumber = "UT-FLOAT-001", ClientId = 999998,
|
||
TradeType = "收益互换", TradeDate = PreSettleDate, StartDate = PreSettleDate,
|
||
ExerciseDate = new DateTime(2027, 4, 27), TradeStatus = "确认成交",
|
||
ValidState = "Valid", StructureType = "单标的",
|
||
QuoteCurrency = "CNY", SettlementCurrency = "CNY",
|
||
OriginalStockEqvNotional = 10000
|
||
};
|
||
}
|
||
|
||
private static swap_position CreateFloatPosition(int id = 3001, decimal qty = 10000m)
|
||
{
|
||
return new swap_position
|
||
{
|
||
id = id, SwapTradeId = SwapTradeId,
|
||
PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long,
|
||
UnderlyingCode = "210210.IB", ContractSize = 1m,
|
||
PosiQuantity = qty, PosiNotionalValue = qty,
|
||
PosiNetPrice = 1.005m, PosiGrossPrice = 1.002m,
|
||
PosiNetFeePrice = 1.004m, PosiNetNoFeePrice = 1.001m,
|
||
IsInitial = true, Invalid = false,
|
||
PosiTradingFee = 0, PosiTradingFeePending = 0
|
||
};
|
||
}
|
||
|
||
private static swap_flow_event CreateCloseEvent(int positionId, decimal qty, decimal markClosePnl = 100m)
|
||
{
|
||
return new swap_flow_event
|
||
{
|
||
SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.平仓,
|
||
PositionId = positionId, Quantity = qty,
|
||
MarkClosePnl = markClosePnl, DividendIn = 0, CloseFee = 5m,
|
||
TradingFeePending = 0, TradingAmount = qty * 1.002m,
|
||
UnwindDate = TradeDate, EventDate = TradeDate, PayDate = TradeDate,
|
||
DataState = (int)SwapFlowDateStateEnum.完成
|
||
};
|
||
}
|
||
|
||
#endregion
|
||
|
||
// ================================================================
|
||
// 场景1:空持仓列表 → 返回空列表
|
||
// ================================================================
|
||
|
||
[TestMethod]
|
||
public void DF_001_空持仓返回空列表()
|
||
{
|
||
var service = new StubEodService();
|
||
var result = service.ExecuteDealFloatPositions(
|
||
new List<swap_position>(), new List<swap_position>(),
|
||
new List<eod_swap_position>(), new List<eod_swap_position>(),
|
||
TradeDate, CreateTrade(), PreSettleDate, new List<swap_flow_event>());
|
||
|
||
Assert.AreEqual(0, result.Count, "空持仓应返回空列表");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景2:首日无前日eod → 走 SaveCurrentEodInitalPosi 分支
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 无前日eod(eodPositions 不含该持仓),应走 SaveCurrentEodInitalPosi。
|
||
/// SaveCurrentEodInitalPosi 是纯计算,验证基本字段正确。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DF_002_首日无eod走初始化分支()
|
||
{
|
||
var service = new StubEodService();
|
||
var td = CreateTrade();
|
||
var position = CreateFloatPosition();
|
||
|
||
var result = service.ExecuteDealFloatPositions(
|
||
new List<swap_position> { position },
|
||
new List<swap_position> { position },
|
||
new List<eod_swap_position>(), // 无前日eod
|
||
new List<eod_swap_position>(), // 无当日eod
|
||
TradeDate, td, PreSettleDate,
|
||
new List<swap_flow_event>()); // 无平仓事件
|
||
|
||
Assert.AreEqual(1, result.Count, "应生成1条浮动腿eod");
|
||
var eod = result[0];
|
||
Assert.AreEqual(position.id, eod.PositionId, "PositionId应匹配");
|
||
Assert.AreEqual(SwapTradeId, eod.SwapTradeId);
|
||
Assert.AreEqual(TradeDate, eod.ValueDate);
|
||
Console.WriteLine($"首日初始化: PosiQuantity={eod.PosiQuantity}, PosiNetPrice={eod.PosiNetPrice}");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景3:SaveCurrentEodInitalPosi 直接验证(纯计算方法)
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 直接测 SaveCurrentEodInitalPosi,验证它正确初始化 eod 的关键字段。
|
||
/// 这个方法无外部依赖(纯计算),可以精确验证值。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DF_003_首日初始化字段正确()
|
||
{
|
||
var service = new StubEodService();
|
||
var td = CreateTrade();
|
||
var position = CreateFloatPosition(qty: 10000m);
|
||
|
||
var eod = service.ExecuteSaveCurrentEodInitalPosi(
|
||
position, td, TradeDate, PreSettleDate, new List<swap_flow_event>());
|
||
|
||
// 验证关键字段
|
||
Assert.AreEqual(10000m, eod.PosiQuantity, "持仓数量应=初始数量");
|
||
Assert.AreEqual(1.005m, eod.PosiNetPrice, "含费均价应=持仓均价");
|
||
Assert.AreEqual(1.002m, eod.PosiGrossPrice, "不含费均价");
|
||
Assert.AreEqual((int)PositionTypeFlag.Long, eod.PositionType, "持仓类型");
|
||
Assert.AreEqual(SwapTradeId, eod.SwapTradeId, "交易ID");
|
||
Assert.AreEqual(td.ClientId, eod.ClientId, "客户ID");
|
||
Assert.AreEqual(0, eod.TdCloseQty, "首日无平仓数量");
|
||
Assert.AreEqual(0, eod.TdCloseMtmPnl, "首日无平仓盈亏");
|
||
Console.WriteLine($"首日初始化 eod: Qty={eod.PosiQuantity}, NetPrice={eod.PosiNetPrice}, GrossPrice={eod.PosiGrossPrice} ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景4:有前日eod无平仓 → 走 CopyEodPosition 分支
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 有前日eod但无平仓事件,应走 CopyEodPosition 分支。
|
||
/// CopyEodPosition 依赖外部数据源(DataCacheProvider/UnderlyingCodePrice),
|
||
/// 测试验证分支选择正确(不验证值),且不抛异常。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DF_004_有eod无平仓走Copy分支()
|
||
{
|
||
var service = new StubEodService();
|
||
var td = CreateTrade();
|
||
var position = CreateFloatPosition();
|
||
|
||
var preEod = new eod_swap_position
|
||
{
|
||
id = 5001, SwapTradeId = SwapTradeId, PositionId = position.id,
|
||
ValueDate = PreSettleDate, PosiQuantity = 10000m,
|
||
PosiNetPrice = 1.005m, PosiGrossPrice = 1.002m,
|
||
UnderlyingCode = "210210.IB", ContractSize = 1m,
|
||
PositionType = (int)PositionTypeFlag.Long, PosiDirection = 2
|
||
};
|
||
|
||
// CopyEodPosition 内部调 DataCacheProvider/UnderlyingCodePrice,
|
||
// 这些连缓存可能返回null → 方法 cs:1492 if(um==null) return curretEod
|
||
// 所以即使缓存没数据,也不会抛异常,只是字段不更新
|
||
try
|
||
{
|
||
var result = service.ExecuteDealFloatPositions(
|
||
new List<swap_position> { position },
|
||
new List<swap_position> { position },
|
||
new List<eod_swap_position> { preEod },
|
||
new List<eod_swap_position>(),
|
||
TradeDate, td, PreSettleDate,
|
||
new List<swap_flow_event>()); // 无平仓
|
||
|
||
Assert.AreEqual(1, result.Count, "应生成1条eod");
|
||
// um==null时 CopyEodPosition 直接返回 clone,字段不变
|
||
Assert.AreEqual(10000m, result[0].PosiQuantity, "无缓存时数量应=前日值");
|
||
Console.WriteLine($"Copy分支(无缓存): PosiQuantity={result[0].PosiQuantity}(保持前日值)");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Assert.Inconclusive($"CopyEodPosition 依赖外部数据源,需额外接缝。异常: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景5:CopyEodPosition 盯市盈亏计算(注入固定标的价格)
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// [DF_005] 无平仓日,标的价格变动 → PosiMtmPnL 正确反映浮动盈亏
|
||
/// ---------------------------------------------------------------
|
||
/// 前日持仓全价=1.002,当日标的价格=1.010(涨了)。
|
||
/// 多头收取方向,PosiMtmPnL = (1.010 - 1.002) × 10000 × 1(ContractSize) × 1(shortRatio) × 1(directionRatio)
|
||
/// = 0.008 × 10000 = 80
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DF_005_Copy分支盯市盈亏计算()
|
||
{
|
||
var service = new StubEodService();
|
||
service.UnderlyingPrice = 1.010m; // 当日标的价格
|
||
service.TaxRate = 0m;
|
||
service.BondPayment = 0m;
|
||
|
||
var td = CreateTrade();
|
||
var position = CreateFloatPosition();
|
||
|
||
var preEod = new eod_swap_position
|
||
{
|
||
id = 5001, SwapTradeId = SwapTradeId, PositionId = position.id,
|
||
ValueDate = PreSettleDate, PosiQuantity = 10000m,
|
||
PosiGrossPrice = 1.002m, PosiNetPrice = 1.005m,
|
||
UnderlyingCode = "210210.IB", ContractSize = 1m,
|
||
PositionType = (int)PositionTypeFlag.Long, PosiDirection = (int)SwapDirectionEnum.收取,
|
||
PosiDividendSum = 0m, PosiFeePending = 0m, PosiProfitSum = 0m
|
||
};
|
||
|
||
var result = service.ExecuteCopyEodPosition(preEod, null, td, TradeDate, PreSettleDate);
|
||
|
||
// PosiMtmPnL = (price - grossPrice) × qty × contractSize × shortRatio × directionRatio
|
||
// 收取方向 directionRatio=1, 多头 shortRatio=1
|
||
// = (1.010 - 1.002) × 10000 × 1 × 1 × 1 = 80
|
||
AssertDecimalEqual(80m, result.PosiMtmPnL, 0.01m, "盯市盈亏");
|
||
Console.WriteLine($"Copy分支盯市: PosiMtmPnL={result.PosiMtmPnL}((1.010-1.002)×10000=80)✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景6:CopyEodPosition 分红计算(注入固定付息)
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// [DF_006] 无平仓日,债券付息 → TdPosiDividend 和 PosiDividendSum 正确
|
||
/// ---------------------------------------------------------------
|
||
/// 注入 BondPayment=100(付息总额),增值税率=0。
|
||
/// TdPosiDividend = 100 / (1+0) × (1-0) = 100。
|
||
/// PosiDividendSum = preEod.PosiDividendSum(0) + TdPosiDividend(100) = 100。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DF_006_Copy分支分红计算()
|
||
{
|
||
var service = new StubEodService();
|
||
service.UnderlyingPrice = 1.002m; // 价格不变
|
||
service.TaxRate = 0m;
|
||
service.BondPayment = 100m; // 付息100
|
||
|
||
var td = CreateTrade();
|
||
var position = CreateFloatPosition();
|
||
|
||
var preEod = new eod_swap_position
|
||
{
|
||
id = 5001, SwapTradeId = SwapTradeId, PositionId = position.id,
|
||
ValueDate = PreSettleDate, PosiQuantity = 10000m,
|
||
PosiGrossPrice = 1.002m, PosiNetPrice = 1.005m,
|
||
UnderlyingCode = "210210.IB", ContractSize = 1m,
|
||
PositionType = (int)PositionTypeFlag.Long, PosiDirection = (int)SwapDirectionEnum.收取,
|
||
PosiDividendSum = 0m, PosiFeePending = 0m, PosiProfitSum = 0m
|
||
};
|
||
|
||
var result = service.ExecuteCopyEodPosition(preEod, null, td, TradeDate, PreSettleDate);
|
||
|
||
// TdPosiDividend = payment / (1+tax) × (1-tax) = 100 / 1 × 1 = 100
|
||
AssertDecimalEqual(100m, result.TdPosiDividend, 0.01m, "当日分红");
|
||
// PosiDividendSum = 前日(0) + 当日(100) = 100
|
||
AssertDecimalEqual(100m, result.PosiDividendSum, 0.01m, "待实现分红累计");
|
||
Console.WriteLine($"Copy分支分红: TdPosiDividend={result.TdPosiDividend}, PosiDividendSum={result.PosiDividendSum} ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景7:分红增值税调整(税率≠0)
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// [DF_007] 分红含增值税调整:BondPayment=100, tax=6%(0.06)
|
||
/// ---------------------------------------------------------------
|
||
/// TdPosiDividend = 100 / (1+0.06) × (1-0.06) = 100/1.06×0.94 ≈ 88.68
|
||
/// 验证增值税调整公式 cs:1508 payment / (1+tax) * (1-tax)。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DF_007_分红增值税调整()
|
||
{
|
||
var service = new StubEodService();
|
||
service.UnderlyingPrice = 1.002m;
|
||
service.TaxRate = 0.06m; // 增值税率6%
|
||
service.BondPayment = 100m;
|
||
|
||
var td = CreateTrade();
|
||
var position = CreateFloatPosition();
|
||
|
||
var preEod = new eod_swap_position
|
||
{
|
||
id = 5001, SwapTradeId = SwapTradeId, PositionId = position.id,
|
||
ValueDate = PreSettleDate, PosiQuantity = 10000m,
|
||
PosiGrossPrice = 1.002m, PosiNetPrice = 1.005m,
|
||
UnderlyingCode = "210210.IB", ContractSize = 1m,
|
||
PositionType = (int)PositionTypeFlag.Long, PosiDirection = 2,
|
||
PosiDividendSum = 0m, PosiFeePending = 0m, PosiProfitSum = 0m
|
||
};
|
||
|
||
var result = service.ExecuteCopyEodPosition(preEod, null, td, TradeDate, PreSettleDate);
|
||
|
||
// TdPosiDividend = 100 / 1.06 × 0.94 = 88.6792...
|
||
decimal expected = Math.Round(100m / 1.06m * 0.94m, 2);
|
||
AssertDecimalEqual(expected, result.TdPosiDividend, 0.01m, "增值税调整后分红");
|
||
Console.WriteLine($"分红增值税调整: 付息100, 税率6% → TdPosiDividend={result.TdPosiDividend}(期望{expected})✅");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void DF_008_CopyBranch_RealizedPnlIncludesRealizedFee()
|
||
{
|
||
var service = new StubEodService
|
||
{
|
||
UnderlyingPrice = 1.002m,
|
||
TaxRate = 0m,
|
||
BondPayment = 0m
|
||
};
|
||
var preEod = new eod_swap_position
|
||
{
|
||
id = 5001,
|
||
SwapTradeId = SwapTradeId,
|
||
PositionId = 3001,
|
||
ValueDate = PreSettleDate,
|
||
PosiQuantity = 10000m,
|
||
PosiGrossPrice = 1.002m,
|
||
PosiNetPrice = 1.005m,
|
||
UnderlyingCode = "210210.IB",
|
||
ContractSize = 1m,
|
||
PositionType = (int)PositionTypeFlag.Long,
|
||
PosiDirection = (int)SwapDirectionEnum.收取,
|
||
RealizedMtmPnL = 98000000m,
|
||
RealizedDividend = 0m,
|
||
RealizedFee = 100m,
|
||
RealizedPnl = 98000000m
|
||
};
|
||
|
||
var result = service.ExecuteCopyEodPosition(preEod, null, CreateTrade(), TradeDate, PreSettleDate);
|
||
|
||
Assert.AreEqual(98000100m, result.RealizedPnl);
|
||
}
|
||
|
||
private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "")
|
||
{
|
||
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
|
||
$"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}");
|
||
}
|
||
}
|
||
}
|