Files
zszq-trs/YLErpDAL/Helpers/FrontendCalcReference.cs
T
hjhan e4a92c93ee feat(swap): 后端只读校验告警-搬FrontendCalcReference+SwapIncome/SwapUnwind插校验
前端保持快速反馈(用户改输入立即算),后端不替代前端,仅做合理性兜底校验。

1. 搬FrontendCalcReference到YLErpDAL/Helpers(测试与生产共用同一份公式,避免分叉)
   - 从FrontendCalcCharacterizationTest提取,改public,8个FC测试搬迁后全绿验证一致

2. SwapDealService新增ValidateFrontendPnL只读校验:
   - 从unwindData.FlowEvents取浮动腿,PosiGrossPrice==0时跳过(避免误报)
   - 调FrontendCalcReference重算,与前端传值逐字段比对
   - 差异>0.01记Logger.Error(带输入快照便于排查)
   - 整体try/catch吞异常,校验自身错误绝不阻断交易
   - SwapUnwind/SwapIncome各插一处(FindTrade后、ExecuteInTransaction前)

不碰NLog配置(告警进Console,生产由运维采集)。SwapModule 157测试全绿。
2026-07-03 09:04:57 +08:00

186 lines
9.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
namespace YLErp.Helpers
{
/// <summary>
/// 前端互换计算公式的后端参考实现(忠实重写 unwindSwapTrade.js / incomeSwapTrade.js)。
/// ============================================================================
/// 用途:
/// 1. 特征化测试的金标准(FrontendCalcCharacterizationTest)—— 冻结前端行为
/// 2. 后端只读校验(SwapDealService.ValidateFrontendPnL)—— 重算盈亏与前端传值比对
///
/// 注:前端保持快速反馈(用户改输入立即算),本类不替代前端,仅作后端校验兜底。
/// 命名规范(见《互换价格字段命名规范决策文档》):
/// PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice
/// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice
/// ============================================================================
/// </summary>
public 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-261calcFloatClosePnl + 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=收取)→+1PositionType(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"));
// calcCloseAmountSwapRealizedPnL/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:-1calcCloseAmount 内重新定义)
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 用 floatRatioPayDirection),与 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);
}
/// <summary>前端计算输入模型(对应前端可见的原始字段)</summary>
public 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();
}
/// <summary>利息腿/预付金腿输入</summary>
public class LegInput
{
public decimal InterestClosePnL; // 利息腿平仓盈亏(已含方向)
}
/// <summary>前端计算输出模型(前端算出的衍生字段)</summary>
public class UnwindResult
{
public decimal MarkClosePnl;
public decimal FloatPnlSum;
public decimal SwapRealizedPnL;
public decimal SwapCloseAmount;
public decimal SwapMarginRebatePnl;
public decimal TradingAmountFeeAvg;
}
}