diff --git a/Framework/YLErp.Core/DBModels/Client.cs b/Framework/YLErp.Core/DBModels/Client.cs index d55ae7f2..b56edc85 100644 --- a/Framework/YLErp.Core/DBModels/Client.cs +++ b/Framework/YLErp.Core/DBModels/Client.cs @@ -648,6 +648,13 @@ namespace YLErp.DBModels [DataChange] public int MarginOptionType { get; set; } + /// + /// 追保规则(0-合约维度盯市 1-客户维度盯市;NULL-未设置,按客户维度盯市/现状处理) + /// + [DisplayName("追保规则")] + [DataChange] + public int? MarginWatchRule { get; set; } + /// /// 是否自动撤单(通道业务) /// diff --git a/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs b/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs index db8ceeac..ac664dcc 100644 --- a/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs +++ b/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs @@ -1,3 +1,5 @@ +using YLErp.Enums; + namespace YLErp.DBModels { /// @@ -5,6 +7,16 @@ namespace YLErp.DBModels /// public static class ConsMarginTerm { + /// + /// 允许配置期限档(SpanConfig.BondTerm)的标的资产类型标志位。 + /// 区间追保结构"按资产类型分类"时,仅这些类型允许设置非空期限档; + /// 本期仅含 利率债(TBonds=1<<4=16),后续按资产分类扩展(转债ETF/科创债ETF 等)时在此补充。 + /// + public static readonly UnderlyingTypeEnum[] TermTierEnabledUnderlyingTypes = + { + UnderlyingTypeEnum.TBonds + }; + /// /// 5年以下(同时也是兜底默认期限) /// diff --git a/Framework/YLErp.Core/DBModels/CreditTable.cs b/Framework/YLErp.Core/DBModels/CreditTable.cs index ee0abbf8..fa899773 100644 --- a/Framework/YLErp.Core/DBModels/CreditTable.cs +++ b/Framework/YLErp.Core/DBModels/CreditTable.cs @@ -25,6 +25,18 @@ namespace YLErp.DBModels [DisplayName("授信额度")] public double? Credit { get; set; } + /// + /// 原始授信值 + /// + [DisplayName("原始授信值")] + public double? OriginalCredit { get; set; } + + /// + /// 最大授信可用比例(0-1,NULL按1) + /// + [DisplayName("最大授信可用比例")] + public double? MaxCreditUseRatio { get; set; } + /// /// PFE授信 /// diff --git a/Framework/YLErp.Core/DBModels/margin_template_detail.cs b/Framework/YLErp.Core/DBModels/margin_template_detail.cs index 1d6c7ad0..83e1ca44 100644 --- a/Framework/YLErp.Core/DBModels/margin_template_detail.cs +++ b/Framework/YLErp.Core/DBModels/margin_template_detail.cs @@ -213,5 +213,51 @@ namespace YLErp.DBModels /// 存于 SpanConfigJson 文本列,无需 DDL。 /// public string BondTerm { get; set; } + + /// + /// ETF 细分种类(预留给"按资产类型分类"扩展:转债ETF / 科创债ETF / 其他)。 + /// 本期不落 UI、不参与取数匹配,仅为后续需求预留扩展键; + /// 届时服务端校验与取数匹配的分组键设计为可扩展元组 (BondTerm, EtfKind)。 + /// 序列化沿用 JsonHelper(默认忽略 null),null 时不写入 SpanConfigJson,不影响存量 JSON 读写。 + /// + public string EtfKind { get; set; } + + /// + /// 指数细分种类(预留给"按资产类型分类"扩展,如中债指数相关细分)。 + /// 本期不落 UI、不参与取数匹配,仅为后续需求预留扩展键。 + /// 序列化沿用 JsonHelper(默认忽略 null),null 时不写入 SpanConfigJson,不影响存量 JSON 读写。 + /// + public string IndexKind { get; set; } + + /// 预警线(小数,0.95=95%) + public double? WarnLine { get; set; } + + /// 平仓线(小数) + public double? CloseLine { get; set; } + + /// 多头方向追保区间(固定4层;第1层只用 Lower=上不封顶) + public List LongSpans { get; set; } + + /// 空头方向追保区间(固定4层;第1层只用 Upper) + public List ShortSpans { get; set; } + } + + /// + /// 区间追保结构单层追保区间配置(规则15 客户原型全量手工录入)。 + /// 区间边界 Lower/Upper 为"×期初净价的百分比"小数(0.95=95%); + /// AmountRate 为追保金额比例,追保金额=AmountRate×期初全价×券面总额(债券) + /// 或 AmountRate×参考标的期初价格×参考标的名义份额(基金/债券指数)。 + /// 多头方向 Lower 逐层严格递减、空头方向 Upper 逐层严格递增(服务端保存时校验)。 + /// + public class SpanTierConfig + { + /// 区间下边界(×期初净价的小数);多头第1层仅用本字段(上不封顶) + public double? Lower { get; set; } + + /// 区间上边界(×期初净价的小数);空头第1层仅用本字段(下不设限) + public double? Upper { get; set; } + + /// 追保金额比例(小数);金额=AmountRate×期初全价×券面总额 / ×参考标的期初价格×名义份额 + public double? AmountRate { get; set; } } } diff --git a/Framework/YLErp.Core/Helpers/JsonHelper.cs b/Framework/YLErp.Core/Helpers/JsonHelper.cs index 8dc293af..4ffbbeb7 100644 --- a/Framework/YLErp.Core/Helpers/JsonHelper.cs +++ b/Framework/YLErp.Core/Helpers/JsonHelper.cs @@ -94,6 +94,23 @@ namespace System return ToJson(obj, ignoreNullValue, camelCase); } + /// + /// 序列化为可安全内嵌进 <script> 的 JSON:HTML 敏感字符(< > & 等)以 \uXXXX 形式转义, + /// 供 Razor 页 @Html.Raw(obj.ToInlineJson()) 内联页面对象使用。 + /// 背景:全局 AntiXssConverter(YLErpWeb Program.cs 注册)在 @Json.Serialize 渲染时把 < > 实体编码为 &lt; &gt;, + /// JS 侧拿到的是实体文本而非原值(如 SpanConfig.BondTerm "<5y" 变 "&lt;5y"),保存回写即脏数据。 + /// + public static string ToInlineJson(this object obj, bool ignoreNullValue = false) + { + if (obj == null) + { + return "null"; + } + var setting = ((JsonSerializerSettingsEx)(ignoreNullValue ? settings[(int)SerializeSetting.ignoreNull] : settings[(int)SerializeSetting.none])).Clone(); + setting.StringEscapeHandling = StringEscapeHandling.EscapeHtml; + return JsonConvert.SerializeObject(obj, setting); + } + //--------------------------------------------------------------------------------- /// diff --git a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs index 33704017..4140b9bd 100644 --- a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs +++ b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs @@ -136,7 +136,8 @@ namespace YLErp.BLL.Eod CreditRating = ratingStr, //客户应付预付金(要么为负,要么为0) //最新概念:负数代表客户应缴预付金,正数代表客户应收预付金 - PayableMargin = clientSpan == null || clientSpan.WorstCastClientPayable == null ? 0 : clientSpan.WorstCastClientPayable.Value, + //合约维度盯市(MarginWatchRule==0)客户成交时已产生出入金,保证金不再计入占用(PayableMargin),避免双重体现 + PayableMargin = client.MarginWatchRule == 0 ? 0 : (clientSpan == null || clientSpan.WorstCastClientPayable == null ? 0 : clientSpan.WorstCastClientPayable.Value), DeltaMargin = clientSpan == null ? 0d : (clientSpan.DeltaMargin ?? 0d), //新增互换预付金 SwapPayableMargin = clientSpan == null || clientSpan.SwapWorstCastClientPayable == null ? 0 : clientSpan.SwapWorstCastClientPayable.Value, diff --git a/YLErpDAL/Modules/ClientModule/ClientCreditService.cs b/YLErpDAL/Modules/ClientModule/ClientCreditService.cs index b24afaac..16cc2700 100644 --- a/YLErpDAL/Modules/ClientModule/ClientCreditService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientCreditService.cs @@ -28,6 +28,8 @@ namespace YLErp.Modules.ClientModule { id = source.id, Credit = source.Credit, + OriginalCredit = source.OriginalCredit, + MaxCreditUseRatio = source.MaxCreditUseRatio, PFECredit = source.PFECredit, IMCredit = source.IMCredit, AuditCredit = source.AuditCredit, diff --git a/YLErpDAL/Modules/ClientModule/CreditTableService.cs b/YLErpDAL/Modules/ClientModule/CreditTableService.cs index 199bfd7a..61697521 100644 --- a/YLErpDAL/Modules/ClientModule/CreditTableService.cs +++ b/YLErpDAL/Modules/ClientModule/CreditTableService.cs @@ -21,6 +21,13 @@ namespace YLErp.Modules.ClientModule throw new ServiceException("授信起始日不能早于授信到期日"); } + //原始授信值 × 最大授信可用比例(NULL按1) 折算出最终授信值; + //OriginalCredit 为空时不触碰 Credit,维持手工授信值(存量兼容) + if (req.OriginalCredit.HasValue) + { + req.Credit = req.OriginalCredit.Value * (req.MaxCreditUseRatio ?? 1); + } + if (req.Credit > req.AuditCredit) { throw new ServiceException("授信额度不能大于审批额度"); @@ -60,6 +67,8 @@ namespace YLErp.Modules.ClientModule throw new ServiceException("数据已不存在"); } updateComments = dbModel.Credit == req.Credit + && dbModel.OriginalCredit == req.OriginalCredit + && dbModel.MaxCreditUseRatio == req.MaxCreditUseRatio && dbModel.PFECredit == req.PFECredit && dbModel.IMCredit == req.IMCredit && dbModel.AuditCredit == req.AuditCredit diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs index 94337c80..cc0200a7 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs @@ -909,7 +909,12 @@ namespace YLErp.Modules.EodModule.SettlementModule var clientSpan = clientSpanList == null || clientSpanList.Count == 0 ? null : clientSpanList.FirstOrDefault(t => t.ClientId == client.id && t.ValueDate == balanceDate && t.SpanType == ClientSpan.SpanType_Eod); if (clientSpan != null) { - PayableMargin += clientSpan.WorstCastClientPayable ?? 0d; + // 合约维度盯市(MarginWatchRule==0)客户成交时已产生出入金,保证金不再计入占用(PayableMargin),避免双重体现; + // 客户维度盯市(null/1)维持现状,计入占用。存量客户为 null,行为不变。 + if (client.MarginWatchRule != 0) + { + PayableMargin += clientSpan.WorstCastClientPayable ?? 0d; + } DeltaMargin = clientSpan.DeltaMargin ?? 0d; SwapPayableMargin = clientSpan.SwapWorstCastClientPayable ?? 0d; SwapUnPayableMargin = clientSpan.SwapUnMargin ?? 0d; diff --git a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs index 95e261d7..6d9cad90 100644 --- a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs +++ b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs @@ -16,7 +16,7 @@ namespace YLErp.Modules.MarginModule public static class MarginTemplateV2RateHelper { /// - /// 取数结果。率已统一换算为小数(如 0.1 表示 10%),调用方直接用。 + /// 取数结果。率为小数(如 0.1 表示 10%,与数据库一致),调用方直接用。 /// public class MarginRateResult { @@ -113,6 +113,14 @@ namespace YLErp.Modules.MarginModule } //5.标的资产类型匹配(仅当模板选了"按资产类型分类"):精确标志位 → 通配行(None/All)兜底 + //标的细分分类判定钩子:本期默认返回 null → 走下方原有标志位匹配/通配行兜底,行为与现状一致; + //后续需求(转债ETF/科创债ETF/中债指数等细分)实现判定规则后,此处按 (BondTerm, EtfKind) 元组匹配明细行。 + var underlyingCategory = GetUnderlyingCategory(underlyingCode, underlyingInstrumentType); + if (underlyingCategory != null) + { + //TODO: 后续需求实现:按 (SpanConfig.BondTerm, SpanConfig.EtfKind) 元组匹配明细行,替代/补充下方标志位匹配 + } + if (template.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.CustomInstrumentType && Enum.TryParse(underlyingInstrumentType, out var instrumentFlag)) { @@ -142,11 +150,20 @@ namespace YLErp.Modules.MarginModule } /// - /// 模板页录入的百分数(如 10 表示 10%)换算为小数(0.1) + /// 标的细分分类判定钩子(转债ETF/科创债ETF/中债指数等)。 + /// 本期默认返回 null → 走通配行兜底,行为与现状一致;后续需求按业务给的判定规则(代码段/标的维护字段)实现。 + /// + private static string GetUnderlyingCategory(string underlyingCode, string underlyingInstrumentType) + { + return null; + } + + /// + /// 明细率值换算:页面上 vue-number-input 以 % 展示,数据库存的就是小数(如 0.05 表示 5%),直接取用 /// private static decimal? ToDecimalRate(double? ratio) { - return ratio.HasValue ? (decimal)(ratio.Value / 100d) : (decimal?)null; + return ratio.HasValue ? (decimal)ratio.Value : (decimal?)null; } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 90d67255..60a031cd 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1658,6 +1658,19 @@ namespace YLErp.Modules.SwapModule } } } + // 合约维度盯市+无预付金腿:重建交易级(positionId=0)初始预付金记录(与 SwapTradeConfirm 一致,回退重补场景)。 + // 有预付金腿的互换由上面 foreach 按腿重建,不在此重复生成。 + var resetWatchClient = DataCacheProvider.GetClientDataSource().GetData(td.ClientId); + var hasPrepayLeg = swapPositions != null && swapPositions.Any(x => x.InterestMode == (int)InterestModeEnum.初始预付金 || x.InterestMode == (int)InterestModeEnum.追加预付金); + if (resetWatchClient != null && resetWatchClient.MarginWatchRule == 0 && !hasPrepayLeg) + { + var cashSvc = new ClientCashInCashOutService(this); + var initMargin = cashSvc.GetInitialMarginAmount(td); + if (initMargin > 0) + { + cashSvc.SaveSwapTradeClientCash(td, initMargin, happenDate, 0, ClientCashInCashOut.系统操作_应付预付金); + } + } } /// /// 获取持仓腿信息 diff --git a/YLErpDAL/Modules/TradeModule/ClientCashInCashOutService.cs b/YLErpDAL/Modules/TradeModule/ClientCashInCashOutService.cs index 141e7b7e..2819dc05 100644 --- a/YLErpDAL/Modules/TradeModule/ClientCashInCashOutService.cs +++ b/YLErpDAL/Modules/TradeModule/ClientCashInCashOutService.cs @@ -9,6 +9,7 @@ using YLErp.Helpers; using YLErp.Model; using YLErp.Modules.CalculationModule; using YLErp.Modules.ClientModule; +using YLErp.Modules.MarginModule; using YLErp.Modules.TradeModule.DealModule; namespace YLErp.Modules.TradeModule @@ -185,6 +186,42 @@ namespace YLErp.Modules.TradeModule } DbContext.SaveChanges(); + + // 追保规则-合约维度盯市:非互换交易(期权等)成交确认时,按计算保证金生成初始预付金资金记录(入金)。 + // 客户维度盯市(null/1)不生成出入金,保证金只进 clientbalancedaily.PayableMargin(占用),维持现状。 + var marginClient = DataCacheProvider.GetClientDataSource().GetData(newtrade.ClientId); + if (marginClient != null && marginClient.MarginWatchRule == 0) + { + var initMargin = GetInitialMarginAmount(newtrade); + if (initMargin > 0) + { + SaveSwapTradeClientCash(newtrade, initMargin, newtrade.TradeDate ?? DateTime.Now, 0, ClientCashInCashOut.系统操作_应付预付金); + } + } + } + + /// + /// 计算合约维度盯市客户成交时应生成的初始预付金金额: + /// 交易绑定了预付金模板V2 → 名义本金(StockEqvNotional) × 初始率x("无预付金"规则算出0,不生成记录); + /// 否则(老交易/期权)→ trade.InitialMargin。期权未放开V2绑定,恒走 InitialMargin。 + /// + public double GetInitialMarginAmount(trade td) + { + if (td == null) return 0; + // 新交易(绑定了 V2 模板)→ 名义本金 × x + bool boundV2 = DbContext.trade_margin_template.AsNoTracking().Any(x => x.TradeId == td.id); + if (boundV2) + { + var valueDate = td.TradeDate ?? DateTime.Today; + var rate = MarginTemplateV2RateHelper.GetTradeMarginRate(td.id, td.UnderlyingCode, td.UnderlyingInstrumentType, valueDate, DbContext); + if (rate != null) + { + return Math.Abs((double)(rate.InitRate ?? 0m) * td.StockEqvNotional); + } + // 绑定了模板但取率失败(模板无效/规则不支持/明细无匹配)→ 兜底用 InitialMargin + } + // 老交易(未绑 V2)/期权 → trade.InitialMargin + return Math.Abs(td.InitialMargin ?? 0); } /// diff --git a/YLErpDAL/Resources/clientEditConfig.js b/YLErpDAL/Resources/clientEditConfig.js index e13724c3..75667b8c 100644 --- a/YLErpDAL/Resources/clientEditConfig.js +++ b/YLErpDAL/Resources/clientEditConfig.js @@ -15,6 +15,7 @@ { name: "SalesDepartmentId", label: "归属营业部", type: "select" }, { name: "DefaultLoginName", label: "默认登录名", type: "text" }, { name: "MarginOptionType", label: "追保方向", required: true, type: "select" }, + { name: "MarginWatchRule", label: "追保规则", required: true, type: "select" }, { name: "Abbreviation", label: "客户简称", type: "text" }, { name: "ProtocolSignVersion", label: "协议签署版本", type: "select", dictionaryKey: "协议签署版本" }, { name: "AdminFullName", label: "管理人全称", type: "text" }, @@ -149,6 +150,7 @@ InstitutionalAttributes: ["国有", "民营", "外商", "其他"], LicenseType: ["营业执照", "产品编号", "身份证"], MarginOptionType: [{ text: "单向追保", value: 0 }, { text: "双向追保", value: 1 }, { text: "对手方单向追保", value: 2 }, { text: "其他", value: 3 }], + MarginWatchRule: [{ text: "合约维度盯市", value: 0 }, { text: "客户维度盯市", value: 1 }], SamePeer: [{ text: "同业", value: 0 }, { text: "非同业", value: 1 }], IsTradeCredit: [{ text: "否", value: 0 }, { text: "是", value: 1 }], IsDocShowParentName: [{ text: "否", value: 0 }, { text: "是", value: 1 }], @@ -173,7 +175,7 @@ }, defaults: { ClientType: "机构", InstitutionalAttributes: "民营", LicenseType: "营业执照", - AccessRule: "2", MarginOptionType: "0", + AccessRule: "2", MarginOptionType: "0", MarginWatchRule: "1", SamePeer: "1", IsTradeCredit: "1", IsDocShowParentName: "0", RiskPreference: "有限", InvestmentExperience: "无", FundsSource: "1", BadFaithRecord: "1", DerivativesInvestmentVarieties: "1,2,3,4", IsRealControl: "0", InvestmentTerm: "1", ExpectedReturn2: "5%-10%", IsIndustryConnectVariety: "1", @@ -277,6 +279,7 @@ { name: 'AgreementBookNo', label: '履约协议编号', hidden: false, sortable: false, align: 'left', width: '90' }, { name: 'AgreementBookType', label: '履约协议类型', hidden: false, sortable: false, align: 'left', width: '90' }, { name: 'MarginOptionType', label: '追保方向', hidden: false, sortable: false, align: 'left', width: '90' }, + { name: 'MarginWatchRule', label: '追保规则', hidden: false, sortable: false, align: 'left', width: '90' }, { name: 'MainProtocolCode', label: '主协议编号', hidden: false, sortable: false, align: 'left', width: '150' }, { name: 'SupProtocolCode', label: '补充协议编号', hidden: false, sortable: false, align: 'left', width: '150' }, { name: 'ProductName', label: '产品名称', hidden: false, sortable: false, align: 'left', width: '150' }, diff --git a/YLErpWeb/Controllers/margin_template_v2Controller.cs b/YLErpWeb/Controllers/margin_template_v2Controller.cs index b540be3c..9f2cc888 100644 --- a/YLErpWeb/Controllers/margin_template_v2Controller.cs +++ b/YLErpWeb/Controllers/margin_template_v2Controller.cs @@ -299,12 +299,69 @@ namespace YLErp.Web.Controllers } } + //区间追保结构 + 按资产类型分类时,期限档仅允许"允许分期限档的资产类型"(本期仅利率债)配置,防止信用债等非分档类型误配期限档; + //该校验按行生效,与明细行数无关(单行明细同样拦截); + //按严格掩码判定:行标的类型位必须全部落在可分档类型内(混合标志位如 利率债|信用债 配期限档同样拦截,与取数侧整行期限档过滤语义一致); + //UnderlyingType 为空的通配行允许配期限档(取数侧期限档过滤在前、通配匹配在后,语义自洽) + if (marginTemplate.RuleType == (int)MarginRuleTypeEnum.区间追保结构 + && marginTemplate.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.CustomInstrumentType) + { + var enabledMask = ConsMarginTerm.TermTierEnabledUnderlyingTypes.Aggregate(UnderlyingTypeEnum.None, (a, t) => a | t); + foreach (var detail in marginTemplate.Details) + { + if (!string.IsNullOrEmpty(detail.SpanConfig?.BondTerm) + && (detail.UnderlyingType & ~enabledMask) != UnderlyingTypeEnum.None) + { + throw new Exception("标的类型不允许配置期限档:" + UnderlyingTypeUtil.GetDesc(detail.UnderlyingType)); + } + } + } + + //区间追保结构:客户原型全量手工录入的预警线/平仓线与多空各4层追保区间校验。 + //预警线/平仓线/两层区间全部为空视为未配置(兼容存量 x/y 过渡),任一值存在即按规则校验: + //数值非负;同层上下边界都有时上边界>下边界;单层部分填写报错提示完整填写 + //(第1层边界特殊:多头第1层只需下边界Lower、空头第1层只需上边界Upper); + //多头各层下边界逐层严格递减、空头各层上边界逐层严格递增(填了才比)。 + if (marginTemplate.RuleType == (int)MarginRuleTypeEnum.区间追保结构) + { + for (var i = 0; i < marginTemplate.Details.Count; i++) + { + var cfg = marginTemplate.Details[i].SpanConfig; + if (cfg == null) + { + continue; + } + var longSpans = cfg.LongSpans ?? new List(); + var shortSpans = cfg.ShortSpans ?? new List(); + var hasAnyValue = cfg.WarnLine.HasValue || cfg.CloseLine.HasValue + || longSpans.Any(t => t != null && (t.Lower.HasValue || t.Upper.HasValue || t.AmountRate.HasValue)) + || shortSpans.Any(t => t != null && (t.Lower.HasValue || t.Upper.HasValue || t.AmountRate.HasValue)); + if (!hasAnyValue) + { + continue; + } + var label = "参数组" + (i + 1); + //区块化结构下利率债区块占连续4行(固定4档),报错带上期限档便于定位 + if (!string.IsNullOrEmpty(cfg.BondTerm)) + { + label += "(利率债期限档 " + cfg.BondTerm + ")"; + } + if (cfg.WarnLine < 0 || cfg.CloseLine < 0) + { + throw new Exception(label + ":预警线/平仓线不能为负数"); + } + ValidateSpanTiers(longSpans, true, label); + ValidateSpanTiers(shortSpans, false, label); + } + } + if (marginTemplate.Details.Count() > 1) { if (marginTemplate.RuleType == (int)MarginRuleTypeEnum.区间追保结构) { - //区间追保结构:按 利率债期限档(SpanConfig.BondTerm)分组校验,同一期限档内标的类型不允许重复,不同期限档允许相同标的类型 - foreach (var termGroup in marginTemplate.Details.GroupBy(x => x.SpanConfig?.BondTerm ?? "")) + //区间追保结构:按 (利率债期限档, ETF细分) 元组分组校验(EtfKind 为预留扩展键,本期无 UI 写入,等价于仅按期限档分组), + //同一分组内标的类型不允许重复,不同分组允许相同标的类型 + foreach (var termGroup in marginTemplate.Details.GroupBy(x => (x.SpanConfig?.BondTerm ?? "", x.SpanConfig?.EtfKind ?? ""))) { var termUnderlyingType = UnderlyingTypeEnum.None; foreach (var detail in termGroup) @@ -432,6 +489,61 @@ namespace YLErp.Web.Controllers } } + /// + /// 规则15 区间追保结构单方向(多头/空头)4层追保区间校验,报错文案风格与"存在重复的标的类型:xxx"一致。 + /// isLong=true 多头:第1层只需下边界 Lower(上不封顶),各层 Lower 逐层严格递减; + /// isLong=false 空头:第1层只需上边界 Upper(下不设限),各层 Upper 逐层严格递增。 + /// + private static void ValidateSpanTiers(List tiers, bool isLong, string label) + { + var direction = isLong ? "多头方向" : "空头方向"; + double? prevBoundary = null; + for (var n = 0; n < tiers.Count; n++) + { + var tier = tiers[n]; + if (tier == null) + { + continue; + } + var tierLabel = label + ":" + direction + "第" + (n + 1) + "层"; + if (tier.Lower < 0 || tier.Upper < 0 || tier.AmountRate < 0) + { + throw new Exception(tierLabel + "追保区间不能为负数"); + } + var hasAny = tier.Lower.HasValue || tier.Upper.HasValue || tier.AmountRate.HasValue; + if (!hasAny) + { + continue; + } + if (tier.Lower.HasValue && tier.Upper.HasValue && tier.Upper <= tier.Lower) + { + throw new Exception(tierLabel + "追保区间上边界必须大于下边界"); + } + //第1层边界特殊:多头只需 Lower、空头只需 Upper;其余层 Lower/Upper/AmountRate 需完整填写 + if (n == 0) + { + if ((isLong && !tier.Lower.HasValue) || (!isLong && !tier.Upper.HasValue) || !tier.AmountRate.HasValue) + { + throw new Exception(tierLabel + "请完整填写追保价格区间与追保金额"); + } + } + else if (!tier.Lower.HasValue || !tier.Upper.HasValue || !tier.AmountRate.HasValue) + { + throw new Exception(tierLabel + "请完整填写追保价格区间与追保金额"); + } + //逐层单调性:多头 Lower 逐层严格递减、空头 Upper 逐层严格递增(填了才比) + var boundary = isLong ? tier.Lower : tier.Upper; + if (boundary.HasValue) + { + if (prevBoundary.HasValue && (isLong ? boundary >= prevBoundary : boundary <= prevBoundary)) + { + throw new Exception(tierLabel + (isLong ? "追保区间下边界须小于上一层的下边界" : "追保区间上边界须大于上一层的上边界")); + } + prevBoundary = boundary; + } + } + } + [HttpPost] public JsonResult deleteEditmargin_template_v2(string enid) { diff --git a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml index 25de064a..f1ce1c96 100644 --- a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml +++ b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml @@ -20,8 +20,10 @@ @section JS{ @@ -154,8 +156,118 @@
-