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测试全绿。
This commit is contained in:
hjhan
2026-07-03 09:04:57 +08:00
parent 129e4bed0d
commit e4a92c93ee
3 changed files with 271 additions and 172 deletions
@@ -5,6 +5,7 @@ using System.Linq.Expressions;
using YLErp.BLL;
using YLErp.BLL.Eod;
using YLErp.DBModels.Enums;
using YLErp.Helpers;
using YLErp.Modules.DataProviderModule;
using YLErp.Modules.EodModule;
using YLErp.Modules.TradeModule;
@@ -15,6 +16,7 @@ namespace YLErp.Modules.SwapModule
{
public class SwapDealService : SwapTradeBaseService
{
private static readonly IYcLogger Logger = LogFactory.GetLogger(nameof(SwapDealService));
protected virtual bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
return EodPriceQueryService.TryGetPrice(valueDate, underlyingCode, out rate);
@@ -125,6 +127,85 @@ namespace YLErp.Modules.SwapModule
{
}
#region
/// <summary>
/// 用 FrontendCalcReference 公式重算盈亏,与前端传来的 unwindData 比对,
/// 差异 > 0.01 记 Error 日志。整体 try/catch 吞异常——校验自身错误绝不阻断交易。
///
/// 目的:前端保持快速反馈(用户改输入立即算),后端不替代前端,仅做合理性兜底,
/// 为将来公式统一积累"前后端差异"数据。
/// </summary>
/// <param name="unwindData">前端算好传入的结算数据</param>
/// <param name="isIncome">true=结息页(income公式)false=平仓页(unwind公式)</param>
private void ValidateFrontendPnL(UnwindData unwindData, bool isIncome)
{
try
{
// 取浮动腿(有 UnderlyingCode 的),与前端 initDeal 取法一致
var floatLeg = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode));
// PosiGrossPrice 是 [NotMapped],前端可能没传;为空/0 时跳过(避免误报)
if (floatLeg == null || floatLeg.PosiGrossPrice == 0)
{
return;
}
// 用 UnderlyingInstrumentType 推 Multiplier(债券=100,否则1
bool isBond = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType);
int multiplier = isBond ? 100 : 1;
// 分类利息腿/预付金腿(InterestMode 初始预付金/追加预付金→Margin,否则→Interest
var input = new UnwindInput
{
Multiplier = multiplier,
PosiGrossPrice = floatLeg.PosiGrossPrice, // EntryDirtyPrice
TradingAmountAvg = floatLeg.TradingAmountAvg, // ExitDirtyPrice(界面×multiplier形态)
CloseQty = unwindData.CloseQty,
CloseNotionalValue = unwindData.CloseNotionalValue,
PayDirection = floatLeg.PayDirection,
PositionType = floatLeg.PositionType,
TradingFee = floatLeg.TradingFee.ToString(),
TradingFeePending = floatLeg.TradingFeePending.ToString(),
DividendIn = floatLeg.DividendIn.ToString(),
};
foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)))
{
var target = (leg.InterestMode == (int)InterestModeEnum.
|| leg.InterestMode == (int)InterestModeEnum.)
? input.MarginLegs : input.InterestLegs;
target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL });
}
var recalc = isIncome
? FrontendCalcReference.CalcIncome(input)
: FrontendCalcReference.CalcUnwind(input);
// 逐字段比对,差异 > 0.01 告警
const decimal threshold = 0.01m;
CheckDiff(nameof(recalc.SwapRealizedPnL), unwindData.SwapRealizedPnL, recalc.SwapRealizedPnL, threshold, unwindData.SwapTradeId, floatLeg);
CheckDiff(nameof(recalc.SwapCloseAmount), unwindData.SwapCloseAmount, recalc.SwapCloseAmount, threshold, unwindData.SwapTradeId, floatLeg);
CheckDiff("MarkClosePnl", floatLeg.MarkClosePnl, recalc.MarkClosePnl, threshold, unwindData.SwapTradeId, floatLeg);
}
catch (Exception ex)
{
// 校验自身错误绝不阻断交易
Logger.Error($"[互换盈亏校验异常] tradeId={unwindData.SwapTradeId} isIncome={isIncome}", ex);
}
}
private void CheckDiff(string field, decimal frontendVal, decimal backendVal, decimal threshold, int tradeId, swap_flow_event floatLeg)
{
decimal diff = frontendVal - backendVal;
if (Math.Abs(diff) > threshold)
{
Logger.Error($"[互换盈亏校验分歧] tradeId={tradeId} field={field} frontend={frontendVal} backend={backendVal} diff={diff} " +
$"floatLeg=[gross={floatLeg.PosiGrossPrice} avg={floatLeg.TradingAmountAvg} qty={floatLeg.Quantity} payDir={floatLeg.PayDirection} posType={floatLeg.PositionType}]");
}
}
#endregion
/// <summary>
/// 平仓初始化
/// </summary>
@@ -1093,6 +1174,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易
bool cofirm = false;
ExecuteInTransaction(() =>
{
@@ -1568,6 +1650,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
ValidateFrontendPnL(unwindData, isIncome: true); // 只读校验告警,不阻断交易
ExecuteInTransaction(() =>
{
int clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut._互换, unwindData.ValueDate);