- 估值日严格取互换起始日 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)。
194 lines
11 KiB
C#
194 lines
11 KiB
C#
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 或 >1000 视为离谱);
|
||
/// - 收益率(ytm,百分数口径):|ytm| > 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;
|
||
}
|
||
}
|
||
|
||
}
|