Files
zszq-trs/YLErpDAL/Helpers/BondCalcHepler.cs
T
hjhan 8694e6e3f4 债券互换三字段互算:估值日(开始日)缺失报错 + 补提漏交回归用例
- 估值日严格取互换起始日 StartDate,去掉"悄悄回退交易日(TradeDate)":
  真的为空时返回错误提示("请先填写开始日…或手动填写三项数值")并 return,
  不调用计算器、不覆盖手工输入,与需求(按开始日估值)完全一致。
- 抽纯函数 getBondStartDateMissingMsg(StartDate 空→文案/null) + 导出;
  复用 D3 的 shouldShowBondErr 去重,避免空开始日时价格格逐键连刷。
- BondCalcHepler.cs 仅补 XML 注释说明前端已强制 StartDate 必填;
  targetDate 空→代理默认 T+1 行为不变(兜底 RealtimePnlCalc 旧链路)。
- 补提此前漏提交的回归用例:bondCalc.test.js(×100/÷100 端到端 + 逐字段手动锁定)、
  swapCalc.test.js(D1/D2/D3 + 本次估值日缺失守卫)。
- jest bondCalc+swapCalc 全绿(68 passed)。
2026-07-23 16:49:40 +08:00

194 lines
11 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 Org.BouncyCastle.Asn1.Ocsp;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using YLErp.Model;
namespace YLErp.Helpers
{
/// <summary>
/// 计算器帮助类
/// 调用链:zszq-trs → zszq-bond-oms(/calc/cal_bond_value) → bond-calc 微服务。
/// 契约(与 bond-oms-ui / zszq-bond-oms 保持一致):
/// 请求体 { bondId, price, priceType(DP全价/CP净价/YD收益率), targetDate?(估值日 yyyy-MM-dd,缺省代理取 T+1) }
/// 成功响应 data:{ errCode:0, errMsg:null, dirtyPrice(全价), cleanPrice(净价), ytm(收益率%) }
/// 失败响应:success=false 且 message=中文原因(债券不存在/信息不全/参数非法/服务异常)
/// </summary>
public class BondCalcHepler
{
private const string CalcUrl = "/calc/cal_bond_value";
/// <summary>
/// 计算器(债券净价/全价/收益率互算)。
/// 兼容旧调用方(如 RealtimePnlCalc):返回 CalBondResult,失败返回 null。
/// targetDate 可选:估值日(yyyy-MM-dd)。前端(fe)已在债券互换录入页强制要求 StartDate(开始日)必填、
/// 为空则报错不调用本接口;故经 UI 的计算请求总会带上估值日,此处的"代理默认 T+1"仅兜底非 UI 调用方(如 RealtimePnlCalc)。
/// </summary>
public static CalBondResult BondCalc(string underlyingCode, decimal price, string priceType = "DP", string targetDate = null)
{
string _err;
return BondCalc(underlyingCode, price, priceType, out _err, targetDate);
}
/// <summary>
/// 计算器(带错误信息输出)。
/// 任何失败(地址未配/无响应/success=false/业务错误码 errCode!=0/异常)都返回 null
/// 并通过 errorMsg 带上**真实原因**,供上层(BondController)清晰反馈给用户。
/// 关键:即使 success=true,只要内部 errCode!=0(债券不存在/信息不全/参数非法)也视为失败,
/// 避免把 0/异常价格静默回写覆盖用户手工输入。
/// targetDate 可选:估值日(yyyy-MM-dd),透传给代理的 targetDate(底层 bond-calc 的 settlementDate);
/// 不传则代理默认取 T+1(下一工作日)。
/// </summary>
public static CalBondResult BondCalc(string underlyingCode, decimal price, string priceType, out string errorMsg, string targetDate = null)
{
errorMsg = null;
var baseUrl = Environment.GetEnvironmentVariable("BondOmsInterface_BaseUrl");
CalcBondRequest request = new CalcBondRequest()
{
bondId = underlyingCode,
price = price.ToString(),
priceType = priceType,
targetDate = targetDate,
};
if (string.IsNullOrEmpty(baseUrl))
{
errorMsg = "计算器服务地址未配置(BondOmsInterface_BaseUrl)";
return null;
}
try
{
LogFactory.GetLogger("BondCalcHepler").Info(
$"债券计算器请求: url={baseUrl}{CalcUrl} bondId={underlyingCode} price={price} priceType={priceType} targetDate={(string.IsNullOrEmpty(targetDate) ? "(空→代理默认T+1)" : targetDate)}");
var httpHelper = new HttpHelper(baseUrl, null);
// http 请求 Web项目接口
var result = httpHelper.PostRequestNoAuth<CalcBondRequest, CalcBondReponse>(CalcUrl, request).Result;
if (result == null)
{
errorMsg = "计算器服务无响应";
LogFactory.GetLogger("BondCalcHepler").Error("债券计算器无响应,地址:" + baseUrl + CalcUrl);
return null;
}
if (!result.success)
{
errorMsg = result.message ?? "债券计算失败";
LogFactory.GetLogger("BondCalcHepler").Error("债券计算失败:" + errorMsg);
return null;
}
if (result.data == null)
{
errorMsg = "计算器返回数据为空";
LogFactory.GetLogger("BondCalcHepler").Error("债券计算器返回 data 为空");
return null;
}
// 业务层错误码(债券不存在 / 债券信息不全 / 参数非法),zszq-bond-oms 在 success=true 时仍可能带 errCode!=0
if (result.data.errCode != 0)
{
errorMsg = result.data.errMsg ?? "债券计算业务错误";
LogFactory.GetLogger("BondCalcHepler").Error("债券计算业务错误(code=" + result.data.errCode + ")" + errorMsg);
return null;
}
// 防御性值域校验:errCode=0 但数值离谱(如净价变负 / 收益率量级爆炸 / 行权收益率哨兵 -999999)。
// 这类"成功但不可信"的响应现有 errCode 守卫拦不住,此处兜底:宁可返回 null+提示用户核对,
// 也绝不把垃圾值回写覆盖手工输入。
string absurdReason;
if (IsResultAbsurd(result.data, out absurdReason))
{
errorMsg = absurdReason;
LogFactory.GetLogger("BondCalcHepler").Error(
$"债券计算返回离谱值(bondId={underlyingCode}){absurdReason} | cleanPrice={result.data.cleanPrice} dirtyPrice={result.data.dirtyPrice} ytm={result.data.ytm}");
return null;
}
LogFactory.GetLogger("BondCalcHepler").Info(
$"债券计算器成功: bondId={underlyingCode} targetDate={targetDate} cleanPrice={result.data.cleanPrice} dirtyPrice={result.data.dirtyPrice} ytm={result.data.ytm}");
return result.data;
}
catch (Exception ex)
{
errorMsg = "请求债券计算器异常:" + ex.Message;
LogFactory.GetLogger("BondCalcHepler").Error("请求债券计算器时发生异常", ex);
return null;
}
}
/// <summary>
/// 按结算日计算(自动结算路径用)。与 BondCalc 同款健壮处理:
/// 地址未配/无响应/success=false/业务错误码/异常 均返回 null(不回写坏数据)。
/// </summary>
public static CalBondResult BondCalcByDate(string underlyingCode, decimal price, String targetDate, string priceType = "DP")
{
var baseUrl = Environment.GetEnvironmentVariable("BondOmsInterface_BaseUrl");
CalcBondRequest request = new CalcBondRequest()
{
bondId = underlyingCode,
price = price.ToString(),
priceType = priceType,
targetDate = targetDate,
};
if (!string.IsNullOrEmpty(baseUrl))
{
var httpHelper = new HttpHelper(baseUrl, null);
try
{
// http 请求 Web项目接口
var result = httpHelper.PostRequestNoAuth<CalcBondRequest, CalcBondReponse>(CalcUrl, request).Result;
if (result == null || !result.success || result.data == null || result.data.errCode != 0)
{
var msg = result != null ? (result.message ?? (result.data != null ? result.data.errMsg : null)) : "计算器服务无响应";
LogFactory.GetLogger("BondCalcHelper").Error("债券计算失败:" + msg);
return null;
}
// 防御性值域校验(同 BondCalc):拦截"成功但数值离谱"的响应,不回写坏数据
string absurdReason;
if (IsResultAbsurd(result.data, out absurdReason))
{
LogFactory.GetLogger("BondCalcHelper").Error(
$"债券计算返回离谱值(bondId={underlyingCode}){absurdReason} | cleanPrice={result.data.cleanPrice} dirtyPrice={result.data.dirtyPrice} ytm={result.data.ytm}");
return null;
}
return result.data;
}
catch (Exception ex)
{
LogFactory.GetLogger("BondCalcHelper").Error("请求计算器时发生异常", ex);
}
}
return null;
}
/// <summary>
/// 防御性值域校验:判断计算器返回值是否"离谱"errCode=0 但净价变负 / 收益率量级爆炸 / 命中哨兵值)。
/// 命中则上层返回 null、不回写,避免把不可信结果覆盖用户手工输入。规则:
/// - 净价/全价:占面值百分比,正常约 20~300,必须为正且不破千(≤0 或 &gt;1000 视为离谱);
/// - 收益率(ytm,百分数口径)|ytm| &gt; 100 视为爆炸(允许负利率债,但量级须合理);
/// - 哨兵值:任一字段命中 -999999 或 -999999×100(如 yieldToCP 不可用哨兵透传)。
/// 与前端 swapCalc.js::getBondCalcErrorMessage 的值域闸门保持一致口径。
/// </summary>
private static bool IsResultAbsurd(CalBondResult data, out string reason)
{
reason = null;
if (data == null) return false;
const decimal SENTINEL = -999999m;
bool Absurd(decimal v) => v == SENTINEL || v == SENTINEL * 100m;
if (Absurd(data.cleanPrice) || Absurd(data.dirtyPrice) || (data.ytm.HasValue && Absurd(data.ytm.Value)))
{
reason = "债券计算返回哨兵值(部分指标不可用),请检查估值日/价格输入或联系管理员核对债券计算服务";
return true;
}
if (data.cleanPrice <= 0m || data.cleanPrice > 1000m || data.dirtyPrice <= 0m || data.dirtyPrice > 1000m)
{
reason = "债券计算净价/全价超出合理范围(应为正值且接近面值百分比),请检查估值日/价格输入或联系管理员核对债券计算服务";
return true;
}
if (data.ytm.HasValue && Math.Abs(data.ytm.Value) > 100m)
{
reason = "债券计算收益率量级异常(" + data.ytm.Value + "),请检查估值日/价格输入或联系管理员核对债券计算服务";
return true;
}
return false;
}
}
}