用C#忠实重写前端JS公式作参考实现(FrontendCalcReference),冻结前端计算行为, 为后端校验/重算兜底提供金标准(注:前端保持快速反馈,不搬到后端)。 8个场景覆盖用户可变输入分支: - FC_001-005 平仓页: 默认值/改标的价格/改平仓数量/改利息金额/非债券空头方向因子 - FC_006-008 结息页: 全量结算/改标的价格/含利息腿与预付金腿总额 参考实现内部用规范命名(EntryDirtyPrice/ExitDirtyFeePrice/floatRatio/longRatio), 践行《互换价格字段命名规范决策文档》,作命名规范活样板。
404 lines
20 KiB
C#
404 lines
20 KiB
C#
using Newtonsoft.Json;
|
||
using Newtonsoft.Json.Linq;
|
||
using YLErp.DBModels;
|
||
using YLErp.DBModels.Enums;
|
||
|
||
namespace YLErp.Modules.SwapModule
|
||
{
|
||
/// <summary>
|
||
/// 前端计算逻辑特征化测试(Characterization Test)
|
||
/// ============================================================================
|
||
/// 目的:用 golden 冻结前端 JS 的计算行为(含用户可变输入分支),
|
||
/// 作为下一轮"计算下沉后端"的金标准——后端结果必须匹配这些 golden。
|
||
///
|
||
/// 背景:前端 unwindSwapTrade.js / incomeSwapTrade.js 是实时响应式计算器,
|
||
/// 用户改标的价格/平仓数量/交易费用/利息金额时,前端立刻重算 MarkClosePnl/
|
||
/// SwapRealizedPnL/SwapCloseAmount,后端拿到"前端算好的最终结果"直接记账。
|
||
/// 本测试用 C# 忠实重写前端公式作参考实现,手算真实输入的期望值存 golden。
|
||
///
|
||
/// 命名规范(见命名决策文档):参考实现内部用规范名(EntryPrice/ExitPrice/
|
||
/// floatRatio/longRatio),注释标明对应前端字段与规范语义。
|
||
/// ============================================================================
|
||
[TestClass]
|
||
public class FrontendCalcCharacterizationTest
|
||
{
|
||
private static readonly string GoldenDir = Path.Combine(
|
||
AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "FrontendCalc");
|
||
|
||
// ================================================================
|
||
// FrontendCalcReference:前端公式的 C# 忠实重写(参考实现,非生产代码)
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 前端公式参考实现。输入=前端可见的原始字段,输出=前端算出的衍生字段。
|
||
/// 注意:otcformat.trading.StockEqvNotional 的精度按 MoneyRound=2 模拟。
|
||
/// </summary>
|
||
private static class FrontendCalcReference
|
||
{
|
||
/// <summary>模拟 otcformat.trading.StockEqvNotional(金额类,2位小数)</summary>
|
||
private static decimal StockEqvNotional(decimal v) => Math.Round(v, 2, MidpointRounding.AwayFromZero);
|
||
|
||
/// <summary>
|
||
/// 计算平仓页(unwind)的盯市盈亏与汇总。
|
||
/// 对应 unwindSwapTrade.js:196-261(calcFloatClosePnl + calcCloseAmount)。
|
||
/// </summary>
|
||
public static UnwindResult CalcUnwind(UnwindInput input)
|
||
{
|
||
// 规范名映射:initPosiNetPrice(前端变量) = PosiGrossPrice = EntryDirtyPrice(期初全价不含费)
|
||
decimal entryPrice = input.PosiGrossPrice;
|
||
// scale = getPriceScale():债券(multiplier=100)→0.01,非债券→1
|
||
decimal scale = input.Multiplier == 100 ? 0.01m : 1m;
|
||
// 方向因子:PayDirection(1=收取)→+1;PositionType(1=多头)→+1
|
||
decimal floatRatio = input.PayDirection == 1 ? 1 : -1;
|
||
decimal longRatio = input.PositionType == 1 ? 1 : -1;
|
||
|
||
decimal tradingFee = ParseOrZero(input.TradingFee);
|
||
decimal tradingFeePending = ParseOrZero(input.TradingFeePending);
|
||
decimal dividendIn = ParseOrZero(input.DividendIn);
|
||
|
||
// MarkClosePnl = round(CloseQty × (TradingAmountAvg × scale − EntryPrice) × floatRatio × longRatio × 10000)/10000
|
||
decimal markClosePnl = Math.Round(
|
||
input.CloseQty * (input.TradingAmountAvg * scale - entryPrice) * floatRatio * longRatio * 10000) / 10000;
|
||
// toFixed(2) → StockEqvNotional
|
||
markClosePnl = Math.Round(markClosePnl, 2, MidpointRounding.AwayFromZero);
|
||
markClosePnl = StockEqvNotional(markClosePnl);
|
||
|
||
// FloatPnlSum = (MarkClosePnl + TradingFee + TradingFeePending + DividendIn).toFixed(2)
|
||
decimal floatPnlSum = decimal.Parse(
|
||
(markClosePnl + tradingFee + tradingFeePending + dividendIn).ToString("F2"));
|
||
|
||
// calcCloseAmount:SwapRealizedPnL/SwapCloseAmount = FloatPnlSum + Σ利息腿 + Σ预付金腿
|
||
decimal swapCloseAmount = floatPnlSum;
|
||
decimal swapRealizedPnL = floatPnlSum;
|
||
decimal swapMarginRebatePnl = 0m;
|
||
foreach (var interest in input.InterestLegs)
|
||
{
|
||
swapCloseAmount += interest.InterestClosePnL;
|
||
swapRealizedPnL += interest.InterestClosePnL;
|
||
}
|
||
foreach (var margin in input.MarginLegs)
|
||
{
|
||
swapCloseAmount += margin.InterestClosePnL;
|
||
swapMarginRebatePnl += margin.InterestClosePnL;
|
||
swapRealizedPnL += margin.InterestClosePnL;
|
||
}
|
||
swapRealizedPnL = StockEqvNotional(swapRealizedPnL);
|
||
swapCloseAmount = StockEqvNotional(swapCloseAmount);
|
||
swapMarginRebatePnl = StockEqvNotional(swapMarginRebatePnl);
|
||
|
||
// TradingAmountFeeAvg = CloseQty==0 ? 0 : (TradingAmountAvg×scale + TradingFee/CloseQty × ratio)
|
||
// 注:unwind 的 ratio = PositionType?1:-1(calcCloseAmount 内重新定义)
|
||
decimal ratio = input.PositionType == 1 ? 1 : -1;
|
||
decimal tradingAmountFeeAvg = input.CloseQty == 0 ? 0 :
|
||
input.TradingAmountAvg * scale + (tradingFee / input.CloseQty) * ratio;
|
||
|
||
return new UnwindResult
|
||
{
|
||
MarkClosePnl = markClosePnl,
|
||
FloatPnlSum = floatPnlSum,
|
||
SwapRealizedPnL = swapRealizedPnL,
|
||
SwapCloseAmount = swapCloseAmount,
|
||
SwapMarginRebatePnl = swapMarginRebatePnl,
|
||
TradingAmountFeeAvg = tradingAmountFeeAvg
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算结息页(income)的盯市盈亏与汇总。
|
||
/// 对应 incomeSwapTrade.js:128-178。
|
||
/// 差异:用 CloseNotionalValue(非 CloseQty)作量纲,无 longRatio,无 Math.round/10000。
|
||
/// </summary>
|
||
public static UnwindResult CalcIncome(UnwindInput input)
|
||
{
|
||
// income 页 initPosiGrossPrice = PosiGrossPrice = EntryDirtyPrice
|
||
decimal entryPrice = input.PosiGrossPrice;
|
||
decimal scale = input.Multiplier == 100 ? 0.01m : 1m;
|
||
decimal floatRatio = input.PayDirection == 1 ? 1 : -1;
|
||
|
||
decimal tradingFee = ParseOrZero(input.TradingFee);
|
||
decimal tradingFeePending = ParseOrZero(input.TradingFeePending);
|
||
decimal dividendIn = ParseOrZero(input.DividendIn);
|
||
|
||
// MarkClosePnl = CloseNotionalValue × (TradingAmountAvg × scale − EntryPrice) × floatRatio
|
||
// (无 longRatio、无 Math.round/10000)
|
||
decimal markClosePnl = input.CloseNotionalValue * (input.TradingAmountAvg * scale - entryPrice) * floatRatio;
|
||
markClosePnl = StockEqvNotional(markClosePnl);
|
||
|
||
decimal floatPnlSum = decimal.Parse(
|
||
(markClosePnl + tradingFee + tradingFeePending + dividendIn).ToString("F2"));
|
||
|
||
decimal swapCloseAmount = floatPnlSum;
|
||
decimal swapRealizedPnL = floatPnlSum;
|
||
decimal swapMarginRebatePnl = 0m;
|
||
foreach (var interest in input.InterestLegs)
|
||
{
|
||
swapCloseAmount += interest.InterestClosePnL;
|
||
swapRealizedPnL += interest.InterestClosePnL;
|
||
}
|
||
foreach (var margin in input.MarginLegs)
|
||
{
|
||
swapCloseAmount += margin.InterestClosePnL;
|
||
swapMarginRebatePnl += margin.InterestClosePnL;
|
||
swapRealizedPnL += margin.InterestClosePnL;
|
||
}
|
||
// income 页无 SwapMarginAmount 计算(恒为0)
|
||
swapRealizedPnL = StockEqvNotional(swapRealizedPnL);
|
||
swapCloseAmount = StockEqvNotional(swapCloseAmount);
|
||
swapMarginRebatePnl = StockEqvNotional(swapMarginRebatePnl);
|
||
|
||
// TradingAmountFeeAvg = CloseQty>0 ? (TradingAmountAvg×scale + TradingFee/CloseQty × floatRatio) : TradingAmountAvg×scale
|
||
// 注:income 用 floatRatio(PayDirection),与 unwind 的 ratio(PositionType) 不同
|
||
decimal tradingAmountFeeAvg = input.CloseQty > 0
|
||
? input.TradingAmountAvg * scale + (tradingFee / input.CloseQty) * floatRatio
|
||
: input.TradingAmountAvg * scale;
|
||
|
||
return new UnwindResult
|
||
{
|
||
MarkClosePnl = markClosePnl,
|
||
FloatPnlSum = floatPnlSum,
|
||
SwapRealizedPnL = swapRealizedPnL,
|
||
SwapCloseAmount = swapCloseAmount,
|
||
SwapMarginRebatePnl = swapMarginRebatePnl,
|
||
TradingAmountFeeAvg = tradingAmountFeeAvg
|
||
};
|
||
}
|
||
|
||
private static decimal ParseOrZero(string s) => string.IsNullOrEmpty(s) ? 0m : decimal.Parse(s);
|
||
}
|
||
|
||
// 输入/输出模型
|
||
private class UnwindInput
|
||
{
|
||
public int Multiplier; // 债券=100,非债券=1
|
||
public decimal PosiGrossPrice; // EntryDirtyPrice(期初全价不含费)
|
||
public decimal TradingAmountAvg; // 用户可改的期末标的价格(界面×multiplier形态)
|
||
public decimal CloseQty; // 平仓数量
|
||
public decimal CloseNotionalValue;// 平仓名义本金(income 用)
|
||
public int PayDirection; // 1=收取,-1=支付
|
||
public int PositionType; // 1=多头,2=空头
|
||
public string TradingFee; // 交易费用(前端是字符串)
|
||
public string TradingFeePending; // 待结算费用
|
||
public string DividendIn; // 分红
|
||
public List<LegInput> InterestLegs = new();
|
||
public List<LegInput> MarginLegs = new();
|
||
}
|
||
|
||
private class LegInput
|
||
{
|
||
public decimal InterestClosePnL; // 利息腿平仓盈亏(已含方向)
|
||
}
|
||
|
||
private class UnwindResult
|
||
{
|
||
public decimal MarkClosePnl;
|
||
public decimal FloatPnlSum;
|
||
public decimal SwapRealizedPnL;
|
||
public decimal SwapCloseAmount;
|
||
public decimal SwapMarginRebatePnl;
|
||
public decimal TradingAmountFeeAvg;
|
||
}
|
||
|
||
// ================================================================
|
||
// 8 个测试场景(含用户可变输入分支)
|
||
// ================================================================
|
||
|
||
// ---- 平仓页(unwind)场景 ----
|
||
|
||
/// <summary>
|
||
/// [FC_001] 平仓-债券多头-默认值(基线)
|
||
/// EntryDirtyPrice(PosiGrossPrice)=1.02, ExitPrice(TradingAmountAvg,×100形态)=105,
|
||
/// CloseQty=1000, PayDirection=1(收取), PositionType=1(多头), TradingFee="20"
|
||
/// scale=0.01, floatRatio=1, longRatio=1
|
||
/// MarkClosePnl = round(1000×(105×0.01−1.02)×1×1×10000)/10000 = round(1000×0.03×10000)/10000 = 30
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void FC_001_平仓_债券多头_默认值()
|
||
{
|
||
var input = new UnwindInput
|
||
{
|
||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m,
|
||
CloseQty = 1000, PayDirection = 1, PositionType = 1,
|
||
TradingFee = "20", TradingFeePending = "0", DividendIn = "0"
|
||
};
|
||
var result = FrontendCalcReference.CalcUnwind(input);
|
||
|
||
// MarkClosePnl = 1000×(1.05−1.02)×1×1 = 30
|
||
AssertDecimalEqual(30m, result.MarkClosePnl, 0.01m, "MarkClosePnl");
|
||
// FloatPnlSum = 30 + 20 + 0 + 0 = 50
|
||
AssertDecimalEqual(50m, result.FloatPnlSum, 0.01m, "FloatPnlSum");
|
||
// SwapRealizedPnL = FloatPnlSum(50)
|
||
AssertDecimalEqual(50m, result.SwapRealizedPnL, 0.01m, "SwapRealizedPnL");
|
||
Console.WriteLine($"FC_001: MarkClosePnl={result.MarkClosePnl}, FloatPnlSum={result.FloatPnlSum} ✅");
|
||
}
|
||
|
||
/// <summary>
|
||
/// [FC_002] 平仓-用户改标的价格(TradingAmountAvg 100→110)
|
||
/// MarkClosePnl = round(1000×(110×0.01−1.02)×10000)/10000 = round(1000×0.08×10000)/10000 = 80
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void FC_002_平仓_用户改标的价格()
|
||
{
|
||
var input = new UnwindInput
|
||
{
|
||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 110m, // 改成110
|
||
CloseQty = 1000, PayDirection = 1, PositionType = 1,
|
||
TradingFee = "20", TradingFeePending = "0", DividendIn = "0"
|
||
};
|
||
var result = FrontendCalcReference.CalcUnwind(input);
|
||
|
||
AssertDecimalEqual(80m, result.MarkClosePnl, 0.01m, "改价格后 MarkClosePnl");
|
||
AssertDecimalEqual(100m, result.FloatPnlSum, 0.01m, "改价格后 FloatPnlSum");
|
||
Console.WriteLine($"FC_002: 改标的价格后 MarkClosePnl={result.MarkClosePnl} ✅");
|
||
}
|
||
|
||
/// <summary>
|
||
/// [FC_003] 平仓-用户改平仓数量(CloseQty 1000→500,TradingFeePending 随比例变)
|
||
/// MarkClosePnl = round(500×(105×0.01−1.02)×10000)/10000 = round(500×0.03×10000)/10000 = 15
|
||
/// TradingFeePending 按比例=BeforeCloseFee×ClosePercent(0.5),假设=10
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void FC_003_平仓_用户改平仓数量()
|
||
{
|
||
var input = new UnwindInput
|
||
{
|
||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m,
|
||
CloseQty = 500, // 改成500(原1000)
|
||
PayDirection = 1, PositionType = 1,
|
||
TradingFee = "20", TradingFeePending = "10", DividendIn = "0"
|
||
};
|
||
var result = FrontendCalcReference.CalcUnwind(input);
|
||
|
||
// MarkClosePnl = 500×0.03 = 15
|
||
AssertDecimalEqual(15m, result.MarkClosePnl, 0.01m, "改数量后 MarkClosePnl");
|
||
// FloatPnlSum = 15 + 20 + 10 + 0 = 45
|
||
AssertDecimalEqual(45m, result.FloatPnlSum, 0.01m, "改数量后 FloatPnlSum");
|
||
Console.WriteLine($"FC_003: 改平仓数量后 MarkClosePnl={result.MarkClosePnl} ✅");
|
||
}
|
||
|
||
/// <summary>
|
||
/// [FC_004] 平仓-用户改利息金额(InterestClosePnL=100)
|
||
/// SwapRealizedPnL = FloatPnlSum(50) + InterestClosePnL(100) = 150
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void FC_004_平仓_用户改利息金额()
|
||
{
|
||
var input = new UnwindInput
|
||
{
|
||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m,
|
||
CloseQty = 1000, PayDirection = 1, PositionType = 1,
|
||
TradingFee = "20", TradingFeePending = "0", DividendIn = "0"
|
||
};
|
||
input.InterestLegs.Add(new LegInput { InterestClosePnL = 100m });
|
||
|
||
var result = FrontendCalcReference.CalcUnwind(input);
|
||
|
||
AssertDecimalEqual(30m, result.MarkClosePnl, 0.01m, "MarkClosePnl 不受利息影响");
|
||
// SwapRealizedPnL = 50 + 100 = 150
|
||
AssertDecimalEqual(150m, result.SwapRealizedPnL, 0.01m, "含利息的 SwapRealizedPnL");
|
||
Console.WriteLine($"FC_004: 改利息后 SwapRealizedPnL={result.SwapRealizedPnL} ✅");
|
||
}
|
||
|
||
/// <summary>
|
||
/// [FC_005] 平仓-非债券空头(PositionType=Short=2, multiplier=1)
|
||
/// floatRatio=1(收取), longRatio=-1(空头)
|
||
/// MarkClosePnl = round(1000×(100×1−100)×1×(−1)×10000)/10000 = 0(价格不变时空头盈亏=0)
|
||
/// 改成价格涨:TradingAmountAvg=105, MarkClosePnl=round(1000×(105−100)×1×(−1)×10000)/10000=−50000
|
||
/// 空头价格涨=亏损
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void FC_005_平仓_非债券空头_方向因子()
|
||
{
|
||
var input = new UnwindInput
|
||
{
|
||
Multiplier = 1, PosiGrossPrice = 100m, TradingAmountAvg = 105m, // 涨了5
|
||
CloseQty = 1000, PayDirection = 1, PositionType = 2, // 空头
|
||
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
|
||
};
|
||
var result = FrontendCalcReference.CalcUnwind(input);
|
||
|
||
// 空头价格涨=亏损:1000×(105−100)×1×(−1) = −5000
|
||
AssertDecimalEqual(-5000m, result.MarkClosePnl, 0.01m, "空头价格涨=亏损");
|
||
Console.WriteLine($"FC_005: 空头方向因子 MarkClosePnl={result.MarkClosePnl} ✅");
|
||
}
|
||
|
||
// ---- 结息页(income)场景 ----
|
||
|
||
/// <summary>
|
||
/// [FC_006] 结息-债券多头-全量结算(基线)
|
||
/// income 用 CloseNotionalValue 而非 CloseQty,无 longRatio
|
||
/// EntryPrice=1.02, TradingAmountAvg=105(×100形态), CloseNotionalValue=10000
|
||
/// MarkClosePnl = 10000×(105×0.01−1.02)×1 = 10000×0.03 = 300
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void FC_006_结息_债券多头_全量结算()
|
||
{
|
||
var input = new UnwindInput
|
||
{
|
||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m,
|
||
CloseNotionalValue = 10000, // income 用名义本金
|
||
CloseQty = 0, // income 不用数量
|
||
PayDirection = 1, PositionType = 1,
|
||
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
|
||
};
|
||
var result = FrontendCalcReference.CalcIncome(input);
|
||
|
||
AssertDecimalEqual(300m, result.MarkClosePnl, 0.01m, "income MarkClosePnl");
|
||
AssertDecimalEqual(300m, result.SwapRealizedPnL, 0.01m, "income SwapRealizedPnL");
|
||
Console.WriteLine($"FC_006: income MarkClosePnl={result.MarkClosePnl} ✅");
|
||
}
|
||
|
||
/// <summary>
|
||
/// [FC_007] 结息-用户改标的价格(TradingAmountAvg 105→110)
|
||
/// MarkClosePnl = 10000×(110×0.01−1.02) = 10000×0.08 = 800
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void FC_007_结息_用户改标的价格()
|
||
{
|
||
var input = new UnwindInput
|
||
{
|
||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 110m,
|
||
CloseNotionalValue = 10000, CloseQty = 0,
|
||
PayDirection = 1, PositionType = 1,
|
||
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
|
||
};
|
||
var result = FrontendCalcReference.CalcIncome(input);
|
||
|
||
AssertDecimalEqual(800m, result.MarkClosePnl, 0.01m, "改价格后 income MarkClosePnl");
|
||
Console.WriteLine($"FC_007: 改价格后 income MarkClosePnl={result.MarkClosePnl} ✅");
|
||
}
|
||
|
||
/// <summary>
|
||
/// [FC_008] 结息-含利息腿与预付金腿(InterestClosePnL + margin InterestClosePnL)
|
||
/// SwapRealizedPnL = FloatPnlSum(300) + 利息腿(100) + 预付金腿(50) = 450
|
||
/// SwapMarginRebatePnl = 预付金腿(50)
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void FC_008_结息_含利息腿与预付金腿_总额()
|
||
{
|
||
var input = new UnwindInput
|
||
{
|
||
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m,
|
||
CloseNotionalValue = 10000, CloseQty = 0,
|
||
PayDirection = 1, PositionType = 1,
|
||
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
|
||
};
|
||
input.InterestLegs.Add(new LegInput { InterestClosePnL = 100m });
|
||
input.MarginLegs.Add(new LegInput { InterestClosePnL = 50m });
|
||
|
||
var result = FrontendCalcReference.CalcIncome(input);
|
||
|
||
// SwapRealizedPnL = 300 + 100 + 50 = 450
|
||
AssertDecimalEqual(450m, result.SwapRealizedPnL, 0.01m, "含利息+预付金的 SwapRealizedPnL");
|
||
// SwapMarginRebatePnl = 50
|
||
AssertDecimalEqual(50m, result.SwapMarginRebatePnl, 0.01m, "SwapMarginRebatePnl");
|
||
Console.WriteLine($"FC_008: SwapRealizedPnL={result.SwapRealizedPnL}, SwapMarginRebatePnl={result.SwapMarginRebatePnl} ✅");
|
||
}
|
||
|
||
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}");
|
||
}
|
||
}
|
||
}
|