214 lines
12 KiB
C#
214 lines
12 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 不可用哨兵透传);
|
||
/// - 疑似到期/无剩余现金流:见 <see cref="IsMaturedDegenerate"/>。
|
||
/// 与前端 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;
|
||
}
|
||
if (IsMaturedDegenerate(data))
|
||
{
|
||
reason = "该债券疑似已到期或无剩余现金流(收益率=0、净价/全价均为面值100),无法反算,请核对债券到期日或手工填写";
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 疑似到期/无剩余现金流债券:估值日晚于到期日时底层 jquantlib 对空现金流求解得 ytm=0、
|
||
/// 净价/全价均为面值100——三者同时成立在现实在市券中几乎不可能(UAT 060203.IB 实证)。
|
||
/// 判定条件与前端 swapCalc.js::getBondCalcErrorMessage 内同款闸门保持一致,勿单边改动。
|
||
/// public 供单测直接覆盖(BondCalcHeplerTest)。
|
||
/// </summary>
|
||
public static bool IsMaturedDegenerate(CalBondResult data)
|
||
{
|
||
return data != null
|
||
&& data.ytm == 0m
|
||
&& data.cleanPrice == 100m
|
||
&& data.dirtyPrice == 100m;
|
||
}
|
||
}
|
||
|
||
}
|