Merge remote-tracking branch 'origin/glms/feature/1.4.2' into glms/feature/1.4.2

This commit is contained in:
张名锐
2026-08-27 16:14:07 +08:00
40 changed files with 2193 additions and 268 deletions
@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace YLErp.DBModels
{
[Table("eod_bond_lending_rate")]
public class eod_bond_lending_rate
{
[Key]
public long id { get; set; }
[DisplayName("业务唯一代码")]
public string RecordCode { get; set; }
[DisplayName("业务日期")]
public DateTime ValueDate { get; set; }
[DisplayName("标的债券代码")]
public string UnderlyingSecurityId { get; set; }
[DisplayName("标的债券名称")]
public string UnderlyingSymbol { get; set; }
[DisplayName("前收盘费率(%)")]
public decimal? PreCloseRate { get; set; }
[DisplayName("前加权平均费率(%)")]
public decimal? PreWeightedAvgRate { get; set; }
[DisplayName("开盘费率(%)")]
public decimal? OpenRate { get; set; }
[DisplayName("最新费率(%)")]
public decimal? LatestRate { get; set; }
[DisplayName("最高费率(%)")]
public decimal? HighRate { get; set; }
[DisplayName("最低费率(%)")]
public decimal? LowRate { get; set; }
[DisplayName("收盘费率(%)")]
public decimal? CloseRate { get; set; }
[DisplayName("加权平均费率(%)")]
public decimal? WeightedAvgRate { get; set; }
[DisplayName("成交量(元)")]
public decimal? TurnoverAmount { get; set; }
[DisplayName("操作人ID")]
public int OptId { get; set; }
[DisplayName("操作人名称")]
public string OptName { get; set; }
[DisplayName("操作时间")]
public DateTime OptDate { get; set; }
[DisplayName("数据来源")]
public string DataSource { get; set; }
[DisplayName("源行情时间")]
public string SourceTime { get; set; }
}
}
@@ -8,6 +8,7 @@ using YLErp.Modules.MarginModule;
using YLErp.Modules.UnderlyingModule;
using YLErp.Plugins.TradeDocGenerator;
using YLErp.Plugins.TradeDocGenerator.Abstracts;
using YieldChain.Helpers;
namespace YLErp.Plugins.GuoLian.DocumentGenerator
{
@@ -245,11 +246,26 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
}
dic["主协议编号"] = mainProtocolCode ?? "";
// 主协议类型:仅 NAFMII 使用中国银行间市场协议,其余类型按 SAC 展示
// 主协议类型取客户开户维护的下拉选择(client_meta.MainProtocolType):1=NAFMII,其余按 SAC。
var mainProtocolType = Context.GetClientMeta(client.id, "MainProtocolType")?.MetaValue;
var isNafmii = mainProtocolType == "1";
dic["IsSac"] = !isNafmii;
dic["IsNafmii"] = isNafmii;
if (string.IsNullOrWhiteSpace(mainProtocolType))
{
LogFactory.GetLogger("确认书生成").Info($"客户 {client.Number} {client.Name} 未维护主协议类型,确认书协议段按 SAC 勾选展示");
}
// 协议段勾选框对应模板占位符,按客户签署的主协议类型动态勾选,替代模板中硬编码的 Wingdings 2 复选框
static string Check(bool on) => on ? "☑" : "□";
dic["主协议SAC勾选"] = Check(!isNafmii);
dic["主协议NAFMII勾选"] = Check(isNafmii);
dic["补充协议SAC勾选"] = Check(!isNafmii);
dic["补充协议NAFMII勾选"] = Check(isNafmii);
// 定义文件签署维度暂无客户协议数据,统一展示空框,待客户资料补齐后接入
dic["协会证券业勾选"] = "□";
dic["协会交易商勾选"] = "□";
dic["定义文件商品勾选"] = "□";
dic["定义文件利率勾选"] = "□";
dic["定义文件债券勾选"] = "□";
// 补充协议编号:优先取 client 表字段,为空时从 client_meta 表兜底
var supProtocolCode = client.SupProtocolCode;
@@ -142,6 +142,88 @@ namespace YLErp.Modules.EodModuleTests
Assert.AreEqual(0m, item.InitMarginLoss);
}
[TestMethod]
public void BuildContract_历史类别为空_仍推送全部TRS合约()
{
var valueDate = new DateTime(2026, 2, 10);
var eodSwap = new eod_swap { ValueDate = valueDate, SwapTradeId = 1645, SwapTradeNo = "ZSZQ-IS-202602090001" };
var positions = new List<eod_swap_position>
{
new() { SwapTradeId = 1645, PositionId = 33306, UnderlyingCode = "220208.IB", PositionType = 1 },
new() { SwapTradeId = 1645, PositionId = 33307, InterestMode = (int)InterestModeEnum., InterestRateDefault = 0.001m, InterestDirection = 1 }
};
var swapPositions = new Dictionary<long, swap_position>
{
[33306] = new() { id = 33306, category_tag = null },
[33307] = new() { id = 33307, category_tag = null }
};
var item = TrsContractKafkaPushService.BuildContract(
eodSwap,
new Dictionary<int, trade> { [1645] = new() { id = 1645, UnderlyingCode = "220208.IB" } },
positions,
swapPositions);
Assert.AreEqual(0m, item.FixedRate);
Assert.AreEqual(1, item.InterestDirection);
Assert.AreEqual(1, item.FloatingDirection);
}
[TestMethod]
public void BuildContract_增强收益腿不参与固定利率取值()
{
var eodSwap = new eod_swap { ValueDate = new DateTime(2026, 8, 24), SwapTradeId = 7 };
var positions = new List<eod_swap_position>
{
new() { SwapTradeId = 7, PositionId = 101, UnderlyingCode = "600000.SH", PositionType = 2 },
new() { SwapTradeId = 7, PositionId = 102, InterestMode = (int)InterestModeEnum., InterestRateDefault = 0.0123m, InterestDirection = 1 },
new() { SwapTradeId = 7, PositionId = 103, InterestMode = (int)InterestModeEnum., InterestRateDefault = 0.0999m, InterestDirection = 2 }
};
var swapPositions = new Dictionary<long, swap_position>
{
[101] = new() { id = 101, category_tag = null },
[102] = new() { id = 102, category_tag = "互换利率" },
[103] = new() { id = 103, category_tag = "增强收益" }
};
var item = TrsContractKafkaPushService.BuildContract(
eodSwap,
new Dictionary<int, trade> { [7] = new() { id = 7, UnderlyingCode = "600000.SH" } },
positions,
swapPositions);
Assert.AreEqual(0.0123m, item.FixedRate);
Assert.AreEqual(1, item.InterestDirection);
Assert.AreEqual(2, item.FloatingDirection);
}
[TestMethod]
public void BuildContract_多条互换利率腿_取第一条()
{
var eodSwap = new eod_swap { ValueDate = new DateTime(2026, 8, 24), SwapTradeId = 7 };
var positions = new List<eod_swap_position>
{
new() { SwapTradeId = 7, PositionId = 101, UnderlyingCode = "600000.SH", PositionType = 1 },
new() { SwapTradeId = 7, PositionId = 102, InterestMode = (int)InterestModeEnum., InterestRateDefault = 0.0123m, InterestDirection = 1 },
new() { SwapTradeId = 7, PositionId = 103, InterestMode = (int)InterestModeEnum., InterestRateDefault = 0.0456m, InterestDirection = 2 }
};
var swapPositions = new Dictionary<long, swap_position>
{
[101] = new() { id = 101, category_tag = null },
[102] = new() { id = 102, category_tag = "互换利率" },
[103] = new() { id = 103, category_tag = "互换利率" }
};
var item = TrsContractKafkaPushService.BuildContract(
eodSwap,
new Dictionary<int, trade> { [7] = new() { id = 7, UnderlyingCode = "600000.SH" } },
positions,
swapPositions);
Assert.AreEqual(0.0123m, item.FixedRate);
Assert.AreEqual(1, item.InterestDirection);
}
private static TestableTrsContractKafkaPushService CreateService(RecordingKafkaProducer producer, DateTime valueDate)
{
return new TestableTrsContractKafkaPushService(
@@ -94,6 +94,23 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(20, day3, 1e-6);
}
/// <summary>
/// 授信垫付的初始预付金计入已缴(2026-08-27 修正,交易2538实证):
/// 初始预付金走授信腿不产生应付预付金流水(现金净收额=0),已缴初始=授信初始占用净额——
/// 目标追加 = 维持 −(现金净收额+初始授信占用净额),否则每个结算日按维持全额重复开追加。
/// </summary>
[TestMethod]
public void AM_009_授信初始预付金计入已缴_不重复追加()
{
//交易2538 首日实际数据:维持2,808,000;初始预付金2,000,000全走授信(现金净收额0)
var payableNetCash = 0d;
var initCreditNet = 2_000_000d;
var target = SwapAdditionalMarginCalc.CalcTarget(2_808_000, payableNetCash + initCreditNet);
Assert.AreEqual(808_000, target, 1e-6);
//对照:修正前只扣现金净收额 → 目标2,808,000 全额追加(多收2,000,000 授信占用,即本BUG
}
/// <summary>
/// 追保回落(维持下降到应付之下)不返还:目标为 0 → 已补足保持,新增 0;
/// 超付部分由可用资金公式的负缺口(Σ维持−累计)体现,不产生返还记录。
@@ -131,6 +131,55 @@ namespace YLErp.Modules.SwapModule
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 2.00));
}
/// <summary>
/// 层间空隙不落最深层(2026-08-27 修正,交易2538实证):
/// 真实模板空头档位 (−∞,0.99](1.00,1.03](0.99,1.00] 为价格在期初附近小幅波动的"未触发追保"空隙——
/// 此前兜底一律按最深层计,ratio≈0.99999 被错误收取最深档(0.04×基数)。
/// 修正后:空隙返回 null(追加保证金按0);仅穿出最深层边界(ratio>最深上界)才按最深层计。
/// </summary>
[TestMethod]
public void SS_008_空头层间空隙_不落最深层_追加为零()
{
//模板"保证金测试01"实际配置形态:第1层 (−∞,0.99],第2~4层 (1.00,1.03]
var tiers = new List<SpanTierConfig>
{
Tier(null, 0.99, 0.01),
Tier(1.00, 1.01, 0.02),
Tier(1.01, 1.02, 0.03),
Tier(1.02, 1.03, 0.04),
};
//交易2538 08-24:净价100 → ratio = 100/100.0011 ≈ 0.99999 落 (0.99,1.00] 空隙 → 不追保
Assert.IsNull(SwapSpanMarginCalc.MatchTier(tiers, false, 0.99999));
Assert.IsNull(SwapSpanMarginCalc.MatchTier(tiers, false, 0.995));
//空隙边界归第1层
Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, false, 0.99));
//空隙上方正常落档
Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, false, 1.005));
Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, false, 1.015));
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.025));
//穿出最深层上界 → 按最深层计(原有语义不变)
Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.0301));
}
/// <summary>
/// 多头层间空隙同理:空隙返回 null(追加0),仅跌破最深层下界才按最深层计。
/// </summary>
[TestMethod]
public void SS_009_多头层间空隙_不落最深层()
{
var tiers = new List<SpanTierConfig>
{
Tier(0.98, null, 0.01),
Tier(0.96, 0.97, 0.03), //留出 (0.97,0.98) 空隙
Tier(0.95, 0.96, 0.04),
};
Assert.IsNull(SwapSpanMarginCalc.MatchTier(tiers, true, 0.975));
Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, true, 0.9699));
Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, true, 0.955));
//穿出最深层下界 → 按最深层计(原有语义不变)
Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, true, 0.94));
}
[TestMethod]
public void SS_008_无可用层_返回null()
{
@@ -0,0 +1,89 @@
using System.IO.Compression;
using System.Text;
using Newtonsoft.Json.Linq;
using YLErp.Office.Converters;
namespace YLErp.PluginTests
{
/// <summary>
/// 国联确认书模板协议段勾选框占位符化后的渲染验证:
/// 客户开户维护的主协议类型(client_meta.MainProtocolType1=NAFMII,其余 SAC
/// 决定协议段 ☑/□ 勾选位置,模板不再依赖 Wingdings 2 硬编码复选框。
/// </summary>
[TestClass]
public class TestGuoLianConfirmTemplate
{
private static string TemplatePath =>
Path.Combine(AppContext.BaseDirectory, "..\\..\\..\\..\\Plugins\\YLErp.Plugins.GuoLian\\App_Docs\\contract_template",
"国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx");
private static string OutputDir => Path.Combine(AppContext.BaseDirectory, "TestOutputs");
private static JsonVarDic BuildVarDic(bool isNafmii)
{
var varDic = new JsonVarDic();
varDic.SetVar("合同编号", "GL-20260827-001");
varDic.SetVar("客户名称", "测试客户");
varDic.SetVar("主协议编号", "GL-ZU-2026-001");
varDic.SetVar("补充协议编号", "GL-BC-2026-001");
static string Check(bool on) => on ? "☑" : "□";
varDic.SetVar("主协议SAC勾选", Check(!isNafmii));
varDic.SetVar("主协议NAFMII勾选", Check(isNafmii));
varDic.SetVar("补充协议SAC勾选", Check(!isNafmii));
varDic.SetVar("补充协议NAFMII勾选", Check(isNafmii));
varDic.SetVar("协会证券业勾选", "□");
varDic.SetVar("协会交易商勾选", "□");
varDic.SetVar("定义文件商品勾选", "□");
varDic.SetVar("定义文件利率勾选", "□");
varDic.SetVar("定义文件债券勾选", "□");
// 利率类型勾选框(同模板内另一处 ☑/□ 占位符),一并驱动以便预览完整
varDic.SetVar("IsFixed", isNafmii ? "□" : "☑");
varDic.SetVar("IsFloat", isNafmii ? "☑" : "□");
return varDic;
}
private static string RenderAndReadBody(bool isNafmii, string fileName)
{
Directory.CreateDirectory(OutputDir);
var targetPath = Path.Combine(OutputDir, fileName);
OfficeFileConverter.ConvertByUsingDocTemplate(TemplatePath, targetPath, BuildVarDic(isNafmii));
using var zip = ZipFile.OpenRead(targetPath);
var entry = zip.GetEntry("word/document.xml");
using var stream = entry.Open();
using var reader = new StreamReader(stream, Encoding.UTF8);
var xml = reader.ReadToEnd();
// 勾选框与协议名称分属不同 run,断言前拼接纯文本
var plainText = new StringBuilder();
foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(xml, @"<w:t[^>]*>(.*?)</w:t>"))
{
plainText.Append(m.Groups[1].Value);
}
return plainText.ToString();
}
[TestMethod("测试-国联确认书-SAC客户协议段勾选")]
public void TestSacAgreementChecked()
{
var body = RenderAndReadBody(isNafmii: false, "guolian_confirm_sac.docx");
Assert.IsTrue(body.Contains("☑《中国证券期货市场衍生品交易主协议》/"), "SAC 客户主协议应勾选 SAC");
Assert.IsTrue(body.Contains("□《中国银行间市场金融衍生产品交易主协议(2009年版)》"), "SAC 客户主协议不应勾选 NAFMII");
Assert.IsTrue(body.Contains("☑《中国证券期货市场衍生品交易主协议》补充协议"), "SAC 客户补充协议应勾选 SAC");
Assert.IsFalse(body.Contains("{{主协议SAC勾选}}"), "协议段占位符应全部被替换");
}
[TestMethod("测试-国联确认书-NAFMII客户协议段勾选")]
public void TestNafmiiAgreementChecked()
{
var body = RenderAndReadBody(isNafmii: true, "guolian_confirm_nafmii.docx");
Assert.IsTrue(body.Contains("□《中国证券期货市场衍生品交易主协议》/"), "NAFMII 客户主协议不应勾选 SAC");
Assert.IsTrue(body.Contains("☑《中国银行间市场金融衍生产品交易主协议(2009年版)》"), "NAFMII 客户主协议应勾选 NAFMII");
Assert.IsTrue(body.Contains("☑《中国银行间市场金融衍生产品交易主协议(2009年版)补充协议》"), "NAFMII 客户补充协议应勾选 NAFMII");
Assert.IsFalse(body.Contains("{{主协议NAFMII勾选}}"), "协议段占位符应全部被替换");
}
}
}
@@ -1627,8 +1627,10 @@ namespace YLErp.BLL.EodSettlement
VmFundSum = roundFunc(item.VmFundSum),
OtherFund = roundFunc(item.OtherFund),
AmountFund = roundFunc(item.AmountFund),
MySideMargin = roundFunc(item.MySideMargin),
MaintenanceMargin = roundFunc(item.MaintenanceMargin),
//初始/维持保证金金额与每日估值报告同口径(SettlementReportService 账户状况块):
//初始=应付预付金流水净额(SwapInitMargin),维持=MySideMarginclient_span 反号聚合)
MySideMargin = roundFunc(item.SwapInitMargin),
MaintenanceMargin = roundFunc(-item.MySideMargin),
SwapMarketAmount = roundFunc(item.SwapMarketAmount),
SwapMarketAmountPercent = item.SwapMarketAmountPercent,
AvailableAmount = roundFunc(item.AvailableAmount),
+1
View File
@@ -255,6 +255,7 @@ namespace YLErp.BLL
public DbSet<eod_trade_risk_hedgevol_s> eod_trade_risk_hedgevol_s { get; set; }
public DbSet<eod_trade_risk_openvol_s> eod_trade_risk_openvol_s { get; set; }
public DbSet<eod_currency_rate> eod_currency_rate { get; set; }
public DbSet<eod_bond_lending_rate> eod_bond_lending_rate { get; set; }
public DbSet<StockBlackWhite> Stock_BlackWhite { get; set; }
public DbSet<SalesCommissionInfo> SalesCommissionInfo { get; set; }
public DbSet<SalesCommissionDetail> SalesCommissionDetail { get; set; }
@@ -280,6 +280,36 @@ namespace YLErp.Modules.DataProviderModule
return bondPrice;
}
/// <summary>
/// 获取债券借贷费率最新行情
/// </summary>
/// <param name="valueDate"></param>
/// <param name="underlyingSecurityId"></param>
/// <returns></returns>
public static eod_bond_lending_rate GetBondLendingRate(DateTime valueDate, string underlyingSecurityId)
{
if (string.IsNullOrWhiteSpace(underlyingSecurityId))
{
return null;
}
using var db = DbContextFactory.GetYLDbContext();
return db.eod_bond_lending_rate
.Where(x => x.UnderlyingSecurityId == underlyingSecurityId && x.ValueDate <= valueDate.Date)
.OrderByDescending(x => x.ValueDate)
.FirstOrDefault();
}
/// <summary>
/// 尝试获取债券借贷费率最新行情
/// </summary>
/// <param name="valueDate"></param>
/// <param name="underlyingSecurityId"></param>
/// <param name="bondLendingRate"></param>
/// <returns></returns>
public static bool TryGetBondLendingRate(DateTime valueDate, string underlyingSecurityId, out eod_bond_lending_rate bondLendingRate)
{
return (bondLendingRate = GetBondLendingRate(valueDate, underlyingSecurityId)) != null;
}
/// <summary>
/// 获取标的收盘价格
/// </summary>
/// <param name="code">标的代码</param>
@@ -1,4 +1,4 @@
using System.Linq.Expressions;
using System.Linq.Expressions;
using YLErp.Helpers;
using YLErp.Model;
using YLErp.Modules.DataProviderModule;
@@ -170,7 +170,7 @@ namespace YLErp.Modules.EodModule.SettlementModule
var yesterday = settleDate.AddDays(i - 1);
var today = settleDate.AddDays(i);
new SwapEodPositionService(OptUser).SwapPositionCompose(today, yesterday, clientIds);
// CalculateMargin(today, yesterday, clientIds);
CalculateMargin(today, yesterday, clientIds);
new SwapEodPositionService(OptUser).SwapEodCompose(today, yesterday, clientIds);
}
@@ -140,7 +140,7 @@ namespace YLErp.Modules.EodModule
.ToList();
var positionIds = eodPositions.Select(x => x.PositionId).Distinct().ToList();
var swapPositions = _dbContext.swap_position
.Where(x => positionIds.Contains(x.id) && !x.Invalid && x.category_tag == InterestCategory)
.Where(x => positionIds.Contains(x.id) && !x.Invalid)
.AsNoTracking()
.ToDictionary(x => x.id);
@@ -167,18 +167,27 @@ namespace YLErp.Modules.EodModule
}
var positions = eodPositions.Where(x => x.SwapTradeId == eodSwap.SwapTradeId).ToList();
var floating = positions.Where(x => !string.IsNullOrWhiteSpace(x.UnderlyingCode) && swapPositions.ContainsKey(x.PositionId)).ToList();
var interest = positions.Where(x => string.IsNullOrWhiteSpace(x.UnderlyingCode)
var floating = positions.Where(x => !string.IsNullOrWhiteSpace(x.UnderlyingCode)).ToList();
var interestCandidates = positions.Where(x => string.IsNullOrWhiteSpace(x.UnderlyingCode)
&& ConsTrade.InterestModels.Contains(x.InterestMode)
&& swapPositions.TryGetValue(x.PositionId, out var swapPosition)
&& swapPosition.category_tag == InterestCategory).ToList();
&& (swapPosition.category_tag == InterestCategory || string.IsNullOrWhiteSpace(swapPosition.category_tag)))
.ToList();
if (floating.Count != 1 || interest.Count != 1)
// 互换利率腿优先;同类别多腿按当前查询顺序取第一条。历史类别为空时保留利息方向,
// 但 fixedRate 按约定置 0,避免把未标注类别的历史值当作已确认利率。
var interest = interestCandidates.FirstOrDefault(x =>
swapPositions.TryGetValue(x.PositionId, out var swapPosition)
&& swapPosition.category_tag == InterestCategory);
var isUncategorizedInterest = interest == null && interestCandidates.Count > 0;
interest ??= interestCandidates.FirstOrDefault();
if (floating.Count != 1 || interest == null)
{
throw new InvalidOperationException($"TRS legs invalid, swapTradeId:{eodSwap.SwapTradeId}, floating:{floating.Count}, interest:{interest.Count}");
throw new InvalidOperationException($"TRS legs invalid, swapTradeId:{eodSwap.SwapTradeId}, floating:{floating.Count}, interest:{(interest == null ? 0 : 1)}");
}
var interestLeg = interest[0];
var interestLeg = interest;
var floatingLeg = floating[0];
return new TrsContractSnapshotItem
{
@@ -193,7 +202,7 @@ namespace YLErp.Modules.EodModule
Dv01 = eodSwap.dv01 ?? 0,
StartDate = trade.StartDate?.ToString(DateFormat),
MaturityDate = trade.ExerciseDate?.ToString(DateFormat),
FixedRate = interestLeg.InterestRateDefault,
FixedRate = isUncategorizedInterest ? 0 : interestLeg.InterestRateDefault,
InterestDirection = interestLeg.InterestDirection,
FloatingDirection = floatingLeg.PositionType,
InitMarginGain = eodSwap.InitMarginGain,
@@ -26,6 +26,13 @@ namespace YLErp.Modules.RiskEngine
/// </summary>
public string TriggerPoint { get; set; }
/// <summary>
/// 当前平仓请求提交的平仓日期
/// </summary>
public DateTime? UnwindDate { get; set; }
public DateTime? PayDate { get; set; }
/// <summary>
/// 数据库上下文,供规则公式直接查询数据库
/// </summary>
@@ -396,6 +403,8 @@ namespace YLErp.Modules.RiskEngine
{
TradeId = ctx.TradeId,
TriggerPoint = ctx.TriggerPoint,
UnwindDate = ctx.UnwindDate,
PayDate = ctx.PayDate,
DbContext = ctx.DbContext
};
return runner(globals).GetAwaiter().GetResult();
@@ -14,11 +14,33 @@ namespace YLErp.Modules.RiskEngine
/// </summary>
public static class RiskCalendarHelper
{
private const string CalendarDateFormat = "yyyy,MM,dd";
/// <summary>
/// 日历数据异常直接影响平仓日期校验和上一交易日取数,单独使用固定 logger 名称便于线上按模块检索。
/// </summary>
private static readonly IYcLogger Logger = LogFactory.GetLogger("RiskCalendarHelper");
/// <summary>
/// calendar.HolidayJson 历史主格式是 yyyy,MM,dd;这里额外兼容常见日期格式,避免历史数据被静默当作交易日。
/// 解析后统一转成 DateTime.Date,后续交易日判断不再依赖原始字符串格式。
/// </summary>
private static readonly string[] SupportedCalendarDateFormats =
{
CalendarDateFormat,
"yyyy/MM/dd",
"yyyy-MM-dd",
"yyyyMMdd",
"yyyy-MM-ddTHH:mm:ss",
"yyyy-MM-ddTHH:mm:ssK",
"yyyy-MM-ddTHH:mm:ss.fffK"
};
/// <summary>
/// 按当前风控执行使用的数据库上下文保存日历缓存,同一次风控检查内复用,数据库上下文释放后不阻止缓存被回收。
/// </summary>
private static readonly ConditionalWeakTable<YLContext, Dictionary<string, HashSet<string>>> HolidayCaches =
new ConditionalWeakTable<YLContext, Dictionary<string, HashSet<string>>>();
private static readonly ConditionalWeakTable<YLContext, Dictionary<string, HashSet<DateTime>>> HolidayCaches =
new ConditionalWeakTable<YLContext, Dictionary<string, HashSet<DateTime>>>();
/// <summary>
/// 获取指定日期的上一银行间交易日。
@@ -33,6 +55,24 @@ namespace YLErp.Modules.RiskEngine
return GetPreviousTradingDay(dbContext, date, "IB", "银行间");
}
/// <summary>
/// 判断指定日期是否为银行间交易日。
/// </summary>
/// <param name="dbContext">当前风控执行使用的数据库上下文。</param>
/// <param name="date">待判断的平仓日期。</param>
/// <returns>日期不在银行间非交易日集合中时返回 true。</returns>
/// <exception cref="ArgumentNullException">数据库上下文为空。</exception>
/// <exception cref="Exception">缺少银行间日历或日历内容异常。</exception>
public static bool IsInterbankTradingDay(YLContext dbContext, DateTime date)
{
if (dbContext == null)
throw new ArgumentNullException(nameof(dbContext));
var holidayCache = HolidayCaches.GetOrCreateValue(dbContext);
var holidays = GetHolidays(dbContext, date.Year, "IB", "银行间", holidayCache);
return !holidays.Contains(date.Date);
}
public static DateTime GetPreviousExchangeTradingDay(YLContext dbContext, DateTime date)
{
return GetPreviousTradingDay(dbContext, date, "CHN", "交易所");
@@ -51,10 +91,9 @@ namespace YLErp.Modules.RiskEngine
for (var i = 0; i < 370; i++)
{
var holidays = GetHolidays(dbContext, currentDate.Year, country, calendarName, holidayCache);
var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture);
// calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是对应市场的交易日。
if (!holidays.Contains(currentDateText))
if (!holidays.Contains(currentDate))
return currentDate;
currentDate = currentDate.AddDays(-1);
@@ -64,30 +103,51 @@ namespace YLErp.Modules.RiskEngine
}
/// <summary>
/// 获取指定年份的银行间非交易日集合。
/// 获取指定年份的非交易日集合。
/// </summary>
/// <param name="dbContext">当前风控执行使用的数据库上下文。</param>
/// <param name="year">日历年份。</param>
/// <param name="holidayCache">单次风控检查内按市场和年份共享的非交易日缓存。</param>
/// <returns>格式为 yyyy,MM,dd 的非交易日集合。</returns>
private static HashSet<string> GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary<string, HashSet<string>> holidayCache)
/// <returns>按 Date 归一化后的非交易日集合。</returns>
private static HashSet<DateTime> GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary<string, HashSet<DateTime>> holidayCache)
{
// 缓存需要同时区分市场和年份,避免银行间与交易所同一年日历相互串用。
var cacheKey = $"{country.ToUpperInvariant()}:{year}";
var normalizedCountry = NormalizeCountry(country);
var cacheKey = $"{normalizedCountry}:{year}";
if (holidayCache.TryGetValue(cacheKey, out var holidays))
return holidays;
// 同一年可能存在多种市场日历,按规则对应的市场代码读取非交易日
var calendar = dbContext.calendar
.Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid))
.ToList()
.FirstOrDefault(c => string.Equals(c.Country, country, StringComparison.OrdinalIgnoreCase));
// 先按年份和有效状态缩小范围,再在内存里做 Trim + ToUpperInvariant 匹配,兼容历史 Country 存在大小写或前后空格的情况
// 这里没有在数据库查询里直接 Trim,是为了避免不同 EF/数据库提供方对字符串函数翻译不一致。
var validCalendars = dbContext.calendar
.Where(c => c.Year == year
&& (c.ValidState == null || c.ValidState != ConsGlobal.InValid)
&& c.Country != null)
.ToList();
var matchedCalendars = validCalendars.Where(c => NormalizeCountry(c.Country) == normalizedCountry).ToList();
var calendar = matchedCalendars.FirstOrDefault();
if (calendar == null)
throw new Exception($"未找到{year}年{calendarName}日历");
{
var availableCountries = string.Join(",", validCalendars.Select(c => c.Country?.Trim()).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct());
Logger.Error($"[风控日历] 未找到目标市场日历 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, AvailableCountries:{availableCountries}");
throw new Exception($"未找到{year}年{calendarName}日历,Country={normalizedCountry},当前可用日历:{availableCountries}");
}
if (matchedCalendars.Count > 1)
{
// 多条匹配不改变原有“取第一条”的行为,只记录数据质量问题,避免线上突然因历史重复配置中断风控。
Logger.Info($"[风控日历] 同一年存在多个匹配市场日历,使用第一条 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, MatchedIds:{string.Join(",", matchedCalendars.Select(c => c.id))}");
}
if (!string.Equals(calendar.Country, normalizedCountry, StringComparison.Ordinal))
{
// Country 能通过归一化匹配说明历史数据存在大小写或空格差异,只在日历首次加载时记录一次,便于后续清洗数据。
Logger.Info($"[风控日历] 日历Country已归一化匹配 - Year:{year}, CalendarName:{calendarName}, RawCountry:{calendar.Country}, NormalizedCountry:{normalizedCountry}, CalendarId:{calendar.id}");
}
if (string.IsNullOrWhiteSpace(calendar.HolidayJson))
{
Logger.Error($"[风控日历] HolidayJson为空 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}");
throw new Exception($"{year}年{calendarName}日历HolidayJson为空");
}
List<string> holidayList;
try
@@ -96,12 +156,70 @@ namespace YLErp.Modules.RiskEngine
}
catch (Exception ex)
{
Logger.Error($"[风控日历] HolidayJson解析失败 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}, Error:{ex.Message}");
throw new Exception($"{year}年{calendarName}日历HolidayJson解析失败", ex);
}
holidays = new HashSet<string>(holidayList ?? new List<string>());
// 日历解析和日志统计只在缓存未命中时执行;同一次风控检查内重复判断交易日不会重复解析 HolidayJson。
holidays = NormalizeHolidays(holidayList, year, calendarName, normalizedCountry, calendar.id);
holidayCache[cacheKey] = holidays;
return holidays;
}
private static string NormalizeCountry(string country)
{
return (country ?? string.Empty).Trim().ToUpperInvariant();
}
private static HashSet<DateTime> NormalizeHolidays(IEnumerable<string> holidayList, int year, string calendarName, string normalizedCountry, int calendarId)
{
var holidays = new HashSet<DateTime>();
var formatCounts = new Dictionary<string, int>();
var rawCount = 0;
var blankCount = 0;
var duplicateCount = 0;
foreach (var holidayText in holidayList ?? Enumerable.Empty<string>())
{
rawCount++;
if (string.IsNullOrWhiteSpace(holidayText))
{
blankCount++;
continue;
}
// 每条原始日期只在缓存加载阶段解析一次;正常数据只累计格式分布,避免大量节假日逐条写日志。
var holidayDate = ParseHolidayDate(holidayText, year, calendarName, normalizedCountry, calendarId, out var matchedFormat);
if (!holidays.Add(holidayDate))
{
duplicateCount++;
}
formatCounts[matchedFormat] = formatCounts.TryGetValue(matchedFormat, out var count) ? count + 1 : 1;
}
Logger.Info($"[风控日历] HolidayJson日期归一化完成 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawCount:{rawCount}, HolidayCount:{holidays.Count}, BlankCount:{blankCount}, DuplicateCount:{duplicateCount}, Formats:{string.Join(",", formatCounts.Select(item => item.Key + ":" + item.Value))}");
return holidays;
}
private static DateTime ParseHolidayDate(string holidayText, int year, string calendarName, string normalizedCountry, int calendarId, out string matchedFormat)
{
var normalizedHolidayText = holidayText.Trim();
foreach (var format in SupportedCalendarDateFormats)
{
if (DateTime.TryParseExact(normalizedHolidayText, format, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDate))
{
matchedFormat = format;
return holidayDate.Date;
}
}
// 支持带时区的历史数据;最终取日期部分用于非交易日集合匹配,避免字符串格式差异导致静默漏判。
if (DateTimeOffset.TryParse(normalizedHolidayText, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDateOffset))
{
matchedFormat = "DateTimeOffset.TryParse";
return holidayDateOffset.Date;
}
Logger.Error($"[风控日历] HolidayJson日期格式无法识别 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawValue:{holidayText}");
throw new Exception($"{year}年{calendarName}日历HolidayJson存在无法识别的日期格式:{holidayText}");
}
}
}
@@ -17,6 +17,13 @@ namespace YLErp.Modules.RiskEngine
/// </summary>
public string TriggerPoint { get; set; }
/// <summary>
/// 当前平仓请求提交的平仓日期,供平仓阶段规则直接使用。
/// </summary>
public DateTime? UnwindDate { get; set; }
public DateTime? PayDate { get; set; }
/// <summary>
/// 数据库上下文,供规则公式直接查询数据库
/// </summary>
@@ -202,8 +202,7 @@ namespace YLErp.Modules.RiskEngine
"BOOK_CONFIRM",
"CLOSE_REVIEW",
"UPLOAD_CONFIRMATION",
"EVENT_TRIGGER",
"FUND_PAYMENT"
"EVENT_TRIGGER"
};
@@ -792,11 +791,10 @@ namespace YLErp.Modules.RiskEngine
private static readonly Dictionary<string, string> TriggerPointCnMap = new Dictionary<string, string>
{
["BOOK_CONFIRM"] = "交易录入确认",
["CLOSE_REVIEW"] = "平仓审核",
["BOOK_CONFIRM"] = "簿记交易确认",
["CLOSE_REVIEW"] = "平仓审核提交",
["UPLOAD_CONFIRMATION"] = "上传确认书",
["EVENT_TRIGGER"] = "事件触发",
["FUND_PAYMENT"] = "资金支付"
["EVENT_TRIGGER"] = "事件发生时"
};
/// <summary>
@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Qdp.Foundation.Utilities;
using YLErp.BLL;
using YLErp.DBModels;
using YLErp.Enums;
namespace YLErp.Modules.RiskEngine
{
/// <summary>
/// 新风控通用试算服务,负责按业务触发时点执行规则、保存试算结果并校验二次确认。
/// </summary>
public class RiskTrialService : YLBaseService
{
private readonly IYcLogger _logger = LogFactory.GetLogger("RiskTrialService");
/// <summary>
/// 使用当前业务服务的用户和数据库上下文创建新风控试算服务。
/// </summary>
/// <param name="baseService">当前业务服务。</param>
public RiskTrialService(YLBaseService baseService) : base(baseService)
{
}
/// <summary>
/// 执行一次独立于老风控的新风控试算并保存试算记录。
/// quotaTrial 在现有交易确认和平仓链路中按“每次试算快照/历史流水”使用,不作为审批状态表;二次确认会重新试算并生成新的快照。
/// </summary>
/// <param name="context">包含交易、触发时点及业务日期的风控上下文。</param>
/// <param name="trialSource">试算来源,由具体业务场景约定。</param>
/// <param name="confirmation">二次确认信息;首次试算传空。</param>
/// <returns>包含阻断、审批、提示及本次试算记录ID的统一结果。</returns>
public RiskTrialResult CheckRisk(RiskContext context, int trialSource, RiskTrialConfirmation confirmation = null)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.TradeId <= 0)
{
throw new ArgumentException("交易ID必须大于0", nameof(context));
}
if (string.IsNullOrWhiteSpace(context.TriggerPoint))
{
throw new ArgumentException("风控触发时点不能为空", nameof(context));
}
var ignoreRiskRuleIds = new HashSet<string>();
var confirmationExpired = false;
if (confirmation != null)
{
confirmationExpired = !TryLoadIgnoreRiskRuleIds(context, trialSource, confirmation, ignoreRiskRuleIds);
}
var riskResult = new RiskEngineService(this).EvaluateRisk(context, context.TriggerPoint);
// 与交易确认流程保持一致:二次确认放行范围包含“需审批”和“提示”,避免已确认过的提示规则重复弹出。
var currentApprovalRules = riskResult.TriggeredRules
.Where(rule => rule.ControlStrategy == RiskControlStrategy.Approval)
.ToList();
var currentTipRules = riskResult.TriggeredRules
.Where(rule => rule.ControlStrategy == RiskControlStrategy.ShowTip)
.ToList();
// 二次提交时,若首次试算记录有效,则剔除已确认过的审批和提示规则;记录过期或上下文不匹配时重新展示全部当前命中的审批和提示规则。
var pendingApprovalRules = confirmationExpired
? currentApprovalRules
: currentApprovalRules.Where(rule => !ignoreRiskRuleIds.Contains(rule.RuleId)).ToList();
var pendingTipRules = confirmationExpired
? currentTipRules
: currentTipRules.Where(rule => !ignoreRiskRuleIds.Contains(rule.RuleId)).ToList();
var trial = CreateQuotaTrial(context, trialSource, riskResult, pendingApprovalRules, pendingTipRules);
// 与交易确认 RunQuotaTrial/RunNewRiskTrial 保持一致:每次风控执行保存一条 quotaTrial 快照,用于详情展示、历史追溯和二次确认时效校验,不回写上一条记录为已确认。
new Modules.RiskModule.QuotaMonitorService(this).SaveQuotaTrial(trial);
var result = new RiskTrialResult
{
Blocked = riskResult.Blocked,
NeedApproval = pendingApprovalRules.Any(),
ShowTip = pendingTipRules.Any(),
ConfirmationExpired = confirmationExpired,
TrialDataId = trial.id,
Message = trial.RiskWarningDetails,
ApprovalRuleIds = pendingApprovalRules.Concat(pendingTipRules)
.Select(rule => rule.RuleId)
.Where(ruleId => !string.IsNullOrWhiteSpace(ruleId))
.Distinct()
.ToList()
};
result.Passed = !result.Blocked && !result.NeedApproval;
_logger.Info($"[新风控试算] TradeId: {context.TradeId}, TriggerPoint: {context.TriggerPoint}, TrialSource: {trialSource}, TrialDataId: {result.TrialDataId}, Passed: {result.Passed}, Blocked: {result.Blocked}, NeedApproval: {result.NeedApproval}, ShowTip: {result.ShowTip}, ConfirmationExpired: {result.ConfirmationExpired}");
return result;
}
/// <summary>
/// 校验二次确认记录,并恢复首次试算中已确认的审批和提示规则ID。
/// </summary>
private bool TryLoadIgnoreRiskRuleIds(RiskContext context, int trialSource, RiskTrialConfirmation confirmation, ISet<string> ignoreRiskRuleIds)
{
if (confirmation.TrialDataId <= 0 || confirmation.ExpireSeconds <= 0)
{
return false;
}
var trial = DbContext.quotaTrial.FirstOrDefault(item => item.id == confirmation.TrialDataId);
if (trial == null
|| trial.TradeId != context.TradeId
|| trial.TrialSource != trialSource
|| !trial.OptDate.HasValue
|| !HasMatchingRequestContext(trial.RiskWarningDetails, context))
{
return false;
}
if (DateTime.Now - trial.OptDate.Value > TimeSpan.FromSeconds(confirmation.ExpireSeconds))
{
return false;
}
foreach (var ruleId in GetIgnoreRiskRuleIds(confirmation, trial.RiskWarningDetails))
{
ignoreRiskRuleIds.Add(ruleId);
}
return true;
}
/// <summary>
/// 校验二次提交与首次试算的触发点和平仓日期一致,避免复用其他业务参数生成的审批记录。
/// </summary>
private static bool HasMatchingRequestContext(string details, RiskContext context)
{
var expectedContext = BuildRequestContext(context);
return (details ?? string.Empty)
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Any(line => string.Equals(line, expectedContext, StringComparison.Ordinal));
}
private static IEnumerable<string> GetIgnoreRiskRuleIds(RiskTrialConfirmation confirmation, string details)
{
var ignoreRiskRuleIds = confirmation.IgnoreRiskRuleIds ?? new List<string>();
return ignoreRiskRuleIds.Any()
? ignoreRiskRuleIds.Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)).Select(ruleId => ruleId.Trim()).Distinct()
: ParseIgnoreRiskRuleIds(details);
}
/// <summary>
/// 从系统生成的审批和提示规则明细中恢复规则ID,兼容未回传忽略规则ID的旧入口。
/// </summary>
private static IEnumerable<string> ParseIgnoreRiskRuleIds(string details)
{
const string ruleIdPrefix = "规则ID:";
var inConfirmableSection = false;
foreach (var line in (details ?? string.Empty).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("[风控引擎] 规则触发:", StringComparison.Ordinal))
{
inConfirmableSection = line.StartsWith("[风控引擎] 规则触发:需审批", StringComparison.Ordinal)
|| line.StartsWith("[风控引擎] 规则触发:提示", StringComparison.Ordinal);
continue;
}
if (!inConfirmableSection)
{
continue;
}
var ruleIdStart = line.IndexOf(ruleIdPrefix, StringComparison.Ordinal);
if (ruleIdStart < 0)
{
continue;
}
ruleIdStart += ruleIdPrefix.Length;
var ruleIdEnd = line.IndexOf('', ruleIdStart);
var ruleId = (ruleIdEnd < 0 ? line.Substring(ruleIdStart) : line.Substring(ruleIdStart, ruleIdEnd - ruleIdStart)).Trim();
if (!string.IsNullOrWhiteSpace(ruleId))
{
yield return ruleId;
}
}
}
/// <summary>
/// 将新风控引擎结果转换为现有试算记录,供详情页面和二次确认复用。
/// </summary>
private QuotaTrial CreateQuotaTrial(RiskContext context, int trialSource, RiskResult riskResult, IReadOnlyCollection<TriggeredRuleInfo> pendingApprovalRules, IReadOnlyCollection<TriggeredRuleInfo> pendingTipRules)
{
// 业务入口已通过Find加载交易,优先复用当前上下文跟踪的实体,避免重复查询数据库。
var trade = DbContext.trade.Local.FirstOrDefault(item => item.id == context.TradeId)
?? DbContext.trade.Find(context.TradeId);
var trial = new QuotaTrial
{
TradeId = context.TradeId,
TradeNumber = trade?.TradeNumber ?? string.Empty,
ClientName = trade?.ClientName ?? string.Empty,
TrialSource = trialSource,
TrialStatus = riskResult.Blocked
? QuotaTrialStatusEnum.Error
: pendingApprovalRules.Any() ? QuotaTrialStatusEnum.RiskWarning : QuotaTrialStatusEnum.Success,
NewRiskBlocked = riskResult.Blocked,
NewRiskNeedApproval = pendingApprovalRules.Any(),
RiskWarningDetails = BuildRiskDetails(context, riskResult, pendingApprovalRules, pendingTipRules)
};
trial.ApprovalRuleIds = pendingApprovalRules.Concat(pendingTipRules)
.Select(rule => rule.RuleId)
.Where(ruleId => !string.IsNullOrWhiteSpace(ruleId))
.Distinct()
.ToList();
return trial;
}
/// <summary>
/// 按控制策略生成与现有试算详情页面兼容的规则明细。
/// </summary>
private static string BuildRiskDetails(RiskContext context, RiskResult riskResult, IReadOnlyCollection<TriggeredRuleInfo> pendingApprovalRules, IReadOnlyCollection<TriggeredRuleInfo> pendingTipRules)
{
var lines = new List<string>
{
BuildRequestContext(context)
};
AppendRuleDetails(lines, "禁止", riskResult.TriggeredRules.Where(rule => rule.ControlStrategy == RiskControlStrategy.Block));
AppendRuleDetails(lines, "需审批", pendingApprovalRules);
AppendRuleDetails(lines, "提示", pendingTipRules);
return string.Join("\n", lines);
}
/// <summary>
/// 生成首次试算的业务请求标识,供二次确认严格校验业务参数。
/// </summary>
private static string BuildRequestContext(RiskContext context)
{
return $"[风控引擎] 请求上下文:触发点:{GetTriggerPointDisplayName(context.TriggerPoint)};平仓日期:{context.UnwindDate?.Date:yyyy-MM-dd};支付日期:{context.PayDate?.Date:yyyy-MM-dd}";
}
private static string GetTriggerPointDisplayName(string triggerPoint)
{
return triggerPoint switch
{
"BOOK_CONFIRM" => "簿记交易确认",
"CLOSE_REVIEW" => "平仓审核提交",
"UPLOAD_CONFIRMATION" => "上传确认书",
"EVENT_TRIGGER" => "事件发生时",
_ => triggerPoint ?? string.Empty
};
}
/// <summary>
/// 追加指定控制策略的规则明细。
/// </summary>
private static void AppendRuleDetails(ICollection<string> lines, string strategyName, IEnumerable<TriggeredRuleInfo> rules)
{
var ruleList = rules.ToList();
if (!ruleList.Any())
{
return;
}
lines.Add($"[风控引擎] 规则触发:{strategyName}");
foreach (var rule in ruleList)
{
var detail = $"应用ID:{rule.ApplicationId?.ToString() ?? "-"};规则ID:{rule.RuleId};规则名称:{rule.RuleName};规则说明:{rule.RuleText}";
if (!string.IsNullOrWhiteSpace(rule.Message))
{
detail += $";信息:{rule.Message}";
}
lines.Add(detail);
}
}
}
/// <summary>
/// 新风控二次确认参数。
/// </summary>
public class RiskTrialConfirmation
{
/// <summary>
/// 首次风控试算记录ID。
/// </summary>
public int TrialDataId { get; set; }
/// <summary>
/// 首次试算允许确认放行的有效时长,单位为秒。
/// </summary>
public int ExpireSeconds { get; set; }
/// <summary>
/// 首次试算返回并经前端回传的需忽略规则ID。
/// </summary>
public List<string> IgnoreRiskRuleIds { get; set; } = new List<string>();
}
/// <summary>
/// 新风控通用试算结果。
/// </summary>
public class RiskTrialResult
{
public bool Passed { get; set; }
public bool Blocked { get; set; }
public bool NeedApproval { get; set; }
public bool ShowTip { get; set; }
public bool ConfirmationExpired { get; set; }
public int TrialDataId { get; set; }
public string Message { get; set; }
public List<string> ApprovalRuleIds { get; set; } = new List<string>();
}
}
@@ -519,8 +519,10 @@ namespace YLErp.Modules.RiskEngine
/// </summary>
private static string BuildBooleanMessage(glms_risk_variable variable, ValueExecuteResult value, bool expected)
{
if (!string.IsNullOrWhiteSpace(value.DetailMessage))
return value.DetailMessage.Trim();
var parts = new List<string>();
AddDetail(parts, value.DetailMessage);
parts.Add($"{variable.VariableName}为{FormatDisplayValue(value.Value)}");
parts.Add($"期望为{FormatDisplayValue(expected)}");
return string.Join("", parts);
File diff suppressed because it is too large Load Diff
@@ -6892,7 +6892,8 @@ namespace YLErp.Modules.RiskModule
return true;
}
/// <summary>
/// 保存试算结果
/// 保存试算结果
/// 新试算对象按历史快照新增;传入已有ID时仅更新该次试算的说明、来源和操作信息,不承担审批状态流转职责。
/// </summary>
/// <param name="obj">试算结果</param>
public void SaveQuotaTrial(QuotaTrial obj)
@@ -6922,6 +6923,40 @@ namespace YLErp.Modules.RiskModule
DbContext.SaveChanges();
}
/// <summary>
/// 保存平仓风控试算说明,并保留首次试算时间作为二次确认超时起点。
/// </summary>
/// <param name="trialDataId">平仓风控试算记录ID。</param>
/// <param name="remark">用户填写的特批说明。</param>
/// <returns>更新后的平仓风控试算记录。</returns>
public QuotaTrial SaveCloseRiskTrialRemark(int trialDataId, string remark)
{
if (trialDataId <= 0)
{
throw new ServiceException("无效的平仓风控试算记录");
}
if (string.IsNullOrWhiteSpace(remark))
{
throw new ServiceException("必须填写说明内容,才可以提交平仓");
}
const int closeTrialSource = 2;
var trial = DbContext.quotaTrial.FirstOrDefault(item => item.id == trialDataId);
if (trial == null
|| trial.TrialSource != closeTrialSource
|| trial.TrialStatus != QuotaTrialStatusEnum.RiskWarning)
{
throw new ServiceException("未找到有效的平仓风控审批记录");
}
// 仅保存说明和操作人,不更新OptDate,避免重置首次试算的确认超时时间。
trial.Remark = remark.Trim();
trial.OptId = UserId;
trial.OptName = UserName;
DbContext.SaveChanges();
return trial;
}
}
}
@@ -8,7 +8,9 @@ namespace YLErp.Modules.SwapModule.Margin
{
/// <summary>
/// R3 阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易日终结算产生"追加保证金"资金记录。
/// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 累计保证金(应付预付金+追加保证金 流水净额 + 追加授信占用);
/// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 已缴保证金净额
/// (应付预付金现金净收额 + 初始授信占用净额 + 追加保证金现金累计 + 追加授信占用累计——
/// 2026-08-27 修正:授信垫付的初始预付金不产生应付预付金流水,此前未计入已缴导致每个结算日按维持全额重复开追加);
/// 现金部分为逐结算日增量记录(BUG-03 修正:每结算日一条、Money=increment,键 TradeId+Action+Deal+HappenDate 幂等),
/// 需求上升只增不减;授信优先(阶段二规则):授信部分只写授信出入表(remark 前缀=追加保证金,position_id 空、冗余 trade_id)。
/// 由 EOD 在客户资金计算之前调用:当日新记录计入当日出入金窗口并翻"已结算",重跑时 目标/已补足 不变 → 新增为 0 不重复写。
@@ -107,6 +109,16 @@ namespace YLErp.Modules.SwapModule.Margin
.Select(g => new { TradeId = g.Key ?? 0, Funded = g.Sum(x => x.amount) })
.ToDictionary(x => x.TradeId, x => x.Funded);
//初始预付金的授信占用净额(非"追加保证金"前缀:簿记初始占用 + 平仓释放取负,Σ(amount) 即净已缴):
//授信腿不产生应付预付金流水,目标追加里只扣现金净收额会把授信垫付的初始预付金漏掉——
//每个结算日都按维持保证金全额重复开追加(BUG:多收授信占用/现金,2026-08-27 交易2538实证:初始授信200万未扣、首日全额追加280.8万)
var initCreditByTrade = DbContext.client_credit_inout.AsNoTracking()
.Where(x => x.trade_id != null && tradeIds.Contains(x.trade_id ?? 0)
&& (x.remark == null || !x.remark.StartsWith(ClientCreditInoutService.AdditionalMarginRemark)))
.GroupBy(x => x.trade_id)
.Select(g => new { TradeId = g.Key ?? 0, Funded = g.Sum(x => x.amount) })
.ToDictionary(x => x.TradeId, x => x.Funded);
var fundTagService = new SwapFundTagService(this);
var cashService = new ClientCashInCashOutService(this);
var creditService = new ClientCreditInoutService(this);
@@ -121,8 +133,11 @@ namespace YLErp.Modules.SwapModule.Margin
{
continue;
}
var target = SwapAdditionalMarginCalc.CalcTarget(maintenance,
payableNetByTrade.TryGetValue(td.id, out var payableNet) ? payableNet : 0);
//目标追加 = 维持保证金 − 已缴初始保证金净额(现金应付预付金净收额 + 授信初始占用净额,
//授信垫付与现金垫付同等对待,杜绝授信初始预付金被重复追加)
var payableNet = (payableNetByTrade.TryGetValue(td.id, out var payable) ? payable : 0)
+ (initCreditByTrade.TryGetValue(td.id, out var initCredit) ? initCredit : 0);
var target = SwapAdditionalMarginCalc.CalcTarget(maintenance, payableNet);
if (target <= 0)
{
continue;
@@ -72,11 +72,27 @@ namespace YLErp.Modules.SwapModule.Margin
return tier;
}
}
//未落任何层:价格已穿出最深一层边界(低于多头最深层下界/高于空头最深层上界),按最深层计;
//最深层按边界值取(多头=最小下界空头=最大上界),不依赖配置数组顺序(BUG-25 引擎侧防御)
return isCustomerLong
//未落任何层分两种情形
//① 价格穿出最深一层边界(多头低于最深层下界/空头高于最深层上界)→ 按最深层计(追保金额不再上升);
// 最深层按边界值取(多头=最小下界、空头=最大上界),不依赖配置数组顺序(BUG-25 引擎侧防御)。
//② 层间空隙(如空头 (0.99,1.00]——价格在期初附近小幅波动、未触发追保的区间)→ 返回 null,追加保证金按 0。
// 此前兜底不分情形一律按最深层计,空隙价格被错误收取最深档追保
// 2026-08-27 交易2538实证:08-24净价100→ratio 0.99999 落空头(0.99,1.00]空档,被按0.04最深档收80.8万)。
var deepest = isCustomerLong
? valid.OrderBy(t => t.Lower ?? double.MinValue).First()
: valid.OrderByDescending(t => t.Upper ?? double.MaxValue).First();
if (isCustomerLong)
{
if (priceRatio < (deepest.Lower ?? double.MinValue))
{
return deepest;
}
}
else if (priceRatio > (deepest.Upper ?? double.MaxValue))
{
return deepest;
}
return null;
}
/// <summary>
@@ -2001,6 +2001,33 @@ namespace YLErp.Modules.SwapModule
SwapCalcTrace.Write(interestTrace);
}
/// <summary>
/// 单标的平仓
/// </summary>
/// <param name="unwindData"></param>
/// <param name="confirmation">新风控二次确认信息;首次提交传空。</param>
/// <returns>本次平仓的新风控试算结果。</returns>
/// <exception cref="ServiceException"></exception>
public YLErp.Modules.RiskEngine.RiskTrialResult SwapUnwind(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation)
{
if (unwindData == null)
{
throw new ServiceException("平仓信息不能为空");
}
if (FindTrade(unwindData.SwapTradeId) == null)
{
throw new ServiceException("未找到交易信息");
}
var riskTrialResult = CheckCloseRisk(unwindData, confirmation);
if (!riskTrialResult.Passed)
{
return riskTrialResult;
}
SwapUnwind(unwindData);
return riskTrialResult;
}
/// <summary>
/// 单标的平仓
/// </summary>
@@ -2421,6 +2448,29 @@ namespace YLErp.Modules.SwapModule
SaveAllChanges();
}
/// <summary>
/// 互换/平仓提交审核
/// </summary>
/// <param name="unwindData"></param>
/// <param name="eventType"></param>
/// <param name="confirmation">新风控二次确认信息;首次提交传空。</param>
/// <returns>平仓事件返回新风控试算结果,互换事件返回空。</returns>
/// <exception cref="ServiceException"></exception>
public YLErp.Modules.RiskEngine.RiskTrialResult ApplySwapTrade(UnwindData unwindData, int eventType, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation)
{
YLErp.Modules.RiskEngine.RiskTrialResult riskTrialResult = null;
if (eventType == (int)SwapEventTypeEnum.)
{
riskTrialResult = CheckCloseRisk(unwindData, confirmation);
if (!riskTrialResult.Passed)
{
return riskTrialResult;
}
}
ApplySwapTrade(unwindData, eventType);
return riskTrialResult;
}
/// <summary>
/// 互换/平仓提交审核
/// </summary>
@@ -2476,6 +2526,33 @@ namespace YLErp.Modules.SwapModule
}
});
}
/// <summary>
/// 执行互换交易平仓审核时点的新风控试算。
/// </summary>
/// <param name="unwindData">包含本次平仓日期和交易ID的平仓请求。</param>
/// <param name="confirmation">新风控二次确认信息;首次提交传空。</param>
/// <returns>包含阻断、审批、提示及试算记录ID的新风控结果。</returns>
/// <exception cref="ServiceException">平仓请求为空。</exception>
private YLErp.Modules.RiskEngine.RiskTrialResult CheckCloseRisk(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation)
{
if (unwindData == null)
{
throw new ServiceException("平仓信息不能为空");
}
const string triggerPoint = "CLOSE_REVIEW";
const int closeTrialSource = 2;
var riskContext = new YLErp.Modules.RiskEngine.RiskContext
{
TradeId = unwindData.SwapTradeId,
TriggerPoint = triggerPoint,
UnwindDate = unwindData.UnwindDate,
PayDate = unwindData.PayDate
};
return new YLErp.Modules.RiskEngine.RiskTrialService(this).CheckRisk(riskContext, closeTrialSource, confirmation);
}
private void ValidateIncomeValueDate(UnwindData unwindData, trade td)
{
var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date;
@@ -46,7 +46,7 @@ namespace YLErp.Modules.TradeModule.DealModule
/// <summary>
/// 2021-02-04改造:批量确认因为耗时太长,用户多页面(交易列表,今日交易)并发确认导致生成多条期权费资金记录
/// </summary>
public TradeConfirmResultModel tradeConfirm(IEnumerable<int> tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false, bool ignoreRiskWarning = false, IEnumerable<string> ignoreRiskRuleIds = null)
public TradeConfirmResultModel tradeConfirm(IEnumerable<int> tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false, bool ignoreRiskWarning = false, IEnumerable<string> ignoreRiskRuleIds = null, bool skipNewRiskCheck = false)
{
if (tradeids is null || !tradeids.Any(n => n > 0))
{
@@ -102,7 +102,8 @@ namespace YLErp.Modules.TradeModule.DealModule
changeConfirmPaths: result.changeConfirmPaths,
continueQuotaCheck: idCache.Contains(pid),
ignoreRiskWarning: ignoreRiskWarning,
ignoreRiskRuleIds: ignoreRiskRuleIds);
ignoreRiskRuleIds: ignoreRiskRuleIds,
skipNewRiskCheck: skipNewRiskCheck);
if (pid > 0)
{
idCache.Add(pid);
@@ -166,9 +167,10 @@ namespace YLErp.Modules.TradeModule.DealModule
/// <param name="confirmedTradeIds"></param>
/// <param name="changeConfirmPaths"></param>
/// <param name="continueQuotaCheck"></param>
/// <param name="skipNewRiskCheck">仅用于特批批量确认,true 表示老风控和新风控都跳过;单笔老风控特批保持 false,仍执行新风控。</param>
/// <returns></returns>
private TradeOpenResult ConfirmTrade(int tid, bool isBatch, bool hasTradeProcess,
bool ignoreMoneyCheck, bool isSkipApproval, List<int> confirmedTradeIds, List<string> changeConfirmPaths, bool continueQuotaCheck = false, bool ignoreRiskWarning = false, IEnumerable<string> ignoreRiskRuleIds = null)
bool ignoreMoneyCheck, bool isSkipApproval, List<int> confirmedTradeIds, List<string> changeConfirmPaths, bool continueQuotaCheck = false, bool ignoreRiskWarning = false, IEnumerable<string> ignoreRiskRuleIds = null, bool skipNewRiskCheck = false)
{
var td = DbContext.trade.FirstOrDefault(t => t.id == tid);
var result = new TradeOpenResult(td ?? new trade());
@@ -211,8 +213,8 @@ namespace YLErp.Modules.TradeModule.DealModule
var trialService = new QuotaMonitorService(this);
if (ignoreMoneyCheck)
{
// ignoreMoneyCheck 表示老风控资金/限额检查已被特批放行,只跳过老风控;新风控仍需独立执行
if (!trialService.CheckTradeConfirmNewRisk(ref result, ignoreRiskWarning, ignoreRiskRuleIds))
// 单笔老风控特批仍需执行新风控;特批批量确认通过 skipNewRiskCheck 显式跳过新风控
if (!skipNewRiskCheck && !trialService.CheckTradeConfirmNewRisk(ref result, ignoreRiskWarning, ignoreRiskRuleIds))
{
return result;
}
@@ -64,7 +64,6 @@ public static class SwapSettlementBillRowBuilder
var positions = input.Positions ?? Array.Empty<swap_position>();
var eventFlows = input.EventFlows ?? Array.Empty<swap_flow_event>();
// 三个业务日期是结算单和文件命名的必填项,缺失时直接阻止生成不完整附件。
var startDate = input.Trade.StartDate
?? throw new ServiceException("结算单缺少交易起始日");
var eventDate = input.CloseFlow.UnwindDate
@@ -72,7 +71,7 @@ public static class SwapSettlementBillRowBuilder
var payDate = input.CloseFlow.PayDate
?? throw new ServiceException("结算单缺少支付日");
// 将同一事件的普通利息与预付金利息分开;预付金本金仅统计结算日前已生效的腿。
var settlementPosition = positions.FirstOrDefault(x => x.id == input.CloseFlow.PositionId);
var interestEvents = eventFlows
.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode))
@@ -91,9 +90,13 @@ public static class SwapSettlementBillRowBuilder
var fee = -(input.CloseFlow.TradingFee + input.CloseFlow.TradingFeePending);
var marginInterest = -marginEvents.Sum(x => x.InterestClosePnL);
var initialMargin = SumMargin(effectiveMargins, InterestModeEnum.);
var additionalMargin = SumMargin(effectiveMargins, InterestModeEnum.);
var additionalMarginPositions = positions
.Where(x => x.InterestMode == (int)InterestModeEnum.)
.ToList();
var additionalMargin = SumMargin(additionalMarginPositions, InterestModeEnum.);
// 净额结算仅包含全部事件利息和浮动盈亏,不包含预付金返还本金。
var netSettlementAmount = -eventFlows.Sum(x => x.InterestClosePnL)
- input.CloseFlow.FloatPnlSum;
var maturitySettlementAmount = netSettlementAmount + initialMargin + additionalMargin;
@@ -101,10 +104,10 @@ public static class SwapSettlementBillRowBuilder
? 0m
: interestAmount / input.CloseNotionalValue;
// 日终持仓的当日浮动端分红按标的类型拆分:债券展示期间付息,非债券展示期间分红。
var isCashBond = ConsGlobal.InstrumentType.IsBond(input.UnderlyingInstrumentType);
// 此处集中完成模板字段映射和展示精度处理,生成器只负责组装原始业务数据。
return new ExcelReportModel
{
TradeNumber = input.ConfirmNo,
@@ -141,7 +144,9 @@ public static class SwapSettlementBillRowBuilder
.ToString("0.0000%"),
MarginInterestAmount = marginInterest.ToString("0.00"),
InitialMargin = initialMargin.ToString("0.00"),
AdditionalMargin = additionalMargin.ToString("0.00"),
AdditionalMargin = additionalMarginPositions.Count > 0
? additionalMargin.ToString("0.00")
: string.Empty,
MarginAmout = Math.Abs(initialMargin).ToString("0.00"),
MarkClosePnl = (-input.CloseFlow.FloatPnlSum).ToString("0.00"),
NetSettleAmout = netSettlementAmount.ToString("0.00"),
+114 -11
View File
@@ -13,6 +13,8 @@ using YLErp.DBModels;
using YLErp.DBModels.Consts;
using YLErp.Enums;
using YLErp.Model.Enum;
using YLErp.Modules.RiskEngine;
using YLErp.Modules.RiskModule;
using YLErp.Modules.SalesModule;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Dto;
@@ -447,12 +449,14 @@ namespace YLErp.Web.Controllers
/// <summary>
///单标的 平仓
/// </summary>
/// <param name="swap_Deal"></param>
/// <returns></returns>
public JsonResult SwapUnwindJson(UnwindData unwindData)
/// <param name="unwindData">本次单标的平仓数据。</param>
/// <param name="trialDataId">首次新风控试算记录ID;二次确认时传入。</param>
/// <param name="ignoreRiskRuleIds">首次试算返回的需忽略规则ID;二次确认时传入。</param>
/// <returns>平仓结果或新风控确认信息。</returns>
public JsonResult SwapUnwindJson(UnwindData unwindData, int? trialDataId = null, string ignoreRiskRuleIds = null)
{
new SwapDealService(CurUser).SwapUnwind(unwindData);
return JsonSuccess("平仓成功");
var result = new SwapDealService(CurUser).SwapUnwind(unwindData, CreateRiskTrialConfirmation(trialDataId, ignoreRiskRuleIds));
return BuildCloseRiskResult(result, "平仓成功");
}
/// <summary>
/// 互换
@@ -467,13 +471,112 @@ namespace YLErp.Web.Controllers
/// <summary>
/// 互换/平仓提交申请
/// </summary>
/// <param name="unwindData"></param>
/// <param name="eventType"></param>
/// <returns></returns>
public JsonResult ApplyUnwind(UnwindData unwindData, int eventType)
/// <param name="unwindData">本次互换或平仓数据。</param>
/// <param name="eventType">事件类型。</param>
/// <param name="trialDataId">首次新风控试算记录ID;平仓二次确认时传入。</param>
/// <param name="ignoreRiskRuleIds">首次试算返回的需忽略规则ID;二次确认时传入。</param>
/// <returns>提交结果或新风控确认信息。</returns>
public JsonResult ApplyUnwind(UnwindData unwindData, int eventType, int? trialDataId = null, string ignoreRiskRuleIds = null)
{
new SwapDealService(CurUser).ApplySwapTrade(unwindData, eventType);
return JsonSuccess("提交成功");
var result = new SwapDealService(CurUser).ApplySwapTrade(unwindData, eventType, CreateRiskTrialConfirmation(trialDataId, ignoreRiskRuleIds));
return BuildCloseRiskResult(result, "提交成功");
}
/// <summary>
/// 保存平仓风控特批说明,不改变首次试算时间。
/// 平仓二次确认会重新执行风控并生成新的 quotaTrial 快照,本接口只补充首次试算记录的用户说明和审计日志。
/// </summary>
/// <param name="trialDataId">平仓风控试算记录ID。</param>
/// <param name="remark">用户填写的特批说明。</param>
/// <returns>保存结果。</returns>
public JsonResult SaveCloseRiskTrialRemark(int trialDataId, string remark)
{
try
{
var trial = new QuotaMonitorService(CurUser)
.SaveCloseRiskTrialRemark(trialDataId, remark);
new TradeRiskCheckLogService(CurUser).AddLog(trial);
return JsonSuccess();
}
catch (Exception ex)
{
LogFactory.GetLogger("SaveCloseRiskTrialRemark").Error(ex);
return JsonError("保存平仓风控试算说明失败!");
}
}
/// <summary>
/// 根据首次试算记录ID创建平仓新风控二次确认参数。
/// </summary>
/// <param name="trialDataId">首次新风控试算记录ID。</param>
/// <param name="ignoreRiskRuleIds">首次试算返回的需忽略规则ID,多个ID使用逗号分隔。</param>
/// <returns>首次提交返回空,二次确认返回包含有效期和需忽略规则ID的确认参数。</returns>
private static RiskTrialConfirmation CreateRiskTrialConfirmation(int? trialDataId, string ignoreRiskRuleIds)
{
if (!trialDataId.HasValue)
{
return null;
}
var expireSeconds = 300;
var expireSecondsConfig = AppManager.GetAppConfigValue("ProjectConfig", "Trade.RiskWarningConfirmExpireSeconds");
if (!string.IsNullOrWhiteSpace(expireSecondsConfig)
&& int.TryParse(expireSecondsConfig, out var configuredExpireSeconds)
&& configuredExpireSeconds > 0)
{
expireSeconds = configuredExpireSeconds;
}
return new RiskTrialConfirmation
{
TrialDataId = trialDataId.Value,
ExpireSeconds = expireSeconds,
IgnoreRiskRuleIds = ParseIgnoreRiskRuleIds(ignoreRiskRuleIds)
};
}
private static List<string> ParseIgnoreRiskRuleIds(string ignoreRiskRuleIds)
{
return (ignoreRiskRuleIds ?? string.Empty)
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(ruleId => ruleId.Trim())
.Where(ruleId => !string.IsNullOrWhiteSpace(ruleId))
.Distinct()
.ToList();
}
/// <summary>
/// 将平仓新风控结果转换为控制器统一JSON响应。
/// </summary>
/// <param name="result">新风控试算结果;互换事件不执行平仓风控时为空。</param>
/// <param name="successMessage">业务执行成功后的提示。</param>
/// <returns>阻断、二次确认或业务成功响应。</returns>
private JsonResult BuildCloseRiskResult(RiskTrialResult result, string successMessage)
{
if (result == null || result.Passed)
{
return JsonSuccess(successMessage, result?.ShowTip == true ? new { result.TrialDataId, result.Message } : null);
}
if (result.Blocked)
{
// 风控阻断属于可预期的业务结果,返回成功协议供前端打开只读试算详情,不进入通用请求失败分支。
return JsonSuccessData(new
{
proccessType = "QuotaTrialError",
result.TrialDataId,
message = string.IsNullOrWhiteSpace(result.Message)
? "平仓风控校验未通过"
: result.Message
});
}
return JsonSuccessData(new
{
proccessType = "AdditionalProcessing",
source = "RiskWarning",
type = "RiskWarningConfirm",
result.TrialDataId,
ignoreRiskRuleIds = result.ApprovalRuleIds,
message = result.ConfirmationExpired ? $"原风控确认已超时,请重新确认。{result.Message}" : result.Message
});
}
/// <summary>
/// 框架合约保存
+5 -2
View File
@@ -671,7 +671,8 @@ namespace YLErp.Web.Controllers
x.PayableMargin = clientBalance.PayableMargin;
x.TwoSideMargin = clientBalance.TwoSideMargin;
x.OtherSideMargin = -clientBalance.OtherSideMargin;
x.MySideMargin = clientBalance.MySideMargin;
//初始保证金金额=应付预付金流水收付净额(与每日估值报告"初始保证金金额"同口径,SwapInitMargin
x.MySideMargin = clientBalance.SwapInitMargin;
x.DeltaMargin = -clientBalance.DeltaMargin;
x.IsPayableMarginManual = clientBalance?.IsPayableMarginManual;
x.AvailableAmount = clientBalance.AvailableAmount;
@@ -689,7 +690,9 @@ namespace YLErp.Web.Controllers
x.EndDesirableFund = x.NetFund + x.SettlementBalance + clientBalance.EndPremiumSum;
x.SwapMarketAmount= clientBalance.SwapMarketAmount;
x.SwapMarketAmountPercent=clientBalance.SwapMarketAmountPercent;
x.MaintenanceMargin=clientBalance.MaintenanceMargin;
//维持保证金金额=MySideMarginclient_span 维持保证金反号聚合,与每日估值报告"维持保证金金额"同口径;
//原取 client_span.VariationMargin——该列全库无写入方恒为0)
x.MaintenanceMargin = -(clientBalance.MySideMargin);
x.NeedAddMargin=clientBalance.NeedAddMargin;
x.LastDayRemainFund=clientBalance.LastDayRemainFund;
if (clientBalance.MarginOccupation == 0)
+9 -3
View File
@@ -2469,7 +2469,6 @@ namespace YLErp.Web.Controllers
var ignoreMoneyCheck = tradeidArr.Count() == 1 && (additionalProcessing == tradeBLL.LackOfMoney || isOldNewRiskConfirm);
var ignoreRiskWarning = tradeidArr.Count() == 1 && (additionalProcessing == tradeBLL.RiskWarningConfirm || isOldNewRiskConfirm);
var ignoreRiskRuleIdArr = StringHelper.ConvertCommaValuesToStringArray(ignoreRiskRuleIds);
_logger.Info($"TradeConfirmRisk.Request tradeids:{tradeids}, additionalProcessing:{additionalProcessing}, trialDataId:{trialDataId}, ignoreMoneyCheck:{ignoreMoneyCheck}, ignoreRiskWarning:{ignoreRiskWarning}, ignoreRiskRuleIds:{ignoreRiskRuleIds}, isSkipCheck:{isSkipCheck}, userId:{CurUser?.UserId}");
// 老风控交易特批、新风控审批及组合特批统一基于 trialDataId 做超时校验,超时后清空放行标记并重新校验。
// tradeview.js 和 swapTradeView.js 会回传 trialDataId;未回传的旧入口保持原有行为,不进入超时校验。
if (ignoreMoneyCheck || ignoreRiskWarning)
@@ -2489,13 +2488,20 @@ namespace YLErp.Web.Controllers
}
}
var isSkipApproval = false;
var skipNewRiskCheck = false;
// 特批批量确认的语义是直接确认,跳过审批、老风控和新风控;单笔老风控特批不设置该标记。
if (CurUser._特批批量确认 && isSkipCheck)
{
ignoreMoneyCheck = true;
isSkipApproval = true;
skipNewRiskCheck = true;
_logger.Info($"TradeConfirmRisk.SkipNewRisk tradeids:{tradeids}, isSkipCheck:{isSkipCheck}, ignoreMoneyCheck:{ignoreMoneyCheck}, isSkipApproval:{isSkipApproval}, skipNewRiskCheck:{skipNewRiskCheck}, userId:{CurUser?.UserId}");
}
var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning, ignoreRiskRuleIdArr, skipNewRiskCheck);
if (result.NewRiskBlocked || result.NewRiskNeedApproval || result.OldRiskNeedSpecialApproval || result.type == TradeOpenRetCode.QuotaTrialError.ToString() || result.type == TradeOpenRetCode.RiskWarning.ToString())
{
_logger.Info($"TradeConfirmRisk.Result tradeids:{tradeids}, lackOfMoney:{result.LackOfMoney}, type:{result.type}, trialDataId:{result.TrialDataId}, oldRiskNeedSpecialApproval:{result.OldRiskNeedSpecialApproval}, newRiskNeedApproval:{result.NewRiskNeedApproval}, newRiskBlocked:{result.NewRiskBlocked}, skipNewRiskCheck:{skipNewRiskCheck}, ignoreRiskRuleIds:{string.Join(",", result.ignoreRiskRuleIds ?? new List<string>())}, errorMsgLength:{result.errorMsg?.Length ?? 0}, tipMsgLength:{result.tipMsg?.Length ?? 0}, userId:{CurUser?.UserId}");
}
var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning, ignoreRiskRuleIdArr);
_logger.Info($"TradeConfirmRisk.Result tradeids:{tradeids}, lackOfMoney:{result.LackOfMoney}, type:{result.type}, trialDataId:{result.TrialDataId}, oldRiskNeedSpecialApproval:{result.OldRiskNeedSpecialApproval}, newRiskNeedApproval:{result.NewRiskNeedApproval}, newRiskBlocked:{result.NewRiskBlocked}, ignoreRiskRuleIds:{string.Join(",", result.ignoreRiskRuleIds ?? new List<string>())}, errorMsgLength:{result.errorMsg?.Length ?? 0}, tipMsgLength:{result.tipMsg?.Length ?? 0}, userId:{CurUser?.UserId}");
//如果需要前端确认信息,触发新老风控
if (result.LackOfMoney)
@@ -1,4 +1,4 @@
@using YLErp.Enums;
@using YLErp.Enums;
@model margin_template_v2
@{
@@ -216,7 +216,6 @@
<span v-if="blk.isFundBlock"> ETF 子类:<select v-model="blk.sections[0].detail.SpanConfig.EtfKind" v-on:change="onEtfKindChange(blk)"><option value="">不区分</option><option v-for="it in etfSubtypeItems" v-bind:value="it">{{it}}</option></select></span>
<span v-if="blk.isTBond || blk.isTieredEtf">(按期限分档:≤5y、(5y-10y]、(10y-30y]、&gt;30y,每个档位独立设置)</span>
</p>
<p style="margin:4px 0; color:#888;">存量 x/y 配置保留但不再展示,请按新区间结构录入。</p>
<div v-for="sec in blk.sections" v-bind:key="sec.key">
<p v-if="blk.isTBond || blk.isTieredEtf" style="margin:8px 0 2px; font-weight:bold;">期限档位:{{sec.termLabel}}</p>
<template v-if="sec.detail && sec.detail.SpanConfig">
@@ -1,4 +1,4 @@
@using YLErp.Enums;
@using YLErp.Enums;
@model margin_template_v2
@{
@@ -217,7 +217,6 @@
<span v-if="blk.isFundBlock"> ETF 子类:<select v-model="blk.sections[0].detail.SpanConfig.EtfKind" v-on:change="onEtfKindChange(blk)"><option value="">不区分</option><option v-for="it in etfSubtypeItems" v-bind:value="it">{{it}}</option></select></span>
<span v-if="blk.isTBond || blk.isTieredEtf">(按期限分档:≤5y、(5y-10y]、(10y-30y]、&gt;30y,每个档位独立设置)</span>
</p>
<p style="margin:4px 0; color:#888;">存量 x/y 配置保留但不再展示,请按新区间结构录入。</p>
<div v-for="sec in blk.sections" v-bind:key="sec.key">
<p v-if="blk.isTBond || blk.isTieredEtf" style="margin:8px 0 2px; font-weight:bold;">期限档位:{{sec.termLabel}}</p>
<template v-if="sec.detail && sec.detail.SpanConfig">
@@ -1,4 +1,4 @@
@using YLErp.Enums;
@using YLErp.Enums;
@model margin_template_v2
@{
@@ -32,7 +32,7 @@
<form id="marginTemplateV2Form" method="post" onsubmit="return false;">
<div class="row no-gutters">
<div class="col form-layout" style="height: 520px; overflow-y: auto;">
<div class="col form-layout" style="height: calc(100vh - 120px); overflow-y: auto;">
<div class="border">
<P>新模板信息</P>
<div class="form-group">
@@ -133,7 +133,7 @@
</div>
</div>
</div>
<div class="col form-layout" style="height: 520px; overflow-y:auto;">
<div class="col form-layout" style="height: calc(100vh - 120px); overflow-y:auto;">
<template v-if="marginTemplate.RuleType == @((int)MarginRuleTypeEnum.区间追保结构)">
<div class="border detail">
<table class="table table-bordered" style="margin-bottom:0;">
@@ -193,7 +193,6 @@
<span v-if="blk.isFundBlock"> ETF 子类:<select v-model="blk.sections[0].detail.SpanConfig.EtfKind" v-on:change="onEtfKindChange(blk)"><option value="">不区分</option><option v-for="it in etfSubtypeItems" v-bind:value="it">{{it}}</option></select></span>
<span v-if="blk.isTBond || blk.isTieredEtf">(按期限分档:≤5y、(5y-10y]、(10y-30y]、&gt;30y,每个档位独立设置)</span>
</p>
<p style="margin:4px 0; color:#888;">存量 x/y 配置保留但不再展示,请按新区间结构录入。</p>
<div v-for="sec in blk.sections" v-bind:key="sec.key">
<p v-if="blk.isTBond || blk.isTieredEtf" style="margin:8px 0 2px; font-weight:bold;">期限档位:{{sec.termLabel}}</p>
<template v-if="sec.detail && sec.detail.SpanConfig">
@@ -1,4 +1,4 @@
@using YLErp.Enums;
@using YLErp.Enums;
@{
ViewBag.Title = "预付金模板V2";
ViewBag.Menu = "数据管理-预付金模板V2";
@@ -134,13 +134,13 @@
if (id) {
editurl = "/margin_template_v2/margin_template_v2Edit/?enid=" + id;
}
main.infopage("编辑预付金模板", editurl, { area: ['1180px', '68%'] });
main.infopage("编辑预付金模板", editurl, { area: ['1180px', '92%'] });
}
function startCopymargin_template_v2(id) {
editurl = "/margin_template_v2/margin_template_v2Copy/?enid=" + id;
main.infopage("复制预付金模板", editurl, { area: ['1180px', '68%'] });
main.infopage("复制预付金模板", editurl, { area: ['1180px', '92%'] });
}
function startDeletemargin_template_v2(id) {
@@ -438,8 +438,11 @@ const vue = new Vue({
});
var that = this;
var merged = [];
SpanBondTerms.forEach(function (t) {
var r = byTerm[t[0]];
//首行未被4个合法档位命中时(单段行 BondTerm 为空)复用为首个档位行,保留已录入内容;
//否则首行会以"EtfKind 已选、BondTerm 为空"的残留行留在明细里,保存时被服务端"分期限档类型必须配置期限档"拦截
var headMatched = SpanBondTerms.some(function (t) { return byTerm[t[0]] === head; });
SpanBondTerms.forEach(function (t, ti) {
var r = byTerm[t[0]] || (ti === 0 && !headMatched ? head : null);
if (!r) r = that.newSpanDetail(FundTypeMask, t[0], kind);
r.UnderlyingType = FundTypeMask;
r.SpanConfig.EtfKind = kind;
@@ -449,8 +449,11 @@ const vue = new Vue({
});
var that = this;
var merged = [];
SpanBondTerms.forEach(function (t) {
var r = byTerm[t[0]];
//首行未被4个合法档位命中时(单段行 BondTerm 为空)复用为首个档位行,保留已录入内容;
//否则首行会以"EtfKind 已选、BondTerm 为空"的残留行留在明细里,保存时被服务端"分期限档类型必须配置期限档"拦截
var headMatched = SpanBondTerms.some(function (t) { return byTerm[t[0]] === head; });
SpanBondTerms.forEach(function (t, ti) {
var r = byTerm[t[0]] || (ti === 0 && !headMatched ? head : null);
if (!r) r = that.newSpanDetail(FundTypeMask, t[0], kind);
r.UnderlyingType = FundTypeMask;
r.SpanConfig.EtfKind = kind;
@@ -112,15 +112,22 @@ const vueTradeType = function () {
//标的选择组件(EQD-7049:改为服务端搜索,不再依赖全量 ylotc.underlyings,避免十几万标的整段下载卡死)
const vueUnderlying = function () {
const _suggestionTpl = _.template($('#underlyingSuggestionTpl').html());
// 标的缓存:按 品种|关键词 隔离;乱序响应由 token 丢弃(helper 收在函数内,避免全局绑定冲突)
// 标的缓存(同品种各实例共享):_cache 为最近一次服务端结果,_fresh 记录其对应的 品种|关键词,
// _seq 单调递增丢弃乱序/过期响应,_inflight 防同关键词重复请求(helper 收在函数内,避免全局绑定冲突)
const _cache = {};
const _tokens = {};
const _fresh = {};
const _seq = {};
const _inflight = {};
function _fetch(varietyId, query, cb) {
var key = (varietyId || 0) + '|' + (query || '');
var token = (_tokens[key] = (_tokens[key] || 0) + 1);
var vid = varietyId || 0;
var q = query || '';
var key = vid + '|' + q;
if (_inflight[key]) return;
var seq = (_seq[vid] = (_seq[vid] || 0) + 1);
_inflight[key] = true;
var postData = {
FilterCode: (query || '').toUpperCase(),
VarietyId: varietyId || 0,
FilterCode: q.toUpperCase(),
VarietyId: vid,
MaxShowLength: 20,
BlackLimit: 1,
UseForTrading: true,
@@ -128,7 +135,8 @@ const vueUnderlying = function () {
CheckLaunch: true
};
main.post('/frontdata/AjaxGetUnderlyingSelect', postData).done(function (res) {
if (_tokens[key] !== token) return; // 丢弃过期响应
delete _inflight[key];
if (_seq[vid] !== seq) return; // 已有更新的关键词发起请求,丢弃本响应
var arr = (res && (res.obj || res.data)) || [];
var norm = arr.map(function (x) {
return {
@@ -142,7 +150,11 @@ const vueUnderlying = function () {
PinYin: x.PinYin || ''
};
});
cb && cb(norm);
_cache[vid] = norm;
_fresh[vid] = key;
cb && cb(norm, q);
}).fail(function () {
delete _inflight[key];
});
}
function _filter(list, query, varietyId) {
@@ -163,24 +175,28 @@ const vueUnderlying = function () {
mounted() {
var self = this;
this.jqInput = $(this.$el).children(0);
// EQD-7049:预拉默认20条(当前品种),避免下拉空白
_fetch(self.underlying.VarietyId, '', function (list) {
_cache[self.underlying.VarietyId || 0] = list;
try { $(self.jqInput).autocomplete('search', ''); } catch (e) {}
});
// EQD-7049:预拉默认20条(当前品种),获得焦点时由插件自身的 onValueChange 呈现
_fetch(self.underlying.VarietyId, '');
this.autoctrl = FastVue.autocomplete(this.jqInput, {
valueField: 'Code',
lookup(query, callback) {
lookup(query) {
var varietyId = self.underlying.VarietyId;
var cached = _cache[varietyId || 0] || [];
var immediate = _filter(cached, query, varietyId);
if (query) {
// 有输入时异步向服务端搜索并刷新缓存(乱序响应由 token 丢弃
_fetch(varietyId, query, function (list) {
_cache[varietyId || 0] = list;
});
var vid = varietyId || 0;
var key = vid + '|' + (query || '');
if (_fresh[vid] === key) {
// 命中当前关键词的服务端结果:直接展示(服务端已按 StartsWith+品种/黑名单过滤,不再前端二次过滤
return (_cache[vid] || []).slice(0, 20);
}
return immediate;
// 异步搜索。FastVue 包装的 lookup 只同步取返回值渲染,服务端结果到达后必须重新触发
// onValueChange 才会显示;重走 lookup 时命中上面的 _fresh 分支直接返回,不会循环请求
_fetch(varietyId, query, function (list, q) {
var inst = self.jqInput.autocomplete();
if (!inst || !inst.visible) return; // 下拉已关闭:留待下次获得焦点时呈现
if ((self.jqInput.val() || '').toLowerCase() !== q.toLowerCase()) return; // 输入已变化:等新关键词的响应
inst.onValueChange();
});
// 过渡兜底:服务端响应到达前用旧缓存按关键词过滤,避免搜索期间下拉空白
return _filter(_cache[vid] || [], query, varietyId);
},
onSelect(data) {
if (self.underlying !== data) {
@@ -1400,7 +1416,7 @@ const vueTrade = function () {
switch (this.viewState.initFlag) {
case 'first':
this.viewState.initFlag = '';
this.changeInstrumentType();
this.preloadDefaultUnderlying();
break;
case 'import1':
case 'import2':
@@ -1430,6 +1446,15 @@ const vueTrade = function () {
tradeUtils.resetRisky(this.trade);
}
},
// EQD-7049:首腿自动预载默认类型标的。固收等环境下默认类型(Stock/CommodityFutures)可能没有
// 已上线标的,后端必返回"标的信息缺失"——属可容忍场景,静默失败不弹窗,留待用户自选;
// 用户主动切换类型仍走 changeInstrumentType,查询失败正常提示
preloadDefaultUnderlying() {
if (new Date().getTime() < this.updateKey.underlying + 300) return;
let instType = this.trade.UnderlyingInstrumentType;
this.viewState.variety = tradeHelper.getEmptyVariety(instType);
this.updateUnderlying({ InstrumentType: instType }, false, true);
},
//变更标的类型
changeInstrumentType() {
if (new Date().getTime() < this.updateKey.underlying + 300) return;
@@ -1468,7 +1493,7 @@ const vueTrade = function () {
}
},
//更新标的
updateUnderlying(reqData, fromSelect) {
updateUnderlying(reqData, fromSelect, silent) {
let self = this;
// EQD-7049:新建空白页未选标的/品种/类型时,跳过必然失败的后端默认标的查询,避免报“标的信息缺失”
var hasQueryKey = !!(reqData.UnderlyingCode || reqData.InstrumentType || reqData.VarietyId > 0);
@@ -1478,7 +1503,10 @@ const vueTrade = function () {
}
var instTypeChanged = !!reqData.InstrumentType;
!fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying());
main.post("/pricing/AjaxGetUnderlying", reqData).done(function (resp) {
// silent:自动预载场景失败不弹窗(alertFn 置空+抑制网络错误提示),用户主动查询仍正常提示
var req = main.post("/pricing/AjaxGetUnderlying", reqData, silent ? { alertFn: $.noop, suppressError: true } : undefined);
silent && req.fail(function (resp) { console.warn('预载默认标的失败(已忽略):', resp && resp.msg); });
req.done(function (resp) {
let trade = self.trade;
let um = resp.obj.underlying;
if (!fromSelect) {
@@ -108,18 +108,26 @@ const vueTradeType = function () {
};
};
//标的选择组件(EQD-7049:改为服务端搜索,不再依赖全量 ylotc.underlyings,避免十几万标的整段下载卡死)
//标的选择组件(EQD-7049:改为服务端搜索,不再依赖全量 ylotc.underlyings,避免十几万标的整段下载卡死)
const vueUnderlying = function () {
const _suggestionTpl = _.template($('#underlyingSuggestionTpl').html());
// 标的缓存:按 品种|关键词 隔离;乱序响应由 token 丢弃(helper 收在函数内,避免全局绑定冲突)
// 标的缓存(同品种各实例共享):_cache 为最近一次服务端结果,_fresh 记录其对应的 品种|关键词,
// _seq 单调递增丢弃乱序/过期响应,_inflight 防同关键词重复请求(helper 收在函数内,避免全局绑定冲突)
const _cache = {};
const _tokens = {};
const _fresh = {};
const _seq = {};
const _inflight = {};
function _fetch(varietyId, query, cb) {
var key = (varietyId || 0) + '|' + (query || '');
var token = (_tokens[key] = (_tokens[key] || 0) + 1);
var vid = varietyId || 0;
var q = query || '';
var key = vid + '|' + q;
if (_inflight[key]) return;
var seq = (_seq[vid] = (_seq[vid] || 0) + 1);
_inflight[key] = true;
var postData = {
FilterCode: (query || '').toUpperCase(),
VarietyId: varietyId || 0,
FilterCode: q.toUpperCase(),
VarietyId: vid,
MaxShowLength: 20,
BlackLimit: 1,
UseForTrading: true,
@@ -127,7 +135,8 @@ const vueUnderlying = function () {
CheckLaunch: true
};
main.post('/frontdata/AjaxGetUnderlyingSelect', postData).done(function (res) {
if (_tokens[key] !== token) return; // 丢弃过期响应
delete _inflight[key];
if (_seq[vid] !== seq) return; // 已有更新的关键词发起请求,丢弃本响应
var arr = (res && (res.obj || res.data)) || [];
var norm = arr.map(function (x) {
return {
@@ -141,7 +150,11 @@ const vueUnderlying = function () {
PinYin: x.PinYin || ''
};
});
cb && cb(norm);
_cache[vid] = norm;
_fresh[vid] = key;
cb && cb(norm, q);
}).fail(function () {
delete _inflight[key];
});
}
function _filter(list, query, varietyId) {
@@ -162,24 +175,28 @@ const vueUnderlying = function () {
mounted() {
var self = this;
this.jqInput = $(this.$el).children(0);
// EQD-7049:预拉默认20条(当前品种),避免下拉空白
_fetch(self.underlying.VarietyId, '', function (list) {
_cache[self.underlying.VarietyId || 0] = list;
try { $(self.jqInput).autocomplete('search', ''); } catch (e) {}
});
// EQD-7049:预拉默认20条(当前品种),获得焦点时由插件自身的 onValueChange 呈现
_fetch(self.underlying.VarietyId, '');
this.autoctrl = FastVue.autocomplete(this.jqInput, {
valueField: 'Code',
lookup(query, callback) {
lookup(query) {
var varietyId = self.underlying.VarietyId;
var cached = _cache[varietyId || 0] || [];
var immediate = _filter(cached, query, varietyId);
if (query) {
// 有输入时异步向服务端搜索并刷新缓存(乱序响应由 token 丢弃
_fetch(varietyId, query, function (list) {
_cache[varietyId || 0] = list;
});
var vid = varietyId || 0;
var key = vid + '|' + (query || '');
if (_fresh[vid] === key) {
// 命中当前关键词的服务端结果:直接展示(服务端已按 StartsWith+品种/黑名单过滤,不再前端二次过滤
return (_cache[vid] || []).slice(0, 20);
}
return immediate;
// 异步搜索。FastVue 包装的 lookup 只同步取返回值渲染,服务端结果到达后必须重新触发
// onValueChange 才会显示;重走 lookup 时命中上面的 _fresh 分支直接返回,不会循环请求
_fetch(varietyId, query, function (list, q) {
var inst = self.jqInput.autocomplete();
if (!inst || !inst.visible) return; // 下拉已关闭:留待下次获得焦点时呈现
if ((self.jqInput.val() || '').toLowerCase() !== q.toLowerCase()) return; // 输入已变化:等新关键词的响应
inst.onValueChange();
});
// 过渡兜底:服务端响应到达前用旧缓存按关键词过滤,避免搜索期间下拉空白
return _filter(_cache[vid] || [], query, varietyId);
},
onSelect(data) {
if (self.underlying !== data) {
@@ -1008,7 +1025,7 @@ const vueTrade = function () {
switch (this.viewState.initFlag) {
case 'first':
this.viewState.initFlag = '';
this.changeInstrumentType();
this.preloadDefaultUnderlying();
break;
case 'import1':
case 'import2':
@@ -1042,6 +1059,15 @@ const vueTrade = function () {
tradeUtils.resetCashFlow(this.trade);
}
},
// EQD-7049:首腿自动预载默认类型标的。固收等环境下默认类型(Stock/CommodityFutures)可能没有
// 已上线标的,后端必返回"标的信息缺失"——属可容忍场景,静默失败不弹窗,留待用户自选;
// 用户主动切换类型仍走 changeInstrumentType,查询失败正常提示
preloadDefaultUnderlying() {
if (new Date().getTime() < this.updateKey.underlying + 300) return;
let instType = this.trade.UnderlyingInstrumentType;
this.viewState.variety = tradeHelper.getEmptyVariety(instType);
this.updateUnderlying({ InstrumentType: instType }, false, true);
},
//变更标的类型
changeInstrumentType() {
if (new Date().getTime() < this.updateKey.underlying + 300) return;
@@ -1082,7 +1108,7 @@ const vueTrade = function () {
}
},
//更新标的
updateUnderlying(reqData, fromSelect) {
updateUnderlying(reqData, fromSelect, silent) {
let self = this;
// EQD-7049:新建空白页未选标的/品种/类型时,跳过必然失败的后端默认标的查询,避免报“标的信息缺失”
var hasQueryKey = !!(reqData.UnderlyingCode || reqData.InstrumentType || reqData.VarietyId > 0);
@@ -1092,7 +1118,10 @@ const vueTrade = function () {
}
var instTypeChanged = !!reqData.InstrumentType;
!fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying());
main.post("/pricing/AjaxGetUnderlying", reqData).done(function (resp) {
// silent:自动预载场景失败不弹窗(alertFn 置空+抑制网络错误提示),用户主动查询仍正常提示
var req = main.post("/pricing/AjaxGetUnderlying", reqData, silent ? { alertFn: $.noop, suppressError: true } : undefined);
silent && req.fail(function (resp) { console.warn('预载默认标的失败(已忽略):', resp && resp.msg); });
req.done(function (resp) {
let trade = self.trade;
let um = resp.obj.underlying;
if (!fromSelect) {
@@ -621,22 +621,75 @@ const vue = new Vue({
postUrl = "/swaptrade2/ApplyUnwind";
postData.eventType = 2;//互换3,平仓2
}
main.confirm(msg,
function () {
//重新计算百分比
var thisObj2 = thisObj;
main.post(postUrl, postData).done(function (res) {
if (res.success) {
thisObj2.closetrade_cashWindow();
// 提交平仓并处理新风控阻断、二次确认和成功提示。
var submitClose = function (trialDataId, ignoreRiskRuleIds) {
var requestData = _.cloneDeep(postData);
if (trialDataId) {
requestData.trialDataId = trialDataId;
}
if (ignoreRiskRuleIds && ignoreRiskRuleIds.length > 0) {
requestData.ignoreRiskRuleIds = ignoreRiskRuleIds.join(',');
}
main.post(postUrl, requestData).done(function (res) {
var riskData = res.obj;
if (riskData && riskData.proccessType === "QuotaTrialError") {
// 禁止类规则只展示试算详情,不允许继续提交平仓。
if (riskData.TrialDataId) {
layer.open({
type: 2,
title: "风控试算详情",
shadeClose: false,
shade: 0.4,
area: ['800px', '500px'],
content: "/trade/showQuotaTrial?id=" + riskData.TrialDataId,
btn: ["关闭"]
});
}
else {
try {
thisObj2.closetrade_cashWindow();
} catch (e) {
else if (res.msg) {
main.message(res.msg);
}
return;
}
if (res.success && riskData && riskData.proccessType === "AdditionalProcessing" && riskData.TrialDataId) {
// 审批类规则由用户填写说明并确认,二次提交回传试算记录ID和需忽略规则ID。
var riskTrialLayerIndex = layer.open({
type: 2,
title: "风控试算详情",
shadeClose: false,
shade: 0.4,
area: ['800px', '500px'],
content: "/trade/showQuotaTrial?id=" + riskData.TrialDataId,
btn: ["交易特批", "取消"],
yes: function (index) {
var trialPage = window["layui-layer-iframe" + index].page;
if (!trialPage.Data.Remark || trialPage.Data.Remark.length <= 0) {
main.message("必须填写说明内容,才可以提交平仓");
return;
}
// 平仓使用专用接口且只提交记录ID和说明,避免客户端覆盖试算来源或刷新首次试算时间。
main.post("/SwapTrade2/SaveCloseRiskTrialRemark", {
trialDataId: riskData.TrialDataId,
remark: trialPage.Data.Remark
}).done(function () {
layer.close(riskTrialLayerIndex);
submitClose(riskData.TrialDataId, riskData.ignoreRiskRuleIds || []);
});
}
});
return;
}
if (res.success) {
// 提示类规则不阻断平仓,成功后展示本次风控提示。
if (riskData && riskData.TrialDataId && riskData.Message) {
main.message(riskData.Message);
}
});
thisObj.closetrade_cashWindow();
}
});
};
main.confirm(msg, function () {
submitClose();
});
},
getSumbitText: function () {
return g_isShowReCheckClose ? "审核提交" : "保存";