Merge remote-tracking branch 'dest/glms/feature/1.4.2' into test

This commit is contained in:
lisong
2026-08-27 15:20:46 +08:00
86 changed files with 2863 additions and 599 deletions
@@ -24,5 +24,25 @@ namespace YLErp.DBModels
{
return fundTag == Credit ? Credit : Cash;
}
/// <summary>
/// 腿是否按授信分配(§2.3 情形1 回退口径):腿上显式选择优先(Credit=授信、Cash=现金);
/// 腿未选(默认)时取交易级资金来源(trade.margin_fund_source,必填默认现金)——授信→按授信分配
/// (额度不足拆单),现金→现金。交易级空值按现金(存量防御,SaveTrade 已归一)。
/// 标签定稿(ApplyMarginFundTags)与簿记资金校验(RealtimePnlCalc.TradeCanBeConfirm
/// 共用本口径,保证校验与定稿一致。
/// </summary>
public static bool PreferCredit(string legFundTag, string tradeFundSource)
{
if (legFundTag == Credit)
{
return true;
}
if (legFundTag == Cash)
{
return false;
}
return string.IsNullOrEmpty(legFundTag) && tradeFundSource == Credit;
}
}
}
+82
View File
@@ -220,4 +220,86 @@ namespace YLErp.DBModels
/// </summary>
public decimal MarginInterestLoss { get; set; }
}
/// <summary>
/// EQD-7084 新“框架合约”Tab 的新增字段及拆分后的估值口径。
/// 该模型不映射数据库,仅由新查询接口计算返回。
/// </summary>
public class EodSwapRiskNewFields
{
/// <summary>
/// 浮动收益端标的类型,仅供前端按债券/非债券选择期初价格精度使用,
/// 不参与任何收益或估值计算。
/// </summary>
public string UnderlyingInstrumentType { get; set; }
/// <summary>浮动收益端多空方向。</summary>
public string UnderlyingDirection { get; set; }
/// <summary>浮动收益端标的代码。</summary>
public string UnderlyingCode { get; set; }
/// <summary>期初标的价格;债券按百分价格展示。</summary>
public decimal? InitialPrice { get; set; }
/// <summary>名义数量,取合约名义本金。</summary>
public decimal NotionalQuantity { get; set; }
/// <summary>合约起始日。</summary>
public DateTime? ContractStartDate { get; set; }
/// <summary>合约到期日。</summary>
public DateTime? ContractMaturityDate { get; set; }
/// <summary>利息端基准:FR007 或固定利率。</summary>
public string InterestBenchmark { get; set; }
/// <summary>普通利息腿当前交易日适用利率合计。</summary>
public decimal InterestRatePrice { get; set; }
/// <summary>
/// 开平仓费用。日终腿已按我方收益方向归一:我方支付为负、我方收取为正;
/// 新 Tab 单独展示该金额,但估值中仍须计入一次。
/// </summary>
public decimal OpeningClosingFee { get; set; }
/// <summary>
/// 不含开平仓费用的浮动端待实现收益,来源为日终浮动腿的 PosiMtmPnL;
/// 不可再由旧口径的 PosiProfitSum 反推,避免把费用重新混入本列。
/// </summary>
public decimal FloatingUnrealizedPnl { get; set; }
/// <summary>
/// 排除初始/维持保证金腿后的普通利息端待实现收益。保证金利息保留在其独立两列,
/// 且只通过 MarginInterestAmount 参与估值,以满足“利息端仅展示利息端盈亏”的新口径。
/// </summary>
public decimal OrdinaryInterestPnl { get; set; }
/// <summary>
/// 保证金利息净额,仅供两种合约估值维持旧总额;前端不直接绑定该字段,
/// 以防它再次落入“合约利息端待实现收益”。
/// </summary>
public decimal MarginInterestAmount { get; set; }
/// <summary>收取对手方保证金利息。</summary>
public decimal MarginInterestGain { get; set; }
/// <summary>支付对手方保证金利息。</summary>
public decimal MarginInterestLoss { get; set; }
/// <summary>到期轧差口径估值;仅 DividendPayDate=0 时有值,且包含期间付息/分红。</summary>
public decimal? MaturityNettingValuation { get; set; }
/// <summary>期间支付派息口径估值;仅 DividendPayDate 非 0 时有值,不重复计入期间付息/分红。</summary>
public decimal? PeriodPaymentValuation { get; set; }
}
/// <summary>
/// EQD-7084 新“框架合约”Tab 响应。继承旧响应以保持原有列字段完全一致,
/// 新接口只额外序列化新增字段。
/// </summary>
public class EodSwapRiskNewResponse : EodSwapResponse
{
public EodSwapRiskNewFields NewFields { get; set; }
}
}
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace YLErp.DBModels
{
public enum PushStateEnum
{
= 0,
= 1,
= 2
}
/// <summary>
/// 外发推送失败状态。只记录定位信息,不保存报文。
/// </summary>
[Table("push_status")]
public class PushStatus
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long id { get; set; }
[Column("value_date")]
public DateTime ValueDate { get; set; }
[Column("push_type")]
public int PushType { get; set; }
[Column("record_id")]
public long RecordId { get; set; }
[Column("state")]
public PushStateEnum State { get; set; }
[Column("retry_count")]
public int RetryCount { get; set; }
[Column("last_error")]
public string LastError { get; set; }
[Column("push_time")]
public DateTime? PushTime { get; set; }
[Column("create_time")]
public DateTime CreateTime { get; set; }
[Column("update_time")]
public DateTime? UpdateTime { get; set; }
}
}
@@ -404,13 +404,12 @@ namespace YLErp.DBModels
/// 期末标的结算收益率(EQD-6953 平仓)。普通债券类收益互换平仓时由债券计算器按
/// 期末标的交割全价反算(估值日=平仓日 ValueDate),允许手工覆盖。
/// 命名遵循《互换价格字段命名规范决策文档》时点维度:平仓/了结用 Exit(勿用 End/Close/Final)。
/// [NotMapped]:不落 swap_flow_event 表列;仅随 UnwindData 序列化进 swap_event.EventData JSON
/// 由平仓待复核回显(GetSwapEvent)与结算确认书 Excel(TradeSettleBillGenerator) 消费。
/// 直接保存到 swap_flow_event.ExitYtm,提前终止详情页和结算确认书均读取该次平仓浮动腿记录。
/// ⚠️ 存储口径为【展示态百分数】(如 6.3721 表示 6.3721%),与同页期末交割全价(展示态)一致,
/// 区别于录入页 trade.InitYtm 的存储态小数(0.063721)——两者载体不同、互不干扰,勿"顺手统一"。
/// 精度:确认书导出固定 4 位小数不去零(ToString("0.0000"));本字段保留 4 位(四舍五入)。
/// </summary>
[NotMapped]
[Column("ExitYtm", TypeName = "decimal(18,4)")]
public decimal? ExitYtm { get; set; }
}
@@ -677,6 +677,17 @@ namespace YLErp.DBModels
[TradeAuditExclude]
public string MarginTemplateName { get; set; }
/// <summary>
/// 保证金资金来源(收益互换,必填,默认现金):预付金腿未选资金标签(默认)时的定稿依据(§2.3 情形1)。
/// 值与 swap_position.fund_tag 同词表:Credit=优先授信(额度不足自动拆单)、Cash=现金。
/// 录入页必选(新交易默认 Cash),SaveTrade 对空值归一为 Cash(兜住 DMA 等绕过页面的链路);
/// 历史来源:旧版 trade.MarginTemplateName 曾以字典文本(授信保证金/现金保证金)承载该语义,模板V2迁移后由本列承接。
/// </summary>
[DisplayName("资金来源")]
[TradeAuditExclude]
[Column("margin_fund_source")]
public string MarginFundSource { get; set; }
/// <summary>
/// 预付金算法
/// </summary>
@@ -1,4 +1,5 @@
using Newtonsoft.Json.Linq;
using YLErp.BLL;
using YLErp.Core.Helpers;
using YLErp.DBModels;
using YLErp.DBModels.Consts;
@@ -7,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
{
@@ -244,6 +246,27 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
}
dic["主协议编号"] = mainProtocolCode ?? "";
// 主协议类型取客户开户维护的下拉选择(client_meta.MainProtocolType):1=NAFMII,其余按 SAC。
var mainProtocolType = Context.GetClientMeta(client.id, "MainProtocolType")?.MetaValue;
var isNafmii = mainProtocolType == "1";
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;
if (string.IsNullOrWhiteSpace(supProtocolCode))
@@ -315,9 +338,40 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
dic["初始保障金率"] = ((double)initRate * 100).ToString("N4");
dic["维持保障金率"] = ((double)maintainRate * 100).ToString("N4");
// 期初预付比例和金额
dic["期初预付比例"] = ((double)initRate * 100).ToString("0.##");
dic["期初预付金额"] = ((trade.OriginalStockEqvNotional ?? 0) * (double)initRate).ToString("N2");
// 期初预付/期初现金交换:预付=初始预付金腿合计(模板口径:可全部或部分授信替代),
// 现金=资金记录实付现金(ClientCashInCashOutAction=应付预付金且 Money<0 即客户期初实付;
// 授信部分不产生资金流水,平仓返还为正数、追加保证金是独立Action,均不落入该口径)
var notional = trade.OriginalStockEqvNotional ?? 0;
var initialLegs = swapPositions.Where(x => x.InterestMode == (int)InterestModeEnum.).ToList();
var initialLegIds = initialLegs.Select(x => x.id).ToList();
double cashPaid;
using (var db = new YLContext())
{
cashPaid = db.ClientCashInCashOut
.Where(x => x.TradeId == trade.id && x.Action == ClientCashInCashOut._应付预付金
&& x.ValidState != ConsGlobal.InValid && x.Money < 0
&& (x.Deal == 0 || initialLegIds.Contains(x.Deal)))
.Sum(x => x.Money) ?? 0;
}
var cashRatio = notional == 0 ? 0 : -cashPaid / notional * 100;
dic["期初现金交换比例"] = cashRatio.ToString("0.##");
dic["期初现金交换金额"] = (-cashPaid).ToString("N2");
// 期初预付比例和金额:有应付腿取腿合计(合约录入值),无应付腿(无预付金模板/合约维度盯市)走模板率兜底
var totalPayable = initialLegs
.Select(x => x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1m : -1m))
.Where(x => x > 0)
.Sum();
if (totalPayable > 0)
{
dic["期初预付比例"] = (notional == 0 ? 0 : (double)totalPayable / notional * 100).ToString("0.##");
dic["期初预付金额"] = ((double)totalPayable).ToString("N2");
}
else
{
dic["期初预付比例"] = ((double)initRate * 100).ToString("0.##");
dic["期初预付金额"] = (notional * (double)initRate).ToString("N2");
}
bool posiLong = IsCustomerLong(swapPosition);
var maintainRatePercent = (double)maintainRate * 100; // A(%)
@@ -441,9 +495,7 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
}
// 期初预付金利率(InterestMode == 初始预付金)
var initialMarginPosition = swapPositions
.Where(x => x.InterestMode == (int)InterestModeEnum.)
.FirstOrDefault();
var initialMarginPosition = initialLegs.FirstOrDefault();
dic["期初预付金利率"] = initialMarginPosition != null
? ((double)initialMarginPosition.InterestRateDefault * 100).ToString("N4")
: "0.0000";
@@ -507,18 +559,9 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
}
// 基本费率 = PosiTradingFeePending / 名义本金 * 100
var notional = trade.OriginalStockEqvNotional ?? 0;
var tradingFee = (double)swapPosition.PosiTradingFeePending;
var basicFeeRate = notional == 0 ? 0 : tradingFee / notional * 100;
dic["基本费率"] = basicFeeRate.ToString("0.####");
// 期初现金交换比例和金额(使用初始预付金数据)
dic["期初现金交换比例"] = initialMarginPosition != null
? ((double)initialMarginPosition.InterestRateDefault * 100).ToString("N4")
: "0.0000";
dic["期初现金交换金额"] = initialMarginPosition != null
? ((double)initialMarginPosition.InterestPrincipalFix).ToString("N2")
: "0.00";
}
else
{
@@ -542,7 +585,8 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
// 参考标的证券全称和参考标的名义份额(复用上方已声明的bond)
dic["参考标的证券全称"] = underlying != null
? (JsonHelper.Deserialize<UnderlyingBond>(underlying.ExJson)?.UnderlyingFullName ?? underlying.UnderlyingName)
// ? (JsonHelper.Deserialize<UnderlyingBond>(underlying.ExJson)?.UnderlyingFullName ?? underlying.UnderlyingName)
? (underlying.UnderlyingName ?? "")
: "";
dic["参考标的名义份额"] = swapPosition != null
? ((double)swapPosition.PosiQuantity).ToString("0.##")
@@ -74,6 +74,11 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
var underlying = Context.GetTradeUnderlying(flowEventGroup.UnderlyingCode);
var closeNotionalValue = unwindData?.CloseNotionalValue
?? flowEventGroup.Quantity * flowEventGroup.ContractSize * posi.PosiGrossPrice;
var settlementDate = flowEventGroup.UnwindDate
?? throw new ServiceException($"平仓事件{flowEventGroup.id}缺少结算日");
var currentDayFloatingDividend = Context.GetEodPositions(tradeId, settlementDate)
.FirstOrDefault(x => x.PositionId == flowEventGroup.PositionId)
?.TdPosiDividend ?? 0m;
// 行构造器统一处理客户视角、结算公式、品种差异和模板展示精度。
var row = SwapSettlementBillRowBuilder.Build(new SwapSettlementBillRowInput
@@ -86,8 +91,9 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
Positions = positions,
UnderlyingInstrumentType = underlying?.UnderlyingInstrumentType,
CloseNotionalValue = closeNotionalValue,
ExitYtm = unwindData?.FlowEvents?
.FirstOrDefault(x => x.PositionType > 0)?.ExitYtm,
CurrentDayFloatingDividend = currentDayFloatingDividend,
// 与提前终止详情页保持同一来源:读取 swap_flow_event 中的平仓浮动腿记录。
ExitYtm = flowEventGroup.ExitYtm,
IncludePeriodPaymentInNetting = (tradeExtend?.ExtendObj?.DividendPayDate ?? 1) == 0
});
table.Add(row);
@@ -0,0 +1,47 @@
using System;
using System.IO;
namespace YLErp.Modules.EodModule
{
[TestClass]
public class BondPaymentListQueryBoundaryTest
{
[TestMethod]
public void SearchList_OnlyQueriesBondPaymentTable()
{
var source = ReadBondPaymentServiceSource();
var searchList = ExtractMethod(source, "public SearchListResult<BondPaymentDto> SearchList", "public BondPayment SaveBondPayment");
Assert.IsFalse(
searchList.Contains("ex_dividend_info", StringComparison.Ordinal),
"债券付息列表只能查询 bond_payment_info,不能把公司行为除权表拼入展示结果。");
StringAssert.Contains(searchList, "DbContext.bondPayment");
}
private static string ExtractMethod(string source, string startMarker, string endMarker)
{
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.IsTrue(start >= 0, $"Could not find method: {startMarker}");
var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
Assert.IsTrue(end >= 0, $"Could not find method end: {endMarker}");
return source.Substring(start, end - start);
}
private static string ReadBondPaymentServiceSource()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null)
{
var path = Path.Combine(directory.FullName, "YLErpDAL", "Modules", "EodModule", "BondPaymentService.cs");
if (File.Exists(path))
{
return File.ReadAllText(path);
}
directory = directory.Parent;
}
Assert.Fail("Could not locate BondPaymentService.cs from the test output directory.");
return string.Empty;
}
}
}
@@ -66,5 +66,23 @@ namespace YLErp.Modules.EodModule
Assert.AreEqual(204000m, actual);
}
[TestMethod]
public void CalcPayment_CorporateActionUsesPreCorporateActionQuantity()
{
var payments = new List<BondPayment>
{
new BondPayment { payment_interest = 2m },
new BondPayment { payment_interest = 10m, IsCorporateActionCashDividend = true }
};
var method = typeof(BondPaymentService).GetMethod(
nameof(BondPaymentService.CalcPayment),
new[] { typeof(List<BondPayment>), typeof(decimal), typeof(decimal), typeof(decimal), typeof(decimal?) });
Assert.IsNotNull(method, "公司行为现金分红需要支持单独传入除权前数量。");
var actual = (decimal)method.Invoke(CreateService(), new object[] { payments, 2000m, 1m, 1m, (decimal?)1000m });
Assert.AreEqual(1040m, actual, "原生付息按当前 2000 份计算为 40,公司行为分红按除权前 1000 份计算为 1000。");
}
}
}
@@ -0,0 +1,59 @@
using System;
using System.IO;
namespace YLErp.Modules.EodModule
{
[TestClass]
public class DividendInfoEditGuardMessageTest
{
[TestMethod]
public void AddDividendInfos_ReportsTheReferencingTradeNumberWhenAnExecutedActionIsEdited()
{
var source = ReadDividendServiceSource();
var addDividendInfos = ExtractMethod(source, "public bool AddDividendInfos", "public bool checkDividendInfoExecuteStatus");
var controllerSource = ReadExDividendInfoControllerSource();
var deleteDividend = ExtractMethod(controllerSource, "public JsonResult deleteDividend", "public JsonResult ImportDividendInfo");
Assert.IsTrue(addDividendInfos.Contains("不可修改,有交易【", StringComparison.Ordinal));
Assert.IsTrue(addDividendInfos.Contains("使用了该条除权除息数据", StringComparison.Ordinal));
Assert.IsTrue(deleteDividend.Contains("不可修改,有交易【", StringComparison.Ordinal));
Assert.IsTrue(deleteDividend.Contains("使用了该条除权除息数据", StringComparison.Ordinal));
}
private static string ExtractMethod(string source, string startMarker, string endMarker)
{
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.IsTrue(start >= 0, $"Could not find method: {startMarker}");
var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
Assert.IsTrue(end >= 0, $"Could not find method end: {endMarker}");
return source.Substring(start, end - start);
}
private static string ReadDividendServiceSource()
{
return ReadSource("YLErpDAL", "Modules", "TradeModule", "DealModule", "DividendService.cs");
}
private static string ReadExDividendInfoControllerSource()
{
return ReadSource("YLErpWeb", "Controllers", "ex_dividend_infoController.cs");
}
private static string ReadSource(params string[] relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null)
{
var path = Path.Combine(new[] { directory.FullName }.Concat(relativePath).ToArray());
if (File.Exists(path))
{
return File.ReadAllText(path);
}
directory = directory.Parent;
}
Assert.Fail("Could not locate DividendService.cs from the test output directory.");
return string.Empty;
}
}
}
@@ -0,0 +1,292 @@
using YLErp.Abstract;
using YLErp.Helpers;
using YLErp.Modules.EodModule;
namespace YLErp.Modules.EodModuleTests
{
[TestClass]
public class TrsContractKafkaPushServiceTest
{
[TestMethod]
public void Push_空日快照_发送一条空消息并使用业务日期作为Key()
{
var valueDate = new DateTime(2026, 8, 24);
var producer = new RecordingKafkaProducer();
var service = CreateService(producer, valueDate);
service.Push(valueDate);
Assert.AreEqual(1, producer.Messages.Count);
Assert.AreEqual("onederiv.trs.contract.v1", producer.Messages[0].Topic);
Assert.AreEqual("2026-08-24", producer.Messages[0].Key);
var payload = JsonHelper.Deserialize<TrsContractSnapshot>(producer.Messages[0].Message);
Assert.AreEqual("2026-08-24", payload.ValueDate);
Assert.AreEqual(0, payload.ContractCount);
Assert.AreEqual(0, payload.Contracts.Count);
}
[TestMethod]
public void Push_区间内每天分别调用_每个日期各发送一条快照()
{
var valueDates = new[]
{
new DateTime(2026, 8, 20),
new DateTime(2026, 8, 21),
new DateTime(2026, 8, 24)
};
var producer = new RecordingKafkaProducer();
var service = new TestableTrsContractKafkaPushService(producer, valueDates.ToDictionary(x => x, CreateEmptySnapshot));
foreach (var valueDate in valueDates)
{
service.Push(valueDate);
}
CollectionAssert.AreEqual(
new[] { "2026-08-20", "2026-08-21", "2026-08-24" },
producer.Messages.Select(x => x.Key).ToArray());
CollectionAssert.AreEqual(
new[] { "2026-08-20", "2026-08-21", "2026-08-24" },
producer.Messages.Select(x => JsonHelper.Deserialize<TrsContractSnapshot>(x.Message).ValueDate).ToArray());
}
[TestMethod]
public void Push_首次失败后成功_停止重试且不记录最终失败()
{
var valueDate = new DateTime(2026, 8, 24);
var producer = new RecordingKafkaProducer { FailuresBeforeSuccess = 1 };
var service = CreateService(producer, valueDate);
service.Push(valueDate);
Assert.AreEqual(2, producer.AttemptCount);
Assert.AreEqual(1, producer.Messages.Count);
Assert.AreEqual(0, service.FailureRecords.Count);
}
[TestMethod]
public void Push_连续失败三次_记录最终失败和三次尝试()
{
var valueDate = new DateTime(2026, 8, 24);
var producer = new RecordingKafkaProducer { FailuresBeforeSuccess = int.MaxValue };
var service = CreateService(producer, valueDate);
service.Push(valueDate);
Assert.AreEqual(3, producer.AttemptCount);
Assert.AreEqual(0, producer.Messages.Count);
Assert.AreEqual(1, service.FailureRecords.Count);
Assert.AreEqual(valueDate, service.FailureRecords[0].ValueDate);
Assert.AreEqual(3, service.FailureRecords[0].RetryCount);
Assert.IsInstanceOfType(service.FailureRecords[0].Exception, typeof(InvalidOperationException));
}
[TestMethod]
public void BuildContract_字段使用日终快照和约定来源()
{
var valueDate = new DateTime(2026, 8, 24);
var eodSwap = new eod_swap
{
id = 10,
ValueDate = valueDate,
SwapTradeId = 7,
SwapTradeNo = "TRS-001",
BookId = 3,
ClientId = 8,
NotionalValue = 1000000m,
dv01 = 12.34m,
InitMarginGain = 100m,
InitMarginLoss = 0m
};
var trade = new trade
{
id = 7,
UnderlyingCode = "600000.SH",
UnderlyingAssetName = "浦发银行",
UnderlyingInstrumentType = "Stock",
StartDate = new DateTime(2026, 8, 1),
ExerciseDate = new DateTime(2027, 8, 1)
};
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 = 2 }
};
var swapPositions = new Dictionary<long, swap_position>
{
[101] = new() { id = 101, category_tag = "互换利率" },
[102] = new() { id = 102, category_tag = "互换利率" }
};
var item = TrsContractKafkaPushService.BuildContract(
eodSwap,
new Dictionary<int, trade> { [7] = trade },
positions,
swapPositions);
Assert.AreEqual("2026-08-24", item.TradeDate);
Assert.AreEqual(3, item.BookId);
Assert.AreEqual("TRS-001", item.SwapTradeNo);
Assert.AreEqual(8, item.ClientId);
Assert.AreEqual("600000.SH", item.UnderlyingCode);
Assert.AreEqual("浦发银行", item.UnderlyingName);
Assert.AreEqual("Stock", item.UnderlyingInstrumentType);
Assert.AreEqual(1000000m, item.NotionalValue);
Assert.AreEqual("2026-08-01", item.StartDate);
Assert.AreEqual("2027-08-01", item.MaturityDate);
Assert.AreEqual(12.34m, item.Dv01);
Assert.AreEqual(0.0123m, item.FixedRate);
Assert.AreEqual(2, item.InterestDirection);
Assert.AreEqual(1, item.FloatingDirection);
Assert.AreEqual(100m, item.InitMarginGain);
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(
producer,
new Dictionary<DateTime, TrsContractSnapshot> { [valueDate] = CreateEmptySnapshot(valueDate) });
}
private static TrsContractSnapshot CreateEmptySnapshot(DateTime valueDate)
{
return new TrsContractSnapshot
{
SchemaVersion = "v1",
ValueDate = valueDate.ToString("yyyy-MM-dd"),
PushTime = "2026-08-24 12:00:00",
ContractCount = 0,
Contracts = new List<TrsContractSnapshotItem>()
};
}
private sealed class TestableTrsContractKafkaPushService : TrsContractKafkaPushService
{
private readonly IReadOnlyDictionary<DateTime, TrsContractSnapshot> _snapshots;
public List<(DateTime ValueDate, int RetryCount, Exception Exception)> FailureRecords { get; } = new();
public TestableTrsContractKafkaPushService(IKafkaProduce producer, IReadOnlyDictionary<DateTime, TrsContractSnapshot> snapshots)
: base(new YLContext(), producer, "onederiv.trs.contract.v1")
{
_snapshots = snapshots;
}
protected override TrsContractSnapshot BuildSnapshot(DateTime valueDate)
{
return _snapshots[valueDate];
}
protected override void RecordFailures(DateTime valueDate, int retryCount, Exception exception)
{
FailureRecords.Add((valueDate, retryCount, exception));
}
}
private sealed class RecordingKafkaProducer : IKafkaProduce
{
public int FailuresBeforeSuccess { get; set; }
public int AttemptCount { get; private set; }
public List<(string Topic, string Key, string Message)> Messages { get; } = new();
public void Produce(string topic, string message)
{
throw new NotSupportedException();
}
public void Produce(string topic, string key, string message)
{
AttemptCount++;
if (AttemptCount <= FailuresBeforeSuccess)
{
throw new InvalidOperationException("Kafka unavailable");
}
Messages.Add((topic, key, message));
}
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.DBModels;
@@ -20,6 +21,7 @@ namespace YLErp.Modules.SwapModule
public void RegistrationSnapshot_IsPending_AndKeepsBeforeFields()
{
var info = CreateAction(77, ConsGlobal.InstrumentType.Stock);
info.GiveShareAmount = 1m;
var before = CreateEodPosition(9, info.UnderlyingCode, 1000m, 100m);
var snapshot = SwapEodPositionService.BuildCorporateActionEventData(
@@ -37,8 +39,9 @@ namespace YLErp.Modules.SwapModule
Assert.IsFalse(snapshot.Applied);
var reason = SwapEventService.BuildCorporateActionEventReason(snapshot);
StringAssert.Contains(reason, "BeforeQuantity=1000");
StringAssert.Contains(reason, "AfterQuantity=0");
StringAssert.Contains(reason, "股权登记日:2026-08-14 发生公司行为(送股)");
StringAssert.Contains(reason, "调整前:名义本金:100000 期初标的价格:100 持仓数量:1000");
StringAssert.Contains(reason, "调整后:名义本金:0 期初标的价格:0 持仓数量:0");
}
[TestMethod]
@@ -65,6 +68,23 @@ namespace YLErp.Modules.SwapModule
Assert.IsFalse(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.TBonds));
}
[TestMethod]
public void CorporateActionReason_ShowsOneActionTypeOrActualCashDividend()
{
AssertActionDescription(
new CorporateActionEventData { ExDividendDate = RecordDate, RationedSharesAmount = 1m, GiveShareAmount = 1m, Split = 2m, GiveCashAmount = 10m, CashFlowChange = 1000m },
"发生公司行为(配股)");
AssertActionDescription(
new CorporateActionEventData { ExDividendDate = RecordDate, GiveShareAmount = 1m, Split = 2m, GiveCashAmount = 10m, CashFlowChange = 1000m },
"发生公司行为(送股)");
AssertActionDescription(
new CorporateActionEventData { ExDividendDate = RecordDate, Split = 2m, GiveCashAmount = 10m, CashFlowChange = 1000m },
"发生公司行为(拆分)");
AssertActionDescription(
new CorporateActionEventData { ExDividendDate = RecordDate, GiveCashAmount = 11m, CashFlowChange = 220000m },
"发生公司行为(产生分红:220000)");
}
[TestMethod]
public void Rerun_DoesNotCreateDuplicateCorporateActionEvent()
{
@@ -113,7 +133,7 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
public void OperationHistory_PreservesPendingCorporateActionForAudit()
public void OperationHistory_HidesPendingCorporateActionUntilItIsApplied()
{
var info = CreateAction(80, ConsGlobal.InstrumentType.Stock);
var pendingData = SwapEodPositionService.BuildCorporateActionEventData(
@@ -133,12 +153,17 @@ namespace YLErp.Modules.SwapModule
new swap_event { id = 3, EventType = (int)SwapEventTypeEnum., EventData = "{}" }
};
// 操作历史不再隐藏登记日待生效事件;Applied=false 是事件状态,不是展示过滤条件。
Assert.AreEqual(3, events.Count);
Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[0], out var pendingSnapshot));
Assert.IsFalse(pendingSnapshot.Applied);
Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[1], out var appliedSnapshot));
Assert.IsTrue(appliedSnapshot.Applied);
var filter = typeof(SwapEventService).GetMethod(
"FilterOperationHistory",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(filter, "操作历史必须过滤登记日创建的待生效公司行为事件。");
var visibleEvents = (List<swap_event>)filter.Invoke(null, new object[] { events });
Assert.AreEqual(2, visibleEvents.Count);
Assert.IsFalse(visibleEvents.Any(x => x.id == 1));
Assert.IsTrue(visibleEvents.Any(x => x.id == 2));
Assert.IsTrue(visibleEvents.Any(x => x.id == 3));
}
[TestMethod]
@@ -283,6 +308,11 @@ namespace YLErp.Modules.SwapModule
};
}
private static void AssertActionDescription(CorporateActionEventData data, string expected)
{
StringAssert.Contains(SwapEventService.BuildCorporateActionEventReason(data), expected);
}
private static eod_swap_position CreateEodPosition(long positionId, string code, decimal quantity, decimal price)
{
return new eod_swap_position
@@ -22,14 +22,14 @@ namespace YLErp.Modules.SwapModule
private const decimal RegPer100 = 1.0m; // 每 100 元面值票息 1.0 → qty(1000) 时单期分红 = 1.0×1000/100 = 10
private static readonly DateTime StartDate = new(2026, 1, 5);
#region reg_date GetBondPayments
#region payment_date GetBondPayments
private const string BondUnderlying = "210210.IB";
private static List<BondPayment> BondPayments() => new List<BondPayment>
{
// 登记日 1/6、1/7 各一期;支付日滞后若干日(刻意与登记日不同,验证按 reg_date 而非 pay_date 计提)
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 6), payment_date_pl = new DateTime(2026, 1, 9), payment_date = new DateTime(2026, 1, 9), payment_interest = RegPer100 },
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 7), payment_date_pl = new DateTime(2026, 1, 10), payment_date = new DateTime(2026, 1, 10), payment_interest = RegPer100 },
// 付息日 1/6、1/7 各一期
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 5), payment_date_pl = new DateTime(2026, 1, 6), payment_date = new DateTime(2026, 1, 6), payment_interest = RegPer100 },
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 6), payment_date_pl = new DateTime(2026, 1, 7), payment_date = new DateTime(2026, 1, 7), payment_interest = RegPer100 },
};
#endregion
@@ -51,7 +51,7 @@ namespace YLErp.Modules.SwapModule
=> _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate);
}
/// <summary>真实 BondPaymentServicereg_date 口径)seam:仅注入内存 BondPayment 数据,票息计算走生产 GetBondPayments+CalcPayment。</summary>
/// <summary>真实 BondPaymentServicepayment_date 口径)seam:仅注入内存 BondPayment 数据,票息计算走生产 GetBondPayments+CalcPayment。</summary>
private sealed class RealBondPaymentService : BondPaymentService
{
private readonly List<BondPayment> _data;
@@ -60,14 +60,14 @@ namespace YLErp.Modules.SwapModule
=> _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
/// <summary>SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPositionCalcBondPayment 桥接真实 BondPaymentServicereg_date 口径,不再用线性假公式)。</summary>
/// <summary>SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPositionCalcBondPayment 桥接真实 BondPaymentServicepayment_date 口径)。</summary>
private sealed class EodSvcStub : TestableSwapEodPositionService
{
private readonly List<BondPayment> _bondPayments;
public EodSvcStub(List<BondPayment> bondPayments) : base(nameof(DividendEodNoDoubleCountTest)) { _bondPayments = bondPayments; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
// 桥接真实生产口径:GetBondPayments 按 reg_date 过滤 + CalcPayment 累加(替换原线性假公式 DailyRatePerUnit*days*qty
// 桥接真实生产口径:GetBondPayments 按 payment_date 过滤 + CalcPayment 累加
var svc = new RealBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser);
return svc.CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
}
@@ -147,9 +147,9 @@ namespace YLErp.Modules.SwapModule
/// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。
/// 验证:不重复(EOD TdCloseDividend 扣 DividendIn+ 不丢失(当日新计进 PosiDividendSum+ 守恒。
///
/// 序列(StartDate=1/5reg_date 1/6、1/7 各一期,每期 = qty×per100/100 = 10):
/// D1=1/6 无事件 Copy:窗口(1/5,1/6] 命中 reg_date 1/6 → TdPosiDividend=10PosiDividendSum=10
/// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_eventEOD 窗口(1/6,1/7] 命中 reg_date 1/7 → 新计 10 - 实现 10 → PosiDividendSum=10
/// 序列(StartDate=1/5payment_date 1/6、1/7 各一期,每期 = qty×per100/100 = 10):
/// D1=1/6 无事件 Copy:窗口(1/5,1/6] 命中 payment_date 1/6 → TdPosiDividend=10PosiDividendSum=10
/// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_eventEOD 窗口(1/6,1/7] 命中 payment_date 1/7 → 新计 10 - 实现 10 → PosiDividendSum=10
/// 守恒:全程新计(10+10) - 全程实现(10) = 末尾 PosiDividendSum(10)
/// </summary>
[TestMethod]
@@ -196,16 +196,10 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红
/// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。
///
/// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1=T日前待实现,正确不含登记日当日 reg_date 1/7 的分红);
/// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日 reg_date 1/7+ PosiDividendSum=0。
/// 即登记日当日分红(reg_date 1/7 的 10)既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。
/// 应得 = T日前待实现累计(r1.PosiDividendSum,仅含 1/6 那期 10);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。
/// 付息日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有付息日当日的分红
/// </summary>
[TestMethod]
public void _按交易场所规定不享有当日分红()
public void _按交易场所规定不享有当日分红()
{
var eodSvc = new EodSvcStub(BondPayments());
var td = CreateTrade();
@@ -231,30 +225,21 @@ namespace YLErp.Modules.SwapModule
// D2=1/7 EODUpdateEodPosition,全平→PosiQuantity=0
var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List<swap_flow_event> { closeEvent });
// 业务规定:登记日当日全平(盘中平仓→收盘持仓为 0),按各交易场所规定不享有登记日当日的分红
// (股权登记日以收盘在册为准)。故应得 = T日(登记日)之前的待实现累计 = r1.PosiDividendSum(不含登记日当日)。
// 系统行为正确:①DividendIn 读 T-1(=T日前待实现,正确不含当日);②EOD 全平 PosiQuantity=0 不计提当日。
// 即登记日当日分红既不进 DividendIn 也不进 PosiDividendSum = 正确不享有。
decimal expectedTotal = r1.PosiDividendSum; // 应得 = T日前待实现(不含登记日当日,因全平不享有)
decimal expectedTotal = r1.PosiDividendSum;
decimal actualGot = dividendIn + r2.PosiDividendSum;
Console.WriteLine($"[登记日全平] 应得(T日前待实现)={expectedTotal}, 实拿(DividendIn+PosiDividendSum)={actualGot}");
Console.WriteLine($"[登记日全平] DividendIn={dividendIn}, EOD:TdPosiDividend={r2.TdPosiDividend} PosiDividendSum={r2.PosiDividendSum} PosiQuantity={r2.PosiQuantity}");
Console.WriteLine($"[付息日全平] 应得(T日前待实现)={expectedTotal}, 实拿(DividendIn+PosiDividendSum)={actualGot}");
Console.WriteLine($"[付息日全平] DividendIn={dividendIn}, EOD:TdPosiDividend={r2.TdPosiDividend} PosiDividendSum={r2.PosiDividendSum} PosiQuantity={r2.PosiQuantity}");
// 断言:实拿 = 应得(登记日全平不享有当日,符合交易场所规定)
AssertDecimalEqual(expectedTotal, actualGot, 0.01m,
$"实拿应=应得(T日前待实现{expectedTotal})登记日全平不享有当日分红(符合交易场所规定)");
AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "登记日全平 EOD 不计提当日(PosiQuantity=0,正确)");
$"实拿应=应得(T日前待实现{expectedTotal})付息日全平不享有当日分红");
AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "付息日全平 EOD 不计提当日(PosiQuantity=0,正确)");
AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0");
}
/// <summary>
/// 【死代码删除的边界规格】脏数据(OriginalStockEqvNotional=null / PosiNetPrice=0)不得让
/// UpdateEodPosition 崩溃,且分红产出与正常数据完全一致。
/// 背景:这两个字段在 UpdateEodPosition 内的唯一消费点是历史遗留死代码
/// originNotional→totalPayment 全历史重算,结果从未被使用,2026-08 论证后删除)——
/// 删除前该脏数据会在 EOD 抛 InvalidOperationException/除零;删除后是设计内行为。
/// 本测试同时钉住:删除后输出等价(与同输入正常数据路径一致)。
/// </summary>
[TestMethod]
public void _死代码涉及字段_不影响EOD分红产出()
@@ -206,5 +206,106 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(ConsFundTag.Cash, settlements[1].Tag);
Assert.AreEqual(-300m, settlements[1].MarginAmount);
}
// ================================================================
// §2.3 情形1 交易级资金来源回退(ConsFundTag.PreferCredit):
// 腿上显式选择优先 > 交易级 margin_fund_source 回退 > 默认现金。
// 标签定稿(ApplyMarginFundTags)与资金校验(TradeCanBeConfirm)共用本口径。
// ================================================================
[TestMethod]
public void FT_031_腿选授信或现金_交易级字段不覆盖腿上显式选择()
{
Assert.IsTrue(ConsFundTag.PreferCredit(ConsFundTag.Credit, null));
Assert.IsTrue(ConsFundTag.PreferCredit(ConsFundTag.Credit, ConsFundTag.Cash));
Assert.IsFalse(ConsFundTag.PreferCredit(ConsFundTag.Cash, ConsFundTag.Credit));
Assert.IsFalse(ConsFundTag.PreferCredit(ConsFundTag.Cash, null));
}
[TestMethod]
public void FT_032_腿未选_按交易级资金来源回退()
{
Assert.IsTrue(ConsFundTag.PreferCredit(null, ConsFundTag.Credit));
Assert.IsTrue(ConsFundTag.PreferCredit("", ConsFundTag.Credit));
Assert.IsFalse(ConsFundTag.PreferCredit(null, ConsFundTag.Cash));
//交易级也未设置 → 默认现金
Assert.IsFalse(ConsFundTag.PreferCredit(null, null));
Assert.IsFalse(ConsFundTag.PreferCredit("", ""));
}
// ================================================================
// §2.3 保存前授信拆单(FundTagCalc.ApplySaveTimeSplit2026-08-26 业务确认:
// 保存检查授信→不足拦截确认→拆完再保存;原腿=可用额度标授信、新腿=现金差额)
// ================================================================
private static LegAmount MarginLeg(long id, decimal fix, bool preferCredit = true)
=> new()
{
Leg = new swap_position
{
id = id,
InterestDirection = 1,
InterestPrincipalFix = fix,
FundTag = preferCredit ? ConsFundTag.Credit : ConsFundTag.Cash
},
Amount = (double)fix,
PreferCredit = preferCredit
};
[TestMethod]
public void FT_033_保存前拆单_额度不足_原腿授信新腿现金差额守恒()
{
var legs = new List<LegAmount> { MarginLeg(101, 1000m) };
var plans = FundTagCalc.AllocateByLegPreference(legs, 300, ignoreMoneyCheck: false);
var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans);
Assert.AreEqual(1, newLegs.Count);
//原腿保留可用额度部分并标授信
Assert.AreEqual(300m, legs[0].Leg.InterestPrincipalFix);
Assert.AreEqual(ConsFundTag.Credit, legs[0].Leg.FundTag);
//新现金腿=差额,倒挤守恒
Assert.AreEqual(700m, newLegs[0].InterestPrincipalFix);
Assert.AreEqual(ConsFundTag.Cash, newLegs[0].FundTag);
Assert.AreEqual(0, newLegs[0].id);
Assert.IsNull(newLegs[0].Obervation);
}
[TestMethod]
public void FT_034_保存前拆单_可用授信为零_整腿定稿现金不拆()
{
var legs = new List<LegAmount> { MarginLeg(101, 1000m) };
var plans = FundTagCalc.AllocateByLegPreference(legs, 0, ignoreMoneyCheck: false);
var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans);
Assert.AreEqual(0, newLegs.Count);
Assert.AreEqual(1000m, legs[0].Leg.InterestPrincipalFix);
Assert.AreEqual(ConsFundTag.Cash, legs[0].Leg.FundTag);
}
[TestMethod]
public void FT_035_保存前拆单_额度充足_全额授信定稿不拆_现金腿不动()
{
var legs = new List<LegAmount> { MarginLeg(101, 1000m), MarginLeg(102, 500m, preferCredit: false) };
var plans = FundTagCalc.AllocateByLegPreference(legs, 5000, ignoreMoneyCheck: false);
var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans);
Assert.AreEqual(0, newLegs.Count);
Assert.AreEqual(ConsFundTag.Credit, legs[0].Leg.FundTag);
Assert.AreEqual(1000m, legs[0].Leg.InterestPrincipalFix);
Assert.AreEqual(ConsFundTag.Cash, legs[1].Leg.FundTag);
}
[TestMethod]
public void FT_036_保存前拆单_支付方向腿按方向比折算()
{
var leg = new swap_position { id = 101, InterestDirection = 2, InterestPrincipalFix = -1000m, FundTag = ConsFundTag.Credit };
var legs = new List<LegAmount> { new() { Leg = leg, Amount = 1000, PreferCredit = true } };
var plans = FundTagCalc.AllocateByLegPreference(legs, 300, ignoreMoneyCheck: false);
var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans);
//应付额 = fix × -1dir=2),授信部分 300 → fix = -300;现金差额倒挤 = -700
Assert.AreEqual(-300m, leg.InterestPrincipalFix);
Assert.AreEqual(-700m, newLegs[0].InterestPrincipalFix);
}
}
}
@@ -3,13 +3,10 @@ using YLErp.Modules.EodModule;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GLMS-20260105-0006 回归:债券 TRS 登记日当天手动平仓/互换,分红收益应为 36160 而非 0
/// 根因双成因
/// A. BondPaymentService.GetBondPayments 用支付日(pay_date_PL/pay_date_act)而非债权登记日(reg_date)判定谁享有票息
/// -> 登记日(4/3)当日 EOD 不计提,跨过支付日(4/6)才计提(巧合:4/4-4/5周末,下一交易日恰=支付日,掩盖缺陷)
/// B. SwapDealService.GetPreEodDividendSum 用 ValueDate 严格小于 dealDate 读 T-1 EOD 快照
/// -> 登记日当天手动平仓读不到当日 EOD,拿到 0
/// 本文件用手工合成内存数据(不连 96 库),通过 virtual seam 注入,真实跑生产日期逻辑。
/// 债券 TRS 付息日(payment_date)分红测试
/// 验证
/// A. BondPaymentService.GetBondPayments 用实际付息日(payment_date)判定付息归属
/// B. SwapDealService.GetPreEodDividendSum 读 EOD 快照
/// </summary>
[TestClass]
public class GLMS20260105_0006_RegisterDateDividendTest
@@ -21,12 +18,12 @@ namespace YLErp.Modules.SwapModule
private const decimal PaymentPer100 = 0.1808m;
private const decimal ExpectedDividend = 36_160m; // 20,000,000 × 0.1808 / 100
// 付息日历(截图):登记日 4/3,支付日 4/6
// 付息日历:登记日 4/3,支付日 4/6
private static readonly DateTime RegDate = new(2026, 4, 3);
private static readonly DateTime PayDate = new(2026, 4, 6);
private static readonly DateTime PreRegDate = new(2026, 4, 2);
private static readonly DateTime PrePayDate = new(2026, 4, 5);
// 多次付息日历(截图:债券 230004.IB,每期票息 0.1808,共 5 次登记日)
// 多次付息日历:债券 230004.IB,每期票息 0.1808,共 5 次付息日
private static readonly DateTime[] RegDates = {
new(2026, 2, 28), new(2026, 4, 3), new(2026, 4, 29),
new(2026, 5, 29), new(2026, 6, 29)
@@ -48,30 +45,28 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
public void CauseA_登记日当日EOD_应按登记日口径选中付息记录()
public void CauseA_付息日当日EOD_应按付息日口径选中付息记录()
{
var record = new BondPayment
{
underlyingCode = BondCode,
reg_date = RegDate, // 债权登记日 4/3(关键:分红归属按此判定)
reg_date = RegDate, // 债权登记日 4/3
payment_date_pl = PayDate, // 理论付息日 4/6
payment_date = PayDate, // 实际付息日 4/6
payment_interest = PaymentPer100
};
var svc = new TestableBondPaymentService(new List<BondPayment> { record });
// 登记日当日的 EOD 计提区间 (4/2, 4/3]
var payments = svc.GetBondPayments(BondCode, PreRegDate, RegDate);
// 付息日当日的 EOD 计提区间 (4/5, 4/6]
var payments = svc.GetBondPayments(BondCode, PrePayDate, PayDate);
// 修复前:用支付日(pay_date_PL=4/6)过滤 -> 4/6 不在 (4/2,4/3] -> 0(漏计分红)
// 修复后:用债权登记日(reg_date=4/3)过滤 -> 4/3 落在区间 -> 1 条(GLMS-20260105-0006 已修复)
// 支付日(payment_date=4/6)过滤 -> 4/6 落在区间 -> 1
Assert.AreEqual(1, payments.Count,
"登记日(4/3)当日 EOD 应按债权登记日(reg_date)选中该笔付息" +
"当前按支付日(pay_date_PL=4/6)过滤会漏选->0条,导致分红不计提。");
"付息日(4/6)当日 EOD 应按实际付息日(payment_date)选中该笔付息");
}
[TestMethod]
public void CauseA_MultiRegDate_跨登记日区间命中正确子集()
public void CauseA_MultiRegDate_跨付息日区间命中正确子集()
{
var records = Enumerable.Range(0, 5).Select(i => new BondPayment
{
@@ -83,25 +78,25 @@ namespace YLErp.Modules.SwapModule
}).ToList();
var svc = new TestableBondPaymentService(records);
// 单次窗口:每个登记日各自命中 1 条(验证按 reg_date 过滤,非支付日
// 单次窗口:每个付息日各自命中 1 条(验证按 payment_date 过滤)
for (int i = 0; i < 5; i++)
{
var prev = i == 0 ? RegDates[i].AddDays(-1) : RegDates[i - 1];
var hit = svc.GetBondPayments(BondCode, prev, RegDates[i]);
Assert.AreEqual(1, hit.Count, $"窗口({prev:yyyy-MM-dd},{RegDates[i]:yyyy-MM-dd}] 应仅命中登记日 {RegDates[i]:yyyy-MM-dd} 那条");
Assert.AreEqual(RegDates[i], hit[0].reg_date, "命中的应是该登记日记录");
var prev = i == 0 ? PayDates[i].AddDays(-1) : PayDates[i - 1];
var hit = svc.GetBondPayments(BondCode, prev, PayDates[i]);
Assert.AreEqual(1, hit.Count, $"窗口({prev:yyyy-MM-dd},{PayDates[i]:yyyy-MM-dd}] 应仅命中付息日 {PayDates[i]:yyyy-MM-dd} 那条");
Assert.AreEqual(PayDates[i], hit[0].payment_date, "命中的应是该付息日记录");
}
// 长区间应命中全部 5 条,不漏不混
var all = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]);
Assert.AreEqual(5, all.Count, "长区间(登记日1前,登记日5] 应命中全部 5 次付息");
var all = svc.GetBondPayments(BondCode, PayDates[0].AddDays(-1), PayDates[4]);
Assert.AreEqual(5, all.Count, "长区间(付息日1前,付息日5] 应命中全部 5 次付息");
// 跨登记日中间区间:(4/2, 4/29] 应命中 4/3 与 4/29 两条(不含 2/28、5/29、6/29
var mid = svc.GetBondPayments(BondCode, new DateTime(2026, 4, 2), new DateTime(2026, 4, 29));
Assert.AreEqual(2, mid.Count, "(4/2,4/29] 应命中 4/3+4/29 两条");
// 跨付息日中间区间:(4/5, 4/30] 应命中 4/6 与 4/30 两条(不含 3/2、6/1、6/30
var mid = svc.GetBondPayments(BondCode, new DateTime(2026, 4, 5), new DateTime(2026, 4, 30));
Assert.AreEqual(2, mid.Count, "(4/5,4/30] 应命中 4/6+4/30 两条");
CollectionAssert.AreEquivalent(
new[] { new DateTime(2026, 4, 3), new DateTime(2026, 4, 29) },
mid.Select(x => x.reg_date!.Value).ToArray());
new[] { new DateTime(2026, 4, 6), new DateTime(2026, 4, 30) },
mid.Select(x => x.payment_date!.Value).ToArray());
}
[TestMethod]
@@ -117,8 +112,8 @@ namespace YLErp.Modules.SwapModule
}).ToList();
var svc = new TestableBondPaymentService(records);
// 长区间取全部 5 期,CalcPayment 应累加 = 5 × 36160 = 180,800(原测试仅覆盖单期)
var payments = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]);
// 长区间取全部 5 期,CalcPayment 应累加 = 5 × 36160 = 180,800
var payments = svc.GetBondPayments(BondCode, PayDates[0].AddDays(-1), PayDates[4]);
var total = svc.CalcPayment(payments, Qty, 1, 1);
Assert.AreEqual(5 * ExpectedDividend, total, 0.01m,
"5 期票息累加应为 5 × 36,160 = 180,800;单期口径会漏计其余 4 期");
@@ -146,88 +141,83 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
public void CauseB_登记日当天手动平仓_应读到当日EOD分红36160()
public void CauseB_付息日当天手动平仓_应读到当日EOD分红36160()
{
// 4/2 EOD:累计分红 04/3 EOD登记日):累计分红 36160(即登记日应有的状态)
// 4/5 EOD:累计分红 04/6 EOD付息日):累计分红 36160
var eodSwaps = new List<eod_swap>
{
new eod_swap { SwapTradeId = TradeId, ValueDate = PreRegDate },
new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate }
new eod_swap { SwapTradeId = TradeId, ValueDate = PrePayDate },
new eod_swap { SwapTradeId = TradeId, ValueDate = PayDate }
};
var eodPositions = new List<eod_swap_position>
{
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = PreRegDate, PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = RegDate, PosiDividendSum = ExpectedDividend, PosiQuantity = Qty }
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = PrePayDate, PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = PayDate, PosiDividendSum = ExpectedDividend, PosiQuantity = Qty }
};
var svc = new TestableSwapDealService(eodSwaps, eodPositions);
// 登记日(4/3)当天手动平仓
var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, RegDate);
// 付息日(4/6)当天手动平仓
var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, PayDate);
// 修复前:ValueDate 严格小于 dealDate 读 T-1(4/2) -> 0(漏读当日分红)
// 修复后:ValueDate 小于等于 dealDate 读当日(4/3) -> 36160GLMS-20260105-0006 已修复)
Assert.AreEqual(ExpectedDividend, dividend, 0.01m,
"登记日(4/3)当天手动平仓应读到当日 EOD 累计分红 36,160" +
"当前 GetPreEodDividendSum 用 ValueDate < dealDate 读 T-1 快照->0。");
"付息日(4/6)当天手动平仓应读到当日 EOD 累计分红 36,160");
}
[TestMethod]
public void CauseB_MultiRegDate_Auto实现归0后下次登记日重新累加()
public void CauseB_MultiRegDate_Auto实现归0后下次付息日重新累加()
{
// 模拟:登记日1(2/28)计提 36160 → auto互换实现归0(3/1) → 登记日2(4/3)再计提 36160
// 模拟:付息日1(3/2)计提 36160 → auto互换实现归0(3/3) → 付息日2(4/6)再计提 36160
var eodSwaps = new List<eod_swap>
{
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,27) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,28) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,3,1) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,2) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,3,2) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,3,3) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,5) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,6) },
};
var eodPositions = new List<eod_swap_position>
{
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,27), PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,28), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,3,1), PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,2), PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,3,2), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,3,3), PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,5), PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,6), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty },
};
var svc = new TestableSwapDealService(eodSwaps, eodPositions);
// 登记日2(4/3)当天手动互换:应读 4/3 EOD = 36160(第二次,非第一次已实现的、非 0)
var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3));
// 付息日2(4/6)当天手动互换:应读 4/6 EOD = 36160(第二次,非第一次已实现的、非 0)
var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 6));
Assert.AreEqual(ExpectedDividend, dividend, 0.01m,
"登记日2(4/3)手动互换应读当日EOD=第二次分红36160" +
"若读T-1(4/2=0)则漏当日,若读2/28则错取第一次已实现的。");
"付息日2(4/6)手动互换应读当日EOD=第二次分红36160");
}
[TestMethod]
public void CauseB_MultiRegDate_手动互换期间分红挂账累计四期()
{
// 模拟:多次登记日之间未 auto 实现,分红挂账累加
// 4/3=36160, 4/29=72320, 5/29=108480, 6/29=1446404期累计)
// 模拟:多次付息日之间未 auto 实现,分红挂账累加
// 4/6=36160, 4/30=72320, 6/1=108480, 6/30=1446404期累计)
var eodSwaps = new List<eod_swap>
{
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,29) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,5,29) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,6,29) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,6) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,30) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,6,1) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,6,30) },
};
var eodPositions = new List<eod_swap_position>
{
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = 1 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,29), PosiDividendSum = 2 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,5,29), PosiDividendSum = 3 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,6,29), PosiDividendSum = 4 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,6), PosiDividendSum = 1 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,30), PosiDividendSum = 2 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,6,1), PosiDividendSum = 3 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,6,30), PosiDividendSum = 4 * ExpectedDividend, PosiQuantity = Qty },
};
var svc = new TestableSwapDealService(eodSwaps, eodPositions);
// 每次登记日当天手动互换应读到该日累计值(验证多次付息累计被正确读取)
Assert.AreEqual(1 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3)), 0.01m, "4/3 应读 36160");
Assert.AreEqual(2 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 29)), 0.01m, "4/29 应读 723202期累计)");
Assert.AreEqual(3 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 5, 29)), 0.01m, "5/29 应读 1084803期累计)");
// 关键:第 4 期登记日累计 = 4 × 36160 = 144640(原 9df39491 仅覆盖单期 36160,未验证多次付息累计)
Assert.AreEqual(4 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 6, 29)), 0.01m,
"6/29 应读 1446404期累计);原 9df39491 仅覆盖单期 36160,未验证多次付息累计。");
// 每次付息日当天手动互换应读到该日累计值(验证多次付息累计被正确读取)
Assert.AreEqual(1 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 6)), 0.01m, "4/6 应读 36160");
Assert.AreEqual(2 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 30)), 0.01m, "4/30 应读 723202期累计)");
Assert.AreEqual(3 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 6, 1)), 0.01m, "6/1 应读 1084803期累计)");
Assert.AreEqual(4 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 6, 30)), 0.01m,
"6/30 应读 1446404期累计)。");
}
#endregion
@@ -8,16 +8,7 @@ using System.Linq;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GLMS-20260105-0006 端到端补充:EOD 分红引擎票息归属按【债权登记日 reg_date】判定
/// 而非支付日(pay_date)。此前 DividendEodNoDoubleCountTest.EodSvcStub 把 CalcBondPayment 覆写成
/// 线性公式(DailyRatePerUnit*days*qty)**绕开了 reg_date 口径**——即没有真正验证"引擎按登记日计提"。
///
/// 本文件把 EOD stub 的 CalcBondPayment seam 重新桥接回【真实的 BondPaymentServicereg_date 口径)】,
/// 仅用内存 BondPayment 数据(不连库),使端到端流程(CopyEodPosition/UpdateEodPosition + GetPreEodDividendSum)
/// 真正跑生产日期逻辑:
/// ① EOD 引擎在登记日计提、支付日不计提(证明 reg_date 口径);
/// ② 登记日下一日(T+1)全平:经 GetPreEodDividendSum 读到登记日当日 EOD 分红(收盘在册→享有);
/// ③ 部分平仓 T+1DividendIn 为全量(非按比例缩放),剩余 PosiDividendSum 归 0(记录当前生产行为)。
/// EOD 分红引擎票息归属按【实际付息日 payment_date】判定测试。
/// </summary>
[TestClass]
public class RegDateDividendEodE2ETest
@@ -31,9 +22,9 @@ namespace YLErp.Modules.SwapModule
private static readonly DateTime StartDate = new(2026, 4, 1);
private static readonly DateTime RegDate = new(2026, 4, 3); // 债权登记日
private static readonly DateTime PayDate = new(2026, 4, 6); // 实际支付日(与登记日差 3 天)
private static readonly DateTime PayDate = new(2026, 4, 6); // 实际支付日
#region reg_date
#region payment_date
private static List<BondPayment> BondPayments()
=> new List<BondPayment>
@@ -41,16 +32,16 @@ namespace YLErp.Modules.SwapModule
new BondPayment
{
underlyingCode = BondCode,
reg_date = RegDate, // 关键:分红归属按债权登记日判定
payment_date_pl = PayDate, // 理论付息日(非归属口径)
payment_date = PayDate, // 实际付息日(非归属口径)
reg_date = RegDate,
payment_date_pl = PayDate,
payment_date = PayDate, // 实际付息日
payment_interest = PaymentPer100
}
};
#endregion
#region BondPaymentService seam reg_date
#region BondPaymentService seam payment_date
private sealed class RegDateBondPaymentService : BondPaymentService
{
@@ -71,7 +62,7 @@ namespace YLErp.Modules.SwapModule
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
// 桥接真实生产口径:BondPaymentService.GetBondPayments 按 reg_date 过滤 + CalcPayment 累加
// 桥接真实生产口径:BondPaymentService.GetBondPayments 按 payment_date 过滤 + CalcPayment 累加
var svc = new RegDateBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser);
return svc.CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
}
@@ -113,7 +104,7 @@ namespace YLErp.Modules.SwapModule
private static trade CreateTrade() => new trade
{
id = TradeId, TradeNumber = "UT-REGDATE-E2E-001", ClientId = 999999,
id = TradeId, TradeNumber = "UT-PAYDATE-E2E-001", ClientId = 999999,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = new DateTime(2027, 4, 1), TradeStatus = "确认成交", ValidState = "Valid",
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
@@ -161,81 +152,74 @@ namespace YLErp.Modules.SwapModule
#endregion
/// <summary>
/// 端到端证 reg_date 口径:EOD 引擎(CopyEodPosition)逐日计提时,
/// 仅在【债权登记日】产生分红,【支付日】不产生(即便支付日与登记日相差数日)
/// 这是线性 stub 无法覆盖的——线性公式按"天数"算,永远无法区分登记日 vs 支付日。
/// 端到端证 payment_date 口径:EOD 引擎(CopyEodPosition)逐日计提时,
/// 仅在【实际付息日】产生分红,【登记日/非付息日】不产生
/// </summary>
[TestMethod]
public void _EOD引擎按reg_date计提_非pay_date()
public void _EOD引擎按payment_date计提()
{
var eodSvc = new RegDateEodStub(BondPayments());
var td = CreateTrade();
var initialEod = CreateInitialEod();
// D1=4/2(登记日前一日):窗口 (4/1,4/2] 无登记日 → 0
// D1=4/2:窗口 (4/1,4/2] 无付息日 → 0
var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, new DateTime(2026, 4, 2), StartDate);
AssertDecimalEqual(0m, r1.TdPosiDividend, 0.01m, "4/2 当日新计(无登记日)");
AssertDecimalEqual(0m, r1.PosiDividendSum, 0.01m, "4/2 累计(无登记日)");
AssertDecimalEqual(0m, r1.TdPosiDividend, 0.01m, "4/2 当日新计(无付息日)");
AssertDecimalEqual(0m, r1.PosiDividendSum, 0.01m, "4/2 累计(无付息日)");
// D2=4/3(登记日):窗口 (4/2,4/3] 命中 reg_date=4/3 → 36160
// D2=4/3(登记日,非付息日):窗口 (4/2,4/3] 无付息日 → 0
var r2 = eodSvc.ExecuteCopyEodPosition(r1, td, RegDate, StartDate);
AssertDecimalEqual(ExpectedDividend, r2.TdPosiDividend, 0.01m,
"4/3 登记日当日应计提 36160(按 reg_date 口径);若按支付日(pay_date=4/6)则此处为 0(漏计)。");
AssertDecimalEqual(ExpectedDividend, r2.PosiDividendSum, 0.01m, "4/3 累计=36160");
AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "4/3 登记日(非付息日)不计提");
AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "4/3 累计=0");
// D3=4/6支付日,非登记日):窗口 (4/3,4/6] 不含任何 reg_date4/3 不>4/34/6 是支付日非登记日)→ 0
// D3=4/6付息日):窗口 (4/3,4/6] 命中 payment_date=4/6 → 36160
var r3 = eodSvc.ExecuteCopyEodPosition(r2, td, PayDate, StartDate);
AssertDecimalEqual(0m, r3.TdPosiDividend, 0.01m,
"4/6 支付日不应计提(分红归属按 reg_date,不是 pay_date);线性 stub 因按天数算会在此误计。");
AssertDecimalEqual(ExpectedDividend, r3.PosiDividendSum, 0.01m, "4/6 累计仍为 36160(支付日不重复计提)");
AssertDecimalEqual(ExpectedDividend, r3.TdPosiDividend, 0.01m, "4/6 付息日当日应计提 36160");
AssertDecimalEqual(ExpectedDividend, r3.PosiDividendSum, 0.01m, "4/6 累计为 36160");
Console.WriteLine($"[reg_date 口径] 4/2={r1.PosiDividendSum}, 4/3={r2.PosiDividendSum}(登记日计提), 4/6={r3.PosiDividendSum}(支付日不计提)");
Console.WriteLine($"[payment_date 口径] 4/2={r1.PosiDividendSum}, 4/3={r2.PosiDividendSum}, 4/6={r3.PosiDividendSum}(付息日计提)");
}
/// <summary>
/// 用户场景「登记日下一日(T+1)全平」:T日(登记日)收盘在册→享有T日分红;
/// T+1盘中全平,GetPreEodDividendSum(T+1) 应读到 T日 EOD(含当日分红)= 36160,而非漏读为 0
/// 验证端到端:EOD 引擎算出 T日分红 → 快照 → 手动/互换读取正确取到。
/// 用户场景「付息日下一日(T+1)全平」:T日(付息日)收盘在册→享有T日分红;
/// T+1盘中全平,GetPreEodDividendSum(T+1) 应读到 T日 EOD(含当日分红)= 36160。
/// </summary>
[TestMethod]
public void _经GetPreEodDividendSum读到登记日分红()
public void _经GetPreEodDividendSum读到付息日分红()
{
var eodSvc = new RegDateEodStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// T日=4/3(登记日)EOD:引擎算出分红 36160reg_date 口径)
var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate);
AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计分红=36160");
// T日=4/6(付息日)EOD:引擎算出分红 36160payment_date 口径)
var rPay = eodSvc.ExecuteCopyEodPosition(initialEod, td, PayDate, StartDate);
AssertDecimalEqual(ExpectedDividend, rPay.PosiDividendSum, 0.01m, "付息日 T日 EOD 累计分红=36160");
// T+1=4/4 盘中:注入 T日 EOD 快照,GetPreEodDividendSum 应读 T日(<=当日) → 36160
// T+1=4/7 盘中:注入 T日 EOD 快照,GetPreEodDividendSum 应读 T日(<=当日) → 36160
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
new List<eod_swap_position> { rReg });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4));
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = PayDate } },
new List<eod_swap_position> { rPay });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 7));
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m,
"T+1(4/4) 盘中全平应经 GetPreEodDividendSum 读到 T日(4/3)EOD 分红 36160(收盘在册→享有);" +
"若 < 严格小于 dealDate 读 T-1(4/2=0) 则漏读登记日当日。");
"T+1(4/7) 盘中全平应经 GetPreEodDividendSum 读到 T日(4/6)EOD 分红 36160");
Console.WriteLine($"[T+1 全平] DividendIn(读T日EOD)={dividendIn}");
// T+1=4/4 EOD 全平:PosiQuantity=0 → 不计提当日 + PosiDividendSum 归 0
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate,
new List<swap_flow_event> { CloseEvent(Qty, dividendIn, new DateTime(2026, 4, 4)) });
// T+1=4/7 EOD 全平:PosiQuantity=0 → 不计提当日 + PosiDividendSum 归 0
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rPay, td, new DateTime(2026, 4, 7), PayDate,
new List<swap_flow_event> { CloseEvent(Qty, dividendIn, new DateTime(2026, 4, 7)) });
// 实拿 = DividendIn(本次落袋) + 末尾 PosiDividendSum(剩余挂账) = 应得(T日前待实现=持有至登记日)
decimal actualGot = dividendIn + rT1.PosiDividendSum;
AssertDecimalEqual(ExpectedDividend, actualGot, 0.01m, "实拿=应得(持有至登记日享有的 36160)");
AssertDecimalEqual(0m, rT1.TdPosiDividend, 0.01m, "T+1 非登记日,EOD 不计提当日");
AssertDecimalEqual(ExpectedDividend, actualGot, 0.01m, "实拿=应得 36160");
AssertDecimalEqual(0m, rT1.TdPosiDividend, 0.01m, "T+1 非付息日,EOD 不计提当日");
AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0");
Console.WriteLine($"[T+1 全平] 应得={ExpectedDividend}, 实拿={actualGot}, 末尾PosiDividendSum={rT1.PosiDividendSum}");
}
/// <summary>
/// 部分平仓 T+1当前生产行为记录(非修复目标)。
/// T日(登记日)持有→T+1盘中部分平仓:GetPreEodDividendSum 返回的是全量待实现分红(非按平仓比例缩放)
/// 故 DividendIn=全量 36160T+1 EOD 部分平仓(PosiQuantity>0)后剩余 PosiDividendSum=前日-全量=0。
/// 注:此"DividendIn 不按平仓比例缩放"是当前生产行为,已与用户确认(潜在一致性议题,非本 bug 修复范围)。
/// 部分平仓 T+1
/// T日(付息日)持有→T+1盘中部分平仓:GetPreEodDividendSum 返回的是全量待实现分红,
/// 故 DividendIn=全量 36160T+1 EOD 部分平仓后剩余 PosiDividendSum=前日-全量=0。
/// </summary>
[TestMethod]
public void _T1_DividendIn为全量_剩余PosiDividendSum归0()
@@ -245,25 +229,25 @@ namespace YLErp.Modules.SwapModule
var position = CreatePosition();
var initialEod = CreateInitialEod();
// T日=4/3(登记日)EOD:累计 36160
var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate);
AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计=36160");
// T日=4/6(付息日)EOD:累计 36160
var rPay = eodSvc.ExecuteCopyEodPosition(initialEod, td, PayDate, StartDate);
AssertDecimalEqual(ExpectedDividend, rPay.PosiDividendSum, 0.01m, "付息日 T日 EOD 累计=36160");
// T+1=4/4 盘中部分平仓(50%)GetPreEodDividendSum 返回全量36160(不按比例缩放)
// T+1=4/7 盘中部分平仓(50%)GetPreEodDividendSum 返回全量 36160
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
new List<eod_swap_position> { rReg });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4));
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, "部分平仓 T+1DividendIn 仍为全量 36160(非按 50% 缩放)");
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = PayDate } },
new List<eod_swap_position> { rPay });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 7));
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, "部分平仓 T+1DividendIn 仍为全量 36160");
// T+1=4/4 EOD 部分平仓(Quantity=Qty/2)PosiQuantity>0TdPosiDividend=0(非登记日)
// T+1=4/7 EOD 部分平仓(Quantity=Qty/2)PosiQuantity>0TdPosiDividend=0(非付息日)
// PosiDividendSum = 前日36160 + 0 - TdCloseDividend(全量36160) = 0
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate,
new List<swap_flow_event> { CloseEvent(Qty / 2, dividendIn, new DateTime(2026, 4, 4)) });
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rPay, td, new DateTime(2026, 4, 7), PayDate,
new List<swap_flow_event> { CloseEvent(Qty / 2, dividendIn, new DateTime(2026, 4, 7)) });
AssertDecimalEqual(ExpectedDividend, rT1.TdCloseDividend, 0.01m, "TdCloseDividend=全量 DividendIn(36160)");
AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m,
"部分平仓后剩余 PosiDividendSum=前日36160 - 全量实现36160 = 0(当前生产行为:DividendIn 不按比例缩放)");
"部分平仓后剩余 PosiDividendSum=前日36160 - 全量实现36160 = 0");
Console.WriteLine($"[部分平仓 T+1] DividendIn={dividendIn}(全量), 剩余PosiDividendSum={rT1.PosiDividendSum}");
}
}
@@ -133,6 +133,17 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(1m, GetSplit(target));
}
[TestMethod]
public void ZeroCashDividendOverridesExistingValueDuringMerge()
{
var target = CreateCorporateAction(cash: 10m);
var source = CreateCorporateAction(cash: 0m);
InvokeMerge(target, source);
Assert.AreEqual(0m, target.GiveCashAmount);
}
private static ex_dividend_info CreateCorporateAction(
decimal cash = 0m,
decimal giveShare = 0m,
@@ -188,7 +199,7 @@ namespace YLErp.Modules.SwapModule
private static void InvokeMerge(ex_dividend_info target, ex_dividend_info source)
{
var method = typeof(DividendService).GetMethod(
"MergeNonZeroDividendValues",
"MergeDividendValues",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.IsNotNull(method, "公司行为存量合并方法不存在");
method.Invoke(null, new object[] { target, source });
@@ -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;
/// 超付部分由可用资金公式的负缺口(Σ维持−累计)体现,不产生返还记录。
@@ -0,0 +1,178 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule;
/// <summary>
/// EQD-7084 新“框架合约”Tab 的口径测试。
/// 纯计算测试不依赖数据库,直接锁定 EodPnlCalculator 的新口径。
/// </summary>
[TestClass]
public class SwapEodPositionRiskNewTabTest
{
[TestMethod]
public void _普通利息排除保证金_浮动收益剔除费用并保留估值总额()
{
var floating = new[]
{
// EOD 归一后,费用已经按我方收益视角落库;支付费用为负数。
FloatingLeg("600000", 1, 100m, 0m, -12m, "普通收益互换")
};
var interests = new[]
{
InterestLeg(1, (int)InterestModeEnum., 80m, 0.02m, 0.02m),
InterestLeg(1, (int)InterestModeEnum., 20m, 0.01m, 0.01m)
};
var fields = InvokeCalculation(
floating,
interests,
structureType: "普通收益互换",
notionalValue: 1_000m,
startDate: new DateTime(2026, 1, 1),
maturityDate: new DateTime(2026, 12, 31),
periodAmount: 5m,
dividendPayDate: 0);
Assert.AreEqual(100m, GetDecimal(fields, "FloatingUnrealizedPnl"), 0.0001m,
"新浮动端待实现收益应排除 PosiFeePendingPosiProfitSum(88) - PosiFeePending(-12) = 100");
Assert.AreEqual(-12m, GetDecimal(fields, "OpeningClosingFee"), 0.0001m,
"开平仓费用直接使用 EOD 已归一的 PosiFeePending");
Assert.AreEqual(80m, GetDecimal(fields, "OrdinaryInterestPnl"), 0.0001m,
"利息端待实现收益应排除初始/维持保证金腿");
Assert.AreEqual(-20m, GetDecimal(fields, "MarginInterestAmount"), 0.0001m,
"保证金利息仍应按保证金腿方向计入估值");
Assert.AreEqual(153m, GetDecimal(fields, "MaturityNettingValuation"), 0.0001m,
"估值应保持旧口径:100 - 12 + 80 - 20 + 5 = 153;费用只计一次");
}
[TestMethod]
public void _当前利率合计使用普通利息腿TdInterestRate_并识别FR007()
{
var fr007Leg = InterestLeg(2, (int)InterestModeEnum., 20m, 0.03m, 0.018m);
fr007Leg.FloatRateUnderlyingCode = "FR007";
fr007Leg.FloatRate = 0.015m;
var fields = InvokeCalculation(
new[] { FloatingLeg("600001", 2, 100m, 0m, 0m, "普通收益互换") },
new[]
{
InterestLeg(1, (int)InterestModeEnum., 10m, 0.02m, 0.0125m),
fr007Leg
},
structureType: "普通收益互换",
notionalValue: 100m,
startDate: new DateTime(2026, 2, 1),
maturityDate: new DateTime(2026, 8, 1),
periodAmount: 0m,
dividendPayDate: 1);
Assert.AreEqual(0.0305m, GetDecimal(fields, "InterestRatePrice"), 0.0000001m,
"利率端价格应为普通利息腿当前 TdInterestRate 合计,而非默认利差合计");
Assert.AreEqual("FR007", GetString(fields, "InterestBenchmark"));
}
[TestMethod]
public void _普通利息腿无FR007时基准为固定利率()
{
var fields = InvokeCalculation(
new[] { FloatingLeg("600002", 1, 100m, 0m, 0m, "普通收益互换") },
new[] { InterestLeg(1, (int)InterestModeEnum., 10m, 0.02m, 0.0125m) },
structureType: "普通收益互换",
notionalValue: 100m,
startDate: new DateTime(2026, 2, 1),
maturityDate: new DateTime(2026, 8, 1),
periodAmount: 0m,
dividendPayDate: 1);
Assert.AreEqual("固定利率", GetString(fields, "InterestBenchmark"));
}
[TestMethod]
public void _债券期初价格按风险页约定放大百分价格_并保留合同字段()
{
var fields = InvokeCalculation(
new[] { FloatingLeg("110000", 1, 99.12m, 0m, 0m, "普通债券类收益互换", "Bond") },
new[] { InterestLeg(1, (int)InterestModeEnum., 1m, 0.01m, 0.01m) },
structureType: "普通债券类收益互换",
notionalValue: 9_900m,
startDate: new DateTime(2026, 3, 1),
maturityDate: new DateTime(2027, 3, 1),
periodAmount: 0m,
dividendPayDate: 1);
Assert.AreEqual(99.12m, GetDecimal(fields, "InitialPrice"), 0.0001m,
"债券日终 PosiGrossPrice 已由 SetPosiPrice 按风险页口径缩放,新接口不能再次乘 100");
Assert.AreEqual(9_900m, GetDecimal(fields, "NotionalQuantity"), 0.0001m);
Assert.AreEqual("多头", GetString(fields, "UnderlyingDirection"));
Assert.AreEqual("110000", GetString(fields, "UnderlyingCode"));
Assert.AreEqual("Bond", GetString(fields, "UnderlyingInstrumentType"));
Assert.AreEqual(new DateTime(2026, 3, 1), GetDate(fields, "ContractStartDate"));
Assert.AreEqual(new DateTime(2027, 3, 1), GetDate(fields, "ContractMaturityDate"));
}
private static object InvokeCalculation(
IEnumerable<eod_swap_position> floating,
IEnumerable<eod_swap_position> interests,
string structureType,
decimal notionalValue,
DateTime startDate,
DateTime maturityDate,
decimal periodAmount,
int dividendPayDate)
{
return EodPnlCalculator.CalculateEodSwapRiskNewFields(
floating,
interests,
structureType,
notionalValue,
startDate,
maturityDate,
periodAmount,
dividendPayDate);
}
private static decimal GetDecimal(object fields, string name)
=> Convert.ToDecimal(fields.GetType().GetProperty(name)!.GetValue(fields));
private static string GetString(object fields, string name)
=> (string)fields.GetType().GetProperty(name)!.GetValue(fields)!;
private static DateTime GetDate(object fields, string name)
=> (DateTime)fields.GetType().GetProperty(name)!.GetValue(fields)!;
private static eod_swap_position FloatingLeg(
string code,
int positionType,
decimal mtm,
decimal dividend,
decimal fee,
string structureType,
string instrumentType = null)
=> new()
{
UnderlyingCode = code,
UnderlyingInstrumentType = instrumentType ?? structureType,
PositionType = positionType,
PosiGrossPrice = mtm,
PosiMtmPnL = mtm,
PosiDividendSum = dividend,
PosiFeePending = fee,
PosiProfitSum = mtm + dividend + fee,
PosiNotionalValue = 100m
};
private static eod_swap_position InterestLeg(
int direction,
int mode,
decimal profit,
decimal defaultRate,
decimal currentRate)
=> new()
{
InterestDirection = direction,
InterestMode = mode,
InterestProfitSum = profit,
InterestRateDefault = defaultRate,
TdInterestRate = currentRate
};
}
@@ -18,7 +18,7 @@ namespace YLErp.Modules.SwapModule
return new trade
{
id = 1668,
TradeNumber = "ZSZQ-IS-202602090001",
TradeNumber = "TRS-IS-202602090001",
ClientId = testClientId,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 2, 10),
@@ -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勾选}}"), "协议段占位符应全部被替换");
}
}
}
+2
View File
@@ -9,5 +9,7 @@ namespace YLErp.Abstract
public interface IKafkaProduce
{
void Produce(string topic, string message);
void Produce(string topic, string key, string message);
}
}
@@ -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),
@@ -2327,8 +2327,9 @@ namespace YLErp.BLL.Eod
if (trade.ExerciseDate.Value.Date >= valuedateBLL.ValueDate.Date)
{
// R4 簿记资金校验口径(2026-08-21 业务强调"走了资金的就不能占用授信"):
// 按腿的资金走向分流——走现金的部分(未选/选现金腿 + 成交金额)只认现金结存;
// 授信的腿认 剩余可用授信(有效授信−已使用授信,授信出入表 Σ(amount)),
// 按腿的资金走向分流——走现金的部分(选现金/未选且交易级资金来源非授信 + 成交金额)只认现金结存;
// 授信的腿(腿选授信,或腿未选回退交易级 margin_fund_source=授信,ConsFundTag.PreferCredit
// 认 剩余可用授信(有效授信−已使用授信,授信出入表 Σ(amount)),
// 授信不够覆盖的部分回落现金,同样只认现金结存。杜绝"现金腿拿授信垫付校验→现金透支"。
var marginModes = new[] { (int)InterestModeEnum., (int)InterestModeEnum. };
var legs = trade.swap_positions?.Where(x => marginModes.Contains(x.InterestMode)).ToList();
@@ -2346,7 +2347,7 @@ namespace YLErp.BLL.Eod
{
continue;
}
if (leg.FundTag == YLErp.DBModels.ConsFundTag.Credit)
if (YLErp.DBModels.ConsFundTag.PreferCredit(leg.FundTag, trade.MarginFundSource))
{
creditPayable += payable;
}
+1
View File
@@ -355,6 +355,7 @@ namespace YLErp.BLL
public DbSet<eod_swap_position> eod_swap_position { get; set; }
public DbSet<swap_event> swap_event { get; set; }
public DbSet<eod_swap> eod_swap { get; set; }
public DbSet<PushStatus> push_status { get; set; }
public DbSet<TradeObervation> trade_obervation { get; set; }
public DbSet<SystemLog> SystemLogs { get; set; }
+3 -3
View File
@@ -10,8 +10,8 @@ namespace YLErp.Helpers
{
/// <summary>
/// 计算器帮助类
/// 调用链:zszq-trs → zszq-bond-oms(/calc/cal_bond_value) → bond-calc 微服务。
/// 契约(与 bond-oms-ui / zszq-bond-oms 保持一致):
/// 调用链:TRS主服务 → bond-oms(/calc/cal_bond_value) → bond-calc 微服务。
/// 契约(与 bond-oms-ui / bond-oms 保持一致):
/// 请求体 { bondId, price, priceType(DP全价/CP净价/YD收益率), targetDate?(估值日 yyyy-MM-dd,缺省代理取 T+1) }
/// 成功响应 data:{ errCode:0, errMsg:null, dirtyPrice(全价), cleanPrice(净价), ytm(收益率%) }
/// 失败响应:success=false 且 message=中文原因(债券不存在/信息不全/参数非法/服务异常)
@@ -82,7 +82,7 @@ namespace YLErp.Helpers
LogFactory.GetLogger("BondCalcHepler").Error("债券计算器返回 data 为空");
return null;
}
// 业务层错误码(债券不存在 / 债券信息不全 / 参数非法),zszq-bond-oms 在 success=true 时仍可能带 errCode!=0
// 业务层错误码(债券不存在 / 债券信息不全 / 参数非法),bond-oms 在 success=true 时仍可能带 errCode!=0
if (result.data.errCode != 0)
{
errorMsg = result.data.errMsg ?? "债券计算业务错误";
+16 -6
View File
@@ -63,19 +63,29 @@ namespace YLErp.Helpers
public void Produce(string topic,string message)
{
var kafkaMessage = new Message<string, string>
{
Key=null,
Value = message
};
try
{
_producer.ProduceAsync(topic, kafkaMessage).GetAwaiter().GetResult();
ProduceCore(topic, null, message);
}
catch (Exception ex)
{
_logger.Error($"Topic:{topic} send failed",ex);
}
}
public void Produce(string topic, string key, string message)
{
ProduceCore(topic, key, message);
}
private void ProduceCore(string topic, string key, string message)
{
var kafkaMessage = new Message<string, string>
{
Key = key,
Value = message
};
_producer.ProduceAsync(topic, kafkaMessage).GetAwaiter().GetResult();
}
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ namespace YLErp.Model
/// <summary>
/// TRS合约数据推送topic(对外,如onebp等)
/// </summary>
public string ContractTopic { get; set; } = "onederi.trs.onebp.contract.v1";
public string ContractTopic { get; set; } = "onederiv.trs.contract.v1";
}
}
@@ -95,7 +95,9 @@ namespace YLErp.Modules.AppModule
string replace(Match m)
{
switch (m.Groups[1].Value)
// 同时兼容标准 {{变量}} 和历史模板中的 {变量} 写法。
var variableName = m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value;
switch (variableName)
{
case "客户名称":
return reqModel.ClientName;
@@ -105,11 +107,16 @@ namespace YLErp.Modules.AppModule
return reqModel.DocNumber;
case "交易列表":
return reqModel.TradeList;
// 结算邮件扩展变量:可同时用于邮件标题和正文。
case "交易编号":
return reqModel.TradeNumber ?? string.Empty;
case "平仓总额":
return reqModel.UnwindTotalAmount ?? string.Empty;
}
return string.Empty;
}
var pattern = @"\{\{(客户名称|交易日期|文档编号|交易列表)\}\}";
var pattern = @"\{\{(客户名称|交易日期|文档编号|交易列表|交易编号|平仓总额)\}\}|\{(客户名称|交易日期|文档编号|交易列表|交易编号|平仓总额)\}";
var result = new MailInfoResultModel
{
@@ -146,6 +153,16 @@ namespace YLErp.Modules.AppModule
/// {{交易列表}}
/// </summary>
public string TradeList { get; set; }
/// <summary>
/// {{交易编号}}:当前客户在本次结算日邮件中涉及的交易编号,多个编号以逗号分隔。
/// </summary>
public string TradeNumber { get; set; }
/// <summary>
/// {{平仓总额}}:当前客户在本次结算日邮件中的平仓金额合计,使用客户视角。
/// </summary>
public string UnwindTotalAmount { get; set; }
}
/// <summary>
@@ -59,43 +59,7 @@ namespace YLErp.Modules.EodModule
create_time = source.create_time,
update_time = source.update_time
};
// 不再依赖 bond-sync 镜像:Stock/Fund 公司行为直接作为展示行返回。
// 展示金额按“每 10 份派现金额”换算为 GiveCashAmount / 10EOD 计算仍使用
// GetBondPayments 的内部单位口径,不受此处展示换算影响。
var corporateQuery = from un in queryUn
join dividend in DbContext.ex_dividend_info.AsNoTracking()
on un.UnderlyingCode equals dividend.UnderlyingCode
where dividend.ValidStatus
&& dividend.EffectiveDate.HasValue
&& dividend.EffectiveDate.Value >= valueDtStart
&& dividend.EffectiveDate.Value < valueDtEnd
&& dividend.GiveCashAmount != 0
&& (string.IsNullOrEmpty(req.UnderlyingCode)
|| dividend.UnderlyingCode.Contains(req.UnderlyingCode))
&& (un.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Stock
|| un.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund)
&& (string.IsNullOrEmpty(req.DataSource)
|| "公司行为除权表".Contains(req.DataSource))
select new BondPaymentDto
{
id = -dividend.id,
channel_source = "公司行为除权表",
MarketName = un.MarketName,
security_id = un.UnderlyingCode,
symbol = un.UnderlyingName,
coupon_rate = null,
payment_date = dividend.EffectiveDate,
payment_interest = dividend.GiveCashAmount / 10m,
payment_parvalue = null,
paying_price = dividend.GiveCashAmount / 10m,
create_time = dividend.OptDate,
update_time = dividend.OptDate
};
// EF Core 无法翻译两个对 BondPaymentDto 继承属性赋值集合不完全一致的投影
// 直接 Concat;分别执行后在内存合并,不改变两组查询的筛选口径。
var rows = query.ToList();
rows.AddRange(corporateQuery.ToList());
var result = rows.AsQueryable().ToSearchList(req);
var result = query.ToSearchList(req);
return result;
}
@@ -128,19 +92,18 @@ namespace YLErp.Modules.EodModule
/// <summary>
/// 获取某债券的期间付息情况集合
/// </summary>
/// <param name="underylingCode"></param>
/// <param name="underlyingCode"></param>
/// <param name="startDate"></param>
/// <param name="endDate"></param>
/// <returns></returns>
public List<BondPayment> GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate)
{
// GLMS-20260105-0006:票息归属按债权登记日(reg_date)判定,而非支付日(pay_date_PL/pay_date_act)。
// 登记日当天 EOD 即应计提;原按支付日口径会让"登记日≠支付日"的债券漏计(二者恰差一工作日时缺陷被掩盖)。
// 票息归属按实际付息日(payment_date)判定
var result = QueryBondPayments(underlyingCode)
.Where(x => x.reg_date > startDate && x.reg_date <= endDate)
.Where(x => x.payment_date > startDate && x.payment_date <= endDate)
.AsNoTracking().ToList();
Log.Info($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " +
string.Join(",", result.Select(r => r.reg_date?.ToString("yyyy-MM-dd"))));
Log.Info($"[分红-付息日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按payment_date过滤, 命中 {result.Count} 条: " +
string.Join(",", result.Select(r => r.payment_date?.ToString("yyyy-MM-dd"))));
// 让 Copy/Update EOD 始终只依赖 BondPaymentService,而不必在收盘链路直接累加 ex_dividend_info。
// Stock/Fund 公司行为直接从 ex_dividend_info 读取,内部仍按“每 10 份派现金额”保存 GiveCashAmount 原值,
@@ -178,7 +141,7 @@ namespace YLErp.Modules.EodModule
/// <summary>
/// 可测性 seam:返回某债券的全部付息记录(未做日期过滤)。测试可 override 注入内存数据,
/// 以验证日期口径(GLMS-20260105-0006:应按债权登记日 reg_date 而非支付日 pay_date_PL/pay_date_act 判定)。
/// 以验证日期口径(按付息日 payment_date 判定)。
/// </summary>
protected virtual IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode);
@@ -193,7 +156,7 @@ namespace YLErp.Modules.EodModule
/// <summary>
/// 计算某债券某段时间的期间付息
/// </summary>
/// <param name="underylingCode">债券代码</param>
/// <param name="underlyingCode">债券代码</param>
/// <param name="startDate">计息开始日</param>
/// <param name="endDate">计息结束日</param>
/// <param name="qty">持仓数量</param>
@@ -225,10 +188,26 @@ namespace YLErp.Modules.EodModule
decimal qty,
decimal longRatio,
decimal payDirection)
{
return CalcPayment(payments, qty, longRatio, payDirection, null);
}
/// <summary>
/// 公司行为现金分红按登记日权益数量计算;同一窗口中的原生债券付息仍按当前持仓数量计算。
/// </summary>
public decimal CalcPayment(
List<BondPayment> payments,
decimal qty,
decimal longRatio,
decimal payDirection,
decimal? corporateActionQty)
{
var actualAmount = (payments ?? new List<BondPayment>()).Sum(payment =>
{
var paymentAmount = (payment.payment_interest ?? 0m) * qty;
var paymentQty = payment.IsCorporateActionCashDividend
? corporateActionQty ?? qty
: qty;
var paymentAmount = (payment.payment_interest ?? 0m) * paymentQty;
// bond_payment_info 原生期间付息按每 100 份存储;由 ex_dividend_info 补充的
// 公司行为现金分红按每 10 份存储。Fund 标的可能同时命中两类记录,故必须逐条分流。
return payment.IsCorporateActionCashDividend
@@ -1,4 +1,4 @@
using System.Linq.Expressions;
using System.Linq.Expressions;
using YLErp.Helpers;
using YLErp.Model;
using YLErp.Modules.DataProviderModule;
@@ -176,33 +176,37 @@ namespace YLErp.Modules.EodModule.SettlementModule
}
/// <summary>
/// 计算互换预付金
/// 计算互换预付金(保证金模板V2 引擎,本端闭环)。
/// 2026-08-26 收口:原实现 HTTP 调 bond-oms /marginAlgorithm/triggerMarginCalc——Java 按 marginrate
/// (旧预付金率维护数据)对在市互换算盯市并清写当天 trade_span/client_span;现改为直接跑本端预付金引擎
/// EodWorstClientPayableCalc(模板V2 三级层解析:无预付金=0、区间追保结构 x/y 或方案B收盘落档,
/// 产出 trade_span.Spv 并聚合 client_span),保证金计算→追加预付金生成闭环全部在 .NET。
/// 交易范围与原 Java selectPreCloseTradeList 同口径:在市收益互换(确认成交未到期 + 平仓日落在区间),
/// 剔除多空组合子交易(引擎本身跳过);"日终持仓结算"步骤随后会对确认成交全集幂等重算覆盖。
/// Java 侧自此仅保留资金通知书邮件与 DMA 实时预付金(RealtimePnlCalc.CalcDMAMargin,迁移方案阶段三待切项)。
/// preSettleDate 参数保留以维持调用签名,引擎取数按 settleDate 自行解析。
/// </summary>
/// <returns></returns>
public bool CalculateMargin(DateTime settleDate, DateTime preSettleDate, IEnumerable<int> ClientIds)
{
// 构建请求对象
CalculateMarginRequest request = new CalculateMarginRequest()
var clientIds = ClientIds?.Where(x => x > 0).Distinct().ToList();
List<trade> tradeList;
using (var db = new YLErp.BLL.YLContext())
{
currentDate = settleDate.ToString("yyyy-MM-dd"),
clientIds = ClientIds,
preSettleDate = preSettleDate.ToString("yyyy-MM-dd")
};
var baseUrl = Environment.GetEnvironmentVariable("BondOmsInterface_BaseUrl");
var calculateMarginUrl = "/marginAlgorithm/triggerMarginCalc";
if (!string.IsNullOrEmpty(baseUrl))
{
var httpHelper = new HttpHelper(baseUrl, null);
// http 请求 Web项目接口
var result = httpHelper.PostRequestNoAuth<CalculateMarginRequest, CalculateMarginResponse>(calculateMarginUrl, request).Result;
if (result != null && !result.success)
var query = db.trade.Where(t => t.TradeType == "收益互换"
&& t.ValidState != YLErp.ConsGlobal.InValid
&& t.ParentTradeId == 0
&& ((t.TradeStatus == YLErp.DBModels.ConsTrade. && t.TradeDate <= settleDate && t.ExerciseDate >= settleDate)
|| (t.TradeStatus == YLErp.DBModels.ConsTrade. && t.TradeDate <= settleDate
&& t.UnWindDate > settleDate && t.UnWindDate > t.StartDate)));
if (clientIds != null && clientIds.Count > 0)
{
throw new ServiceException($"互换预付金计算失败:{result.message}");
var idScope = clientIds;
query = query.Where(t => idScope.Contains(t.ClientId));
}
tradeList = query.ToList();
}
new EodWorstClientPayableCalc(_context).WorstClientPayableCalc(tradeList);
return true;
}
}
@@ -10,6 +10,12 @@ using YLErp.Modules.SystemModule;
using YLErp.Modules.TradeDalModule;
using YLErp.Modules.TradeModule.DealModule;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using YieldChain.Commons;
using YLErp.Abstract;
using YLErp.Model;
namespace YLErp.Modules.EodModule.SettlementModule
{
/// <summary>
@@ -445,12 +451,35 @@ where {nameof(t.TaskStartTime)}>'{startDateStr}' and {nameof(t.TaskState)}={(int
}
ClientBalanceUtility.saveClientRiskMonitor(eodTask.ValueDate);
new EodFileService(this.OptUser).GenerateFileAfterEod(eodTask.ValueDate);
PushTrsContractSnapshot(eodTask.ValueDate);
//执行下一日
eodTask.ValueDate = eodTask.ValueDate.AddDays(1);
eodTask.TaskEndTime = DateTime.Now;
}
}
private void PushTrsContractSnapshot(DateTime valueDate)
{
try
{
var provider = YLServiceLocator.ServiceProvider;
var kafkaProduce = provider?.GetService<IKafkaProduce>();
var kafkaOptions = provider?.GetService<IOptions<KafkaConfig>>();
if (kafkaProduce == null || kafkaOptions?.Value == null)
{
LogFactory.GetLogger("TRS合约日终Kafka推送").Error("Kafka service or configuration is unavailable");
return;
}
using var pushDbContext = DbContextFactory.GetYLDbContext();
new TrsContractKafkaPushService(pushDbContext, kafkaProduce, kafkaOptions.Value.ContractTopic).Push(valueDate);
}
catch (Exception ex)
{
LogFactory.GetLogger("TRS合约日终Kafka推送").Error($"TRS contract snapshot task failed, valueDate:{valueDate:yyyy-MM-dd}", ex);
}
}
/// <summary>
/// 查找第一个可用的任务
/// </summary>
@@ -0,0 +1,242 @@
using YLErp.Abstract;
using YLErp.BLL;
using YLErp.DBModels;
using YLErp.Helpers;
namespace YLErp.Modules.EodModule
{
/// <summary>
/// 收盘后按交易日推送 TRS 合约全量快照。
/// </summary>
public class TrsContractKafkaPushService
{
private const string DateFormat = "yyyy-MM-dd";
private const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss";
private const string InterestCategory = "互换利率";
private const int TrsContractPushType = 1;
private const int MaxAttempts = 3;
private readonly YLContext _dbContext;
private readonly IKafkaProduce _kafkaProduce;
private readonly string _topic;
private readonly IYcLogger _logger;
public TrsContractKafkaPushService(YLContext dbContext, IKafkaProduce kafkaProduce, string topic)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_kafkaProduce = kafkaProduce ?? throw new ArgumentNullException(nameof(kafkaProduce));
_topic = string.IsNullOrWhiteSpace(topic) ? throw new ArgumentException("Kafka topic is empty", nameof(topic)) : topic;
_logger = LogFactory.GetLogger(nameof(TrsContractKafkaPushService));
}
public void Push(DateTime valueDate)
{
valueDate = valueDate.Date;
TrsContractSnapshot snapshot;
string payload;
try
{
snapshot = BuildSnapshot(valueDate);
payload = JsonHelper.Serialize(snapshot, true, true);
}
catch (Exception ex)
{
_logger.Error($"TRS contract snapshot build failed, valueDate:{valueDate:yyyy-MM-dd}", ex);
RecordFailures(valueDate, 0, ex);
return;
}
var key = valueDate.ToString(DateFormat);
Exception lastException = null;
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
_kafkaProduce.Produce(_topic, key, payload);
_logger.Info($"TRS contract snapshot sent, valueDate:{key}, topic:{_topic}, count:{snapshot.ContractCount}, attempt:{attempt}");
return;
}
catch (Exception ex)
{
lastException = ex;
_logger.Error($"TRS contract snapshot send failed, valueDate:{key}, topic:{_topic}, attempt:{attempt}", ex);
}
}
_logger.Error($"TRS contract snapshot send exhausted retries, valueDate:{key}, topic:{_topic}, attempts:{MaxAttempts}");
RecordFailures(valueDate, MaxAttempts, lastException);
}
protected virtual void RecordFailures(DateTime valueDate, int retryCount, Exception exception)
{
try
{
var recordIds = _dbContext.eod_swap
.Where(x => x.ValueDate == valueDate)
.Select(x => x.id)
.ToList();
if (recordIds.Count == 0)
{
recordIds.Add(0);
}
var now = DateTime.Now;
var statuses = _dbContext.push_status
.Where(x => x.ValueDate == valueDate
&& x.PushType == TrsContractPushType
&& recordIds.Contains(x.RecordId))
.ToList();
var error = exception?.ToString();
if (error?.Length > 2000)
{
error = error.Substring(0, 2000);
}
foreach (var recordId in recordIds)
{
var status = statuses.FirstOrDefault(x => x.RecordId == recordId);
if (status == null)
{
status = new PushStatus
{
ValueDate = valueDate,
PushType = TrsContractPushType,
RecordId = recordId,
CreateTime = now
};
_dbContext.push_status.Add(status);
}
status.State = PushStateEnum.;
status.RetryCount = retryCount;
status.LastError = error;
status.PushTime = now;
status.UpdateTime = now;
}
_dbContext.SaveChanges();
}
catch (Exception ex)
{
_logger.Error($"TRS contract push failure status save failed, valueDate:{valueDate:yyyy-MM-dd}", ex);
}
}
protected virtual TrsContractSnapshot BuildSnapshot(DateTime valueDate)
{
var eodSwaps = _dbContext.eod_swap
.Where(x => x.ValueDate == valueDate)
.AsNoTracking()
.ToList();
var tradeIds = eodSwaps.Select(x => x.SwapTradeId).Distinct().ToList();
var trades = _dbContext.trade
.Where(x => tradeIds.Contains(x.id))
.AsNoTracking()
.ToDictionary(x => x.id);
var eodPositions = _dbContext.eod_swap_position
.Where(x => x.ValueDate == valueDate && tradeIds.Contains(x.SwapTradeId) && !x.Invalid)
.AsNoTracking()
.ToList();
var positionIds = eodPositions.Select(x => x.PositionId).Distinct().ToList();
var swapPositions = _dbContext.swap_position
.Where(x => positionIds.Contains(x.id) && !x.Invalid)
.AsNoTracking()
.ToDictionary(x => x.id);
var contracts = eodSwaps.Select(eodSwap => BuildContract(eodSwap, trades, eodPositions, swapPositions)).ToList();
return new TrsContractSnapshot
{
SchemaVersion = "v1",
ValueDate = valueDate.ToString(DateFormat),
PushTime = DateTime.Now.ToString(DateTimeFormat),
ContractCount = contracts.Count,
Contracts = contracts
};
}
internal static TrsContractSnapshotItem BuildContract(
eod_swap eodSwap,
IReadOnlyDictionary<int, trade> trades,
IReadOnlyCollection<eod_swap_position> eodPositions,
IReadOnlyDictionary<long, swap_position> swapPositions)
{
if (!trades.TryGetValue(eodSwap.SwapTradeId, out var trade))
{
throw new InvalidOperationException($"TRS trade not found, swapTradeId:{eodSwap.SwapTradeId}");
}
var positions = eodPositions.Where(x => x.SwapTradeId == eodSwap.SwapTradeId).ToList();
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 || string.IsNullOrWhiteSpace(swapPosition.category_tag)))
.ToList();
// 互换利率腿优先;同类别多腿按当前查询顺序取第一条。历史类别为空时保留利息方向,
// 但 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 == null ? 0 : 1)}");
}
var interestLeg = interest;
var floatingLeg = floating[0];
return new TrsContractSnapshotItem
{
TradeDate = eodSwap.ValueDate.ToString(DateFormat),
BookId = eodSwap.BookId,
SwapTradeNo = eodSwap.SwapTradeNo,
ClientId = eodSwap.ClientId,
UnderlyingCode = trade.UnderlyingCode,
UnderlyingName = trade.UnderlyingAssetName,
UnderlyingInstrumentType = trade.UnderlyingInstrumentType,
NotionalValue = eodSwap.NotionalValue,
Dv01 = eodSwap.dv01 ?? 0,
StartDate = trade.StartDate?.ToString(DateFormat),
MaturityDate = trade.ExerciseDate?.ToString(DateFormat),
FixedRate = isUncategorizedInterest ? 0 : interestLeg.InterestRateDefault,
InterestDirection = interestLeg.InterestDirection,
FloatingDirection = floatingLeg.PositionType,
InitMarginGain = eodSwap.InitMarginGain,
InitMarginLoss = eodSwap.InitMarginLoss
};
}
}
public class TrsContractSnapshot
{
public string SchemaVersion { get; set; }
public string ValueDate { get; set; }
public string PushTime { get; set; }
public int ContractCount { get; set; }
public List<TrsContractSnapshotItem> Contracts { get; set; }
}
public class TrsContractSnapshotItem
{
public string TradeDate { get; set; }
public int BookId { get; set; }
public string SwapTradeNo { get; set; }
public int ClientId { get; set; }
public string UnderlyingCode { get; set; }
public string UnderlyingName { get; set; }
public string UnderlyingInstrumentType { get; set; }
public decimal NotionalValue { get; set; }
public decimal Dv01 { get; set; }
public string StartDate { get; set; }
public string MaturityDate { get; set; }
public decimal FixedRate { get; set; }
public int InterestDirection { get; set; }
public int FloatingDirection { get; set; }
public decimal InitMarginGain { get; set; }
public decimal InitMarginLoss { get; set; }
}
}
@@ -166,13 +166,17 @@ namespace YLErp.Modules.ReportModule
fileList.Add(Path.ChangeExtension(docInfo.AbsolutePath, extension));
}
}
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(useTemplate.Value, new MailInfoRequestModel
// 根据当前客户的结算确认书准备交易编号和平仓总额变量。
var settlementRows = SettlementMailTemplateHelper.GetSettlementRows(DbContext, SettleBills, client.id);
var mailRequest = new MailInfoRequestModel
{
ClientName = client.Name,
TradeDate = valueDate.ToString("yyyy-MM-dd"),
DocNumber = SettleBills.Count > 0 ? SettleBills[0].Code : string.Empty
}) ?? new MailInfoResultModel
};
SettlementMailTemplateHelper.PopulateSettlementFields(mailRequest, settlementRows);
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(useTemplate.Value, mailRequest) ?? new MailInfoResultModel
{
Body = string.Empty,
Title = "清算确认书-" + valueDate.ToString("yyyy-MM-dd")
@@ -316,7 +320,9 @@ namespace YLErp.Modules.ReportModule
foreach (var client in clientListOn)
{
var SettleBills = theDaySettleDocList.Where(t => t.SellerId == client.id || t.BuyerId == client.id).ToList();
var res = ts.Where(O => O.trade.ClientId == client.id).ToList();
// 批量查询包含整个日期区间,此处仅保留当前结算日的数据。
var res = SettlementMailTemplateHelper.ForValueDate(
ts.Where(O => O.trade.ClientId == client.id), valueDate);
if (SettleBills.Count > 0)
{
var fileList = new List<string>();
@@ -338,14 +344,16 @@ namespace YLErp.Modules.ReportModule
var sb = new StringBuilder();
var instrumentTypeArr = new List<string>() { ConsGlobal.InstrumentType.Stock, ConsGlobal.InstrumentType.StockIF };
SendEmailSettleBillHtml(sb, res);
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(tempalte.Value, new MailInfoRequestModel
var mailRequest = new MailInfoRequestModel
{
ClientName = client.Name,
TradeDate = valueDate.ToString("yyyy-MM-dd"),
TradeList = sb.ToString(),
DocNumber = SettleBills.Count > 0 ? SettleBills[0].Code : string.Empty
}) ?? new MailInfoResultModel
};
SettlementMailTemplateHelper.PopulateSettlementFields(mailRequest, res);
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(tempalte.Value, mailRequest) ?? new MailInfoResultModel
{
Body = string.Empty,
Title = "清算确认书-" + valueDate.ToString("yyyy-MM-dd")
@@ -0,0 +1,107 @@
using System.Globalization;
using Microsoft.EntityFrameworkCore;
using YLErp.BLL;
using YLErp.Modules.AppModule;
namespace YLErp.Modules.ReportModule
{
/// <summary>
/// 为结算确认书邮件准备交易编号和平仓总额模板变量。
/// </summary>
public static class SettlementMailTemplateHelper
{
/// <summary>
/// 根据当前客户的确认书反查对应交易和平仓记录。
/// 文档编号可能在不同文档类型中重复,因此必须同时匹配编号和类型。
/// </summary>
public static List<Datas> GetSettlementRows(YLContext dbContext, IEnumerable<trade_contract_document> documents, int clientId)
{
var documentKeys = documents
.Where(document => !string.IsNullOrWhiteSpace(document.Code))
.Select(document => BuildDocumentKey(document.Code, document.Type))
.Distinct()
.ToHashSet();
if (documentKeys.Count == 0)
{
return new List<Datas>();
}
var contractCodes = documents.Select(document => document.Code).Distinct().ToList();
var rows = (from relation in dbContext.trade_contract_r
join tradeCash in dbContext.trade_cash on relation.TradeCashId equals tradeCash.id
join tradeData in dbContext.trade on tradeCash.TradeId equals tradeData.id
where contractCodes.Contains(relation.ContractCode)
&& relation.IsValid
&& !tradeCash.IsDeleted
&& tradeData.ClientId == clientId
select new
{
relation.ContractCode,
relation.Type,
Trade = tradeData,
TradeCash = tradeCash
}).AsNoTracking().ToList();
return rows
// 数据库先按编号缩小范围,再在内存中用“编号+类型”精确匹配附件。
.Where(row => documentKeys.Contains(BuildDocumentKey(row.ContractCode, row.Type)))
.Select(row => new Datas
{
trade = row.Trade,
tradecash = row.TradeCash
}).ToList();
}
/// <summary>
/// 将批量加载的数据限制到当前结算日,避免跨日发送时变量汇总串日。
/// </summary>
public static List<Datas> ForValueDate(IEnumerable<Datas> rows, DateTime valueDate)
{
return (rows ?? Enumerable.Empty<Datas>())
.Where(row => row?.tradecash != null && row.tradecash.ValueDate.Date == valueDate.Date)
.ToList();
}
/// <summary>
/// 将去重后的交易编号及客户视角平仓总额写入邮件模板参数。
/// </summary>
public static void PopulateSettlementFields(MailInfoRequestModel request, IEnumerable<Datas> rows)
{
ArgumentNullException.ThrowIfNull(request);
// 同一平仓记录可能因关联多份文档重复出现,按 trade_cash.id 去重以避免重复计入金额。
var distinctRows = (rows ?? Enumerable.Empty<Datas>())
.Where(row => row?.trade != null && row.tradecash != null)
.GroupBy(row => row.tradecash.id)
.Select(group => group.First())
.ToList();
request.TradeNumber = string.Join(",", distinctRows
.Select(row => row.trade.TradeNumber)
.Where(number => !string.IsNullOrWhiteSpace(number))
.Distinct()
.OrderBy(number => number));
if (distinctRows.Count == 0)
{
request.UnwindTotalAmount = string.Empty;
return;
}
// trade_cash.Amount 为交易员视角,邮件展示需取反转换为客户视角。
var customerAmount = -distinctRows.Sum(row => row.tradecash.Amount);
if (customerAmount == 0)
{
// 消除浮点求和可能产生的 -0.00。
customerAmount = 0;
}
request.UnwindTotalAmount = customerAmount.ToString("F2", CultureInfo.InvariantCulture);
}
private static string BuildDocumentKey(string code, string type)
{
return $"{code}\u001f{type}";
}
}
}
@@ -425,13 +425,17 @@ namespace YLErp.Modules.ReportModule
if (fileList.Count == 0) { continue; }
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(useTemplate.Value, new MailInfoRequestModel
// 根据当前客户的提前终止确认书准备交易编号和平仓总额变量。
var settlementRows = SettlementMailTemplateHelper.GetSettlementRows(DbContext, SettleBills, client.id);
var mailRequest = new MailInfoRequestModel
{
ClientName = client.Name,
TradeDate = valueDate.ToString("yyyy-MM-dd"),
DocNumber = SettleBills.Count > 0 ? SettleBills[0].Code : string.Empty
}) ?? new MailInfoResultModel
};
SettlementMailTemplateHelper.PopulateSettlementFields(mailRequest, settlementRows);
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(useTemplate.Value, mailRequest) ?? new MailInfoResultModel
{
Body = string.Empty,
Title = "清算确认书-" + valueDate.ToString("yyyy-MM-dd")
@@ -601,7 +605,9 @@ trades.GroupBy(O => new { UnderlyingInstrumentType = instrumentTypeArr.Contains(
foreach (var client in clientListOn)
{
var SettleBills = theDaySettleDocList.Where(t => t.SellerId == client.id || t.BuyerId == client.id).ToList();
var res = ts.Where(O => O.trade.ClientId == client.id).ToList();
// 批量查询包含整个日期区间,此处仅保留当前结算日的数据。
var res = SettlementMailTemplateHelper.ForValueDate(
ts.Where(O => O.trade.ClientId == client.id), valueDate);
if (SettleBills.Count < 1)
{
continue;
@@ -627,14 +633,16 @@ trades.GroupBy(O => new { UnderlyingInstrumentType = instrumentTypeArr.Contains(
var sb = new StringBuilder();
SendEmailSettleBillHtml(sb, res);
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(tempalte.Value, new MailInfoRequestModel
var mailRequest = new MailInfoRequestModel
{
ClientName = client.Name,
TradeDate = valueDate.ToString("yyyy-MM-dd"),
TradeList = sb.ToString(),
DocNumber = SettleBills.Count > 0 ? SettleBills[0].Code : string.Empty
}) ?? new MailInfoResultModel
};
SettlementMailTemplateHelper.PopulateSettlementFields(mailRequest, res);
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(tempalte.Value, mailRequest) ?? new MailInfoResultModel
{
Body = string.Empty,
Title = "清算确认书-" + valueDate.ToString("yyyy-MM-dd")
@@ -889,13 +897,17 @@ trades.GroupBy(O => new { UnderlyingInstrumentType = instrumentTypeArr.Contains(
}
if (fileList.Count == 0) { continue; }
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(useTemplate.Value, new MailInfoRequestModel
// 合并发送时按当前客户附件反查记录,确保模板变量与附件范围一致。
var settlementRows = SettlementMailTemplateHelper.GetSettlementRows(DbContext, SettleBills, client.id);
var mailRequest = new MailInfoRequestModel
{
ClientName = client.Name,
TradeDate = valueDate.ToString("yyyy-MM-dd"),
DocNumber = SettleBills.Count > 0 ? SettleBills[0].Code : string.Empty
}) ?? new MailInfoResultModel
};
SettlementMailTemplateHelper.PopulateSettlementFields(mailRequest, settlementRows);
//组织邮件主体
var mailInfo = EmailTemplateService.GenerateMailInfo(useTemplate.Value, mailRequest) ?? new MailInfoResultModel
{
Body = string.Empty,
Title = "清算确认书-" + valueDate.ToString("yyyy-MM-dd")
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule.Margin;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace YLErp.Modules.SwapModule
@@ -180,5 +182,103 @@ namespace YLErp.Modules.SwapModule
{
position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee;
}
/// <summary>
/// 计算 EQD-7084 新“框架合约”Tab 的纯展示口径。
/// 浮动腿盯市收益、开平仓费用和普通利息分别计算;保证金腿的利息
/// 仅作为估值组成项保留一次,不混入新 Tab 的普通利息列。
/// </summary>
public static EodSwapRiskNewFields CalculateEodSwapRiskNewFields(
IEnumerable<eod_swap_position> floatingLegs,
IEnumerable<eod_swap_position> interestLegs,
string structureType,
decimal notionalValue,
DateTime? startDate,
DateTime? ExerciseDate,
decimal periodAmount,
int dividendPayDate)
{
// 日终明细以 UnderlyingCode 是否存在区分浮动腿和利息腿;调用方即使传入混合集合,
// 这里也会重新过滤,避免保证金/利息数据被带入浮动端新口径。
var floating = (floatingLegs ?? Enumerable.Empty<eod_swap_position>())
.Where(x => x != null && !string.IsNullOrEmpty(x.UnderlyingCode))
.ToList();
var interests = (interestLegs ?? Enumerable.Empty<eod_swap_position>())
.Where(x => x != null && string.IsNullOrEmpty(x.UnderlyingCode))
.ToList();
// MarginModes 覆盖初始/维持保证金相关腿。它们的利息不属于需求中的“利息端待实现收益”,
// 但必须单独保留,以使两个合约估值与旧口径总额保持一致。
var ordinaryInterests = interests.Where(x => !MarginModes.Contains(x.InterestMode)).ToList();
var marginInterests = interests.Where(x => MarginModes.Contains(x.InterestMode)).ToList();
var firstFloating = floating.FirstOrDefault();
// PosiGrossPrice 已是 EOD 归档口径的期初全价;债券价格不可在报表接口再次乘 100。
var initialPrice = firstFloating?.PosiGrossPrice;
// PosiFeePending 是日终归一后的我方损益方向:支付费用为负、收取费用为正。
// 本列独立展示它,下面的 valuation 再加回一次,不能因展示拆列而改变合约估值。
var openingClosingFee = floating.Sum(x => x.PosiFeePending);
// PosiMtmPnL 已排除分红和费用,避免从 PosiProfitSum 重复拆分历史费用。
var floatingUnrealizedPnl = floating.Sum(x => x.PosiMtmPnL);
var ordinaryInterestPnl = ordinaryInterests.Sum(x =>
x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode));
var marginInterestAmount = marginInterests.Sum(x =>
x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode));
// 新口径估值 = 去费用浮动收益 + 开平仓费用 + 普通利息 + 保证金利息。
// “浮动端待实现收益”列不包含费用,而合约估值仍沿用旧总额,故费用只能在此加一次。
var valuation = floatingUnrealizedPnl
+ openingClosingFee
+ ordinaryInterestPnl
+ marginInterestAmount;
var result = new EodSwapRiskNewFields
{
UnderlyingInstrumentType = firstFloating?.UnderlyingInstrumentType,
UnderlyingDirection = string.Join(",", floating
.Select(x => x.PositionType == (int)PositionTypeFlag.Long ? "多头"
: x.PositionType == (int)PositionTypeFlag.Short ? "空头" : "")
.Where(x => !string.IsNullOrEmpty(x))
.Distinct()),
UnderlyingCode = string.Join(",", floating
.Select(x => x.UnderlyingCode)
.Where(x => !string.IsNullOrEmpty(x))
.Distinct()),
InitialPrice = initialPrice,
NotionalQuantity = notionalValue,
ContractStartDate = startDate,
ContractMaturityDate = ExerciseDate,
// 只要普通利息腿存在 FR007,即按需求显示 FR007;保证金腿不影响该展示基准。
InterestBenchmark = ordinaryInterests.Any(x =>
!string.IsNullOrWhiteSpace(x.FloatRateUnderlyingCode)
&& x.FloatRateUnderlyingCode.IndexOf("FR007", StringComparison.OrdinalIgnoreCase) >= 0)
? "FR007" : "固定利率",
// 使用日终当日实际适用的 TdInterestRate 合计,而非合同初始利率或利差字段。
InterestRatePrice = ordinaryInterests.Sum(x => x.TdInterestRate),
OpeningClosingFee = -openingClosingFee,
// 合约浮动端待实现收益
FloatingUnrealizedPnl = floatingUnrealizedPnl,
// 合约利息端待实现收益
OrdinaryInterestPnl = ordinaryInterestPnl,
MarginInterestAmount = marginInterestAmount,
MarginInterestGain = marginInterests
.Where(x => x.InterestDirection == (int)SwapDirectionEnum.)
.Sum(x => Math.Abs(x.InterestIncomeSum)),
MarginInterestLoss = marginInterests
.Where(x => x.InterestDirection == (int)SwapDirectionEnum.)
.Sum(x => -Math.Abs(x.InterestIncomeSum))
};
// DividendPayDate=0 表示到期才与本金轧差,期间付息/分红需要加进该口径;
// 其余支付方式则由现金支付承担期间金额,估值字段不再包含 periodAmount。
if (dividendPayDate == 0)
{
result.MaturityNettingValuation = valuation + periodAmount;
}
else
{
result.PeriodPaymentValuation = valuation;
}
return result;
}
}
}
@@ -40,6 +40,49 @@ public static class FundTagCalc
return plans;
}
/// <summary>
/// 保存前授信拆单(§2.3 保存前拆单,2026-08-26 业务确认):把确认成交阶段的物理拆分前移到录入保存——
/// 对 NeedSplit 的腿:原腿保留授信部分(InterestPrincipalFix 按可用额度折算)标 Credit,
/// 克隆一条现金差额腿(倒挤守恒)标 Cash 返回(Obervation 置空,防 SaveSwapPositions 重复插观察配置);
/// 不拆的授信偏好腿同步定稿标签:全额授信→Credit、额度为0/耗尽全额现金→Cash;
/// 现金/默认腿不动(最终定稿仍由确认成交 ApplyMarginFundTags 兜底重写)。
/// legs 与 plans 须为 AllocateByLegPreference 的同序输入输出。占用/流水仍发生在确认成交。
/// </summary>
public static List<swap_position> ApplySaveTimeSplit(List<LegAmount> legs, List<LegFundPlan> plans)
{
var newLegs = new List<swap_position>();
for (var i = 0; i < plans.Count; i++)
{
if (!legs[i].PreferCredit)
{
continue;
}
var plan = plans[i];
if (plan.NeedSplit)
{
var position = plan.Leg;
//应付额 = fix × (dir==1 ? 1 : -1),反推 fix 用同一比例(±1 自反)
var payableRatio = position.InterestDirection == 1 ? 1 : -1;
var originalFix = position.InterestPrincipalFix;
position.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CreditAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
position.FundTag = ConsFundTag.Credit;
var cashLeg = position.Clone();
cashLeg.id = 0;
cashLeg.PositionId = 0;
cashLeg.Obervation = null;
//现金腿倒挤 = 原 fix − 授信 fix(分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒)
cashLeg.InterestPrincipalFix = originalFix - position.InterestPrincipalFix;
cashLeg.FundTag = ConsFundTag.Cash;
newLegs.Add(cashLeg);
}
else
{
plan.Leg.FundTag = plan.CreditAmount > 0 ? ConsFundTag.Credit : ConsFundTag.Cash;
}
}
return newLegs;
}
/// <summary>
/// 平仓/到期返还金额按被平仓腿的 FundTag 分流(§2.4):
/// Credit 腿的返还本金与返息不产生资金流水(本金写授信出入表"释放",出金方向记正数),Cash/无标签(存量)走现金。
@@ -102,8 +145,8 @@ public class LegFundPlan
public double CreditAmount { get; set; }
/// <summary>现金部分金额</summary>
public double CashAmount { get; set; }
/// <summary>拆单时新拆出的授信腿(占用记录绑定到它)</summary>
public swap_position CreditLeg { get; set; }
/// <summary>拆单时新拆出的现金腿(授信不足的差额;占用记录绑原腿、现金流水绑它)</summary>
public swap_position CashLeg { get; set; }
public bool NeedSplit => CreditAmount > 0 && CashAmount > 0;
}
@@ -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>
@@ -357,6 +357,19 @@ namespace YLErp.Modules.SwapModule
directionRatio);
}
protected virtual decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate,
decimal qty, int shortRatio, int directionRatio, decimal? corporateActionQty)
{
if (!corporateActionQty.HasValue)
{
return CalcBondPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
}
var service = new BondPaymentService(UserInfo);
var payments = service.GetBondPayments(underlyingCode, fromDate, toDate);
return service.CalcPayment(payments, qty, shortRatio, directionRatio, corporateActionQty);
}
// ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)----
/// <summary>查找收盘所需的活跃互换交易(生产: DbContext.trade.Where;测试: 内存列表)</summary>
@@ -439,7 +452,7 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// 获取公司行为公式使用的收盘价。
/// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日
/// ExDividendDate;不能在 8 月 17 日 EOD 误取 8 月 17 日收盘价重算 8 月 14
/// ExDividendDate;不能在 除权日 EOD 误取 除权日收盘价重算 登记
/// 登记日形成的系数。测试实现可以返回快照中的回退值,生产实现从登记日行情读取。
/// </summary>
protected virtual decimal GetFundCorporateActionClosePrice(
@@ -516,18 +529,22 @@ namespace YLErp.Modules.SwapModule
// 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘,
// 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。
var corporateActionInfos = FindCorporateActionInfos(settleDate) ?? new List<ex_dividend_info>();
// 除权日信息
var exDividendInfos = corporateActionInfos
.Where(x => x != null
&& x.ValidStatus
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value.Date == settleDate.Date)
.ToList();
// 登记日信息
var registrationInfos = corporateActionInfos
.Where(x => x != null
&& x.ValidStatus
&& x.ExDividendDate.HasValue
&& x.ExDividendDate.Value.Date == settleDate.Date)
.ToList();
// 公司行为去重 - 除权日
var duplicateDividend = exDividendInfos
.GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(x => x.Count() > 1);
@@ -535,7 +552,8 @@ namespace YLErp.Modules.SwapModule
{
throw new InvalidOperationException($"标的【{duplicateDividend.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效除权记录");
}
// 公司行为去重 - 拦截
// 公司行为去重 - 登记日
var duplicateRegistration = registrationInfos
.GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(x => x.Count() > 1);
@@ -545,6 +563,8 @@ namespace YLErp.Modules.SwapModule
// 有多条有效记录时,系统无法证明应采用哪一条派现金额,必须中止收盘。
throw new InvalidOperationException($"标的【{duplicateRegistration.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效登记日记录");
}
// 根据标的代码 创建map
var exDividendByCode = exDividendInfos.ToDictionary(
x => x.UnderlyingCode,
x => x,
@@ -588,22 +608,27 @@ namespace YLErp.Modules.SwapModule
var flowEvents = FindFlowEvents(td.id, settleDate);
var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期
List<swap_flow_event> autoInterests = new List<swap_flow_event>();//自动互换利息腿信息
// 处理浮动腿前先准备当日开盘基线:登记日 EOD 仍保存
// 1000 份/100 元,除权日收盘时先把上一 EOD 的基线转换为
// 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。
// 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到
// 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。
// 重置基线
// 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,
// 否则会错误得到 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。
// 重置基线 - 除权日
var openingEodPositions = PrepareFundOpeningEodPositions(
eodPositions,
eodPositions, // 上一日终持仓
exDividendByCode,
settleDate);
// 构建公司行为前eod持仓
var corporateActionBeforePositions = BuildCorporateActionBeforePositions(
eodPositions,
eodPositions, // 上一日终持仓
posiList);
var corporateActionCashDividendBeforePositions = corporateActionBeforePositions
.Where(position => !string.IsNullOrWhiteSpace(position.UnderlyingCode)
&& exDividendByCode.TryGetValue(position.UnderlyingCode, out var dividend)
&& dividend.GiveCashAmount != 0m)
.ToList();
// 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线,应用生效日公司行为。
// 有上一份 EOD 时沿用 PrepareFundOpeningEodPositions,避免重复套系数。
@@ -613,18 +638,20 @@ namespace YLErp.Modules.SwapModule
// 处理浮动腿归档
var curEodPosis = DealFloatPositions(
floatPositionsForCompose,
realPosiList,
openingEodPositions,
todyEodPositions,
settleDate,
td,
preSettleDate,
flowEvents);
floatPositionsForCompose, // 初始腿
realPosiList, // 实时腿
openingEodPositions, // 开盘基线
todyEodPositions, // 当日终持仓
settleDate, // 收盘日期
td, // 交易
preSettleDate, // 上一交易日
flowEvents, // 流水事件
corporateActionCashDividendBeforePositions);
// 现金分红不在登记日直接累加;Copy/Update EOD 通过 CalcBondPayment
// 读取 EffectiveDate 命中的 ex_dividend_info,并生成 TdPosiDividend。
// 这样登记日快照不提前变化,且公司行为分红与债券付息共用同一待实现余额。
// 公司行为事件
RecordCorporateActionEvents(
td,
curEodPosis,
@@ -632,8 +659,9 @@ namespace YLErp.Modules.SwapModule
registrationInfos,
exDividendInfos,
settleDate);
// 登记日 EOD 仍保存除权前快照,但下一交易日开盘读取的实时浮动腿需要
// 先切换到生效后的 Q/P。该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大
// 登记日 EOD 仍保存除权前快照,
// 但下一交易日开盘读取的实时浮动腿需要先切换到生效后的 Q/P。
// 该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大。
UpdateRealtimeCorporateActionPositions(td, curEodPosis, registrationInfos, exDividendInfos, settleDate);
var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue);
var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue);
@@ -668,8 +696,8 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Stock/Fund 公司行为。
/// 原始上一 EOD 只读保留在数据库中,确保登记日 EOD 报表仍展示除权前 Q/P。
/// 例如 1000 份/100 元、10 送 10 的记录在 8 月 14 日 EOD 仍是 1000/100
/// 8 月 17 日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。
/// 例如 1000 份/100 元、10 送 10 的记录在 登记日 EOD 仍是 1000/100
/// 除权日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。
/// </summary>
protected List<eod_swap_position> PrepareFundOpeningEodPositions(
IReadOnlyCollection<eod_swap_position> previousEodPositions,
@@ -729,6 +757,7 @@ namespace YLErp.Modules.SwapModule
var dividendTaxRate = 0m;
foreach (var position in positions)
{
// 不是浮动腿 或者 不是 Fund Stock类型的标的 或者 没有除权信息 或者 除权日不是结算日 - 跳过
if (position.PosiDirection <= 0
|| !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType)
|| string.IsNullOrWhiteSpace(position.UnderlyingCode)
@@ -739,7 +768,7 @@ namespace YLErp.Modules.SwapModule
continue;
}
// 获取除权参考价
// 获取除权参考价 - 登记日收盘价
var corporateActionClosePrice = GetFundCorporateActionClosePrice(
dividendInfo,
position.UnderlyingPrice);
@@ -773,8 +802,11 @@ namespace YLErp.Modules.SwapModule
position.PosiNetFeePrice = adjusted.NetFeePrice;
position.PosiNetNoFeePrice = adjusted.NetNoFeePrice;
// 多空方向
var shortRatio = DirectionRatio.LongShort(position.PositionType);
// 收付方向
var directionRatio = DirectionRatio.ReceivePay(position.PosiDirection);
// 处理价格的正负号(收支方向)
position.PosiNotionalValue = Math.Round(
position.PosiGrossPrice * position.PosiQuantity * position.ContractSize,
ConsGlobal.MoneyRound,
@@ -900,8 +932,9 @@ namespace YLErp.Modules.SwapModule
return;
}
// 登记日收盘后即切换实时 BOD。EffectiveDate 只用于确认这条记录仍是未来生效的
// 公司行为;无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。
// 登记日收盘后即切换实时 BOD。
// EffectiveDate 只用于确认这条记录仍是未来生效的公司行为;
// 无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。
var pendingInfos = (registrationInfos ?? Array.Empty<ex_dividend_info>())
.Where(x => x.EffectiveDate.HasValue && x.EffectiveDate.Value.Date > settleDate.Date)
.ToList();
@@ -912,6 +945,7 @@ namespace YLErp.Modules.SwapModule
&& IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType)
&& !string.IsNullOrWhiteSpace(x.UnderlyingCode)))
{
// 实时腿
var realtime = DbContext.swap_position.FirstOrDefault(x => x.SwapTradeId == td.id
&& !x.Invalid
&& !x.IsInitial
@@ -920,7 +954,8 @@ namespace YLErp.Modules.SwapModule
{
continue;
}
// 对每条当日 EOD 浮动腿,按标的代码在 pendingInfos 中找匹配的公司行为。
var pending = pendingInfos.FirstOrDefault(x => string.Equals(
x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase));
if (pending != null)
@@ -966,6 +1001,7 @@ namespace YLErp.Modules.SwapModule
return;
}
// 登记日信息合并除权日信息
var infos = (registrationInfos ?? Array.Empty<ex_dividend_info>())
.Concat(effectiveInfos ?? Array.Empty<ex_dividend_info>())
.Where(x => x != null && x.ValidStatus && !string.IsNullOrWhiteSpace(x.UnderlyingCode))
@@ -983,6 +1019,7 @@ namespace YLErp.Modules.SwapModule
return;
}
// 跟据交易id查当前交易关联事件
var existingEvents = FindCorporateActionEvents(td.id);
foreach (var current in currentPositions.Where(x => x != null && x.PosiDirection > 0
&& IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType)))
@@ -1004,16 +1041,18 @@ namespace YLErp.Modules.SwapModule
&& x.Data.ExDividendInfoId == info.id
&& x.Data.PositionId == current.PositionId)
.ToList();
// 寻找applied = false的(登记日记录的)
var eventData = matchingEvents.FirstOrDefault(x => !x.Data.Applied)
?? matchingEvents.FirstOrDefault();
var previous = previousPositions?.FirstOrDefault(x => x != null && x.PositionId == current.PositionId);
var previous = previousPositions?.FirstOrDefault(x => x != null
&& x.PositionId == current.PositionId);
// 登记日 false 除权日 true
var isEffective = info.EffectiveDate.HasValue
&& info.EffectiveDate.Value.Date <= settleDate.Date
&& effectiveInfos != null
&& effectiveInfos.Any(x => x.id == info.id);
// 如果没有匹配到事件或事件未生效,则创建新事件。
// 如果没有匹配到事件或今天不是除权日 但找到的事件的applied=true(异常事件/重收盘),则创建新事件。
if (eventData == null || (!isEffective && eventData.Data.Applied))
{
// 创建新事件
@@ -1184,6 +1223,7 @@ namespace YLErp.Modules.SwapModule
decimal dividendTaxRate,
int grossPriceRound)
{
// 计算除权系数 - adjustCashDividendPrice = false (现金分红模式)
var factors = DividendService.CalculateCorporateActionFactors(
dividendInfo,
closePrice,
@@ -1414,7 +1454,8 @@ namespace YLErp.Modules.SwapModule
DateTime settleDate,
trade td,
DateTime preSettleDate,
List<swap_flow_event> flowEvents)
List<swap_flow_event> flowEvents,
IReadOnlyCollection<eod_swap_position> corporateActionBeforePositions = null)
{
string settleDateStr = settleDate.ToString("yyyy-MM-dd");
string preSettleDateStr = preSettleDate.ToString("yyyy-MM-dd");
@@ -1436,18 +1477,20 @@ namespace YLErp.Modules.SwapModule
var tdEodPosition = todyEodPositions.FirstOrDefault(x => x.PositionId == posi.id);//当前结算日日终持仓信息
var unwindEvents = flowEvents.Where(x => x.PositionId == posi.id).ToList();//当前日平仓信息
var realPosition = realPosiList.FirstOrDefault(s => s.PositionId == posi.id);
var corporateActionBeforeQuantity = corporateActionBeforePositions?
.FirstOrDefault(x => x.PositionId == posi.id)?.PosiQuantity;
eod_swap_position eodPosi = new eod_swap_position();
if (eodPosition == null)
{
eodPosi = SaveCurrentEodInitalPosi(posi, td, settleDate, preSettleDate, unwindEvents);
eodPosi = SaveCurrentEodInitalPosi(posi, td, settleDate, preSettleDate, unwindEvents, corporateActionBeforeQuantity);
}
else if (unwindEvents.Count() == 0)
{
eodPosi = CopyEodPosition(eodPosition, tdEodPosition, td, settleDate, preSettleDate);
eodPosi = CopyEodPosition(eodPosition, tdEodPosition, td, settleDate, preSettleDate, corporateActionBeforeQuantity);
}
else
{
eodPosi = UpdateEodPosition(posi, eodPosition, tdEodPosition, td, settleDate, preSettleDate, unwindEvents);
eodPosi = UpdateEodPosition(posi, eodPosition, tdEodPosition, td, settleDate, preSettleDate, unwindEvents, corporateActionBeforeQuantity);
}
Log.Info($"eodPosi为:{JsonHelper.Serialize(eodPosi, false)}");
list.Add(eodPosi);
@@ -2634,7 +2677,7 @@ namespace YLErp.Modules.SwapModule
/// <param name="todayPositions">当日日终归档信息</param>
/// <param name="swap_Deals">当日平仓/互换事件信息</param>
/// <param name="td">交易信息</param>
protected eod_swap_position CopyEodPosition(eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate)
protected eod_swap_position CopyEodPosition(eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate, decimal? corporateActionBeforeQuantity = null)
{
if (curretEod == null)
{
@@ -2656,7 +2699,7 @@ namespace YLErp.Modules.SwapModule
decimal tax = um.ValueAddedTax ?? 0;
if (valueDate > td.StartDate.Value && curretEod.PosiQuantity > 0)
{
decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio);
decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio, corporateActionBeforeQuantity);
curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax);
}
curretEod.PosiDividendSum = eod.PosiQuantity > 0 ? Math.Round(eod.PosiDividendSum + curretEod.TdPosiDividend, 2) : 0;
@@ -2718,7 +2761,7 @@ namespace YLErp.Modules.SwapModule
/// <param name="curretEod"></param>
/// <param name="td"></param>
/// <param name="valueDate"></param>
protected eod_swap_position UpdateEodPosition(swap_position swapPosition, eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate, List<swap_flow_event> unwindEvents)
protected eod_swap_position UpdateEodPosition(swap_position swapPosition, eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate, List<swap_flow_event> unwindEvents, decimal? corporateActionBeforeQuantity = null)
{
if (curretEod == null)
{
@@ -2752,7 +2795,7 @@ namespace YLErp.Modules.SwapModule
// 修改,互换事件会影响待实现的分红的,现在要算上
if (valueDate > td.StartDate.Value && (curretEod.PosiQuantity > 0))
{
decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio);
decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio, corporateActionBeforeQuantity);
curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax);
}
curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl;
@@ -2879,7 +2922,8 @@ namespace YLErp.Modules.SwapModule
/// <param name="position"></param>
/// <param name="td"></param>
/// <param name="settleDate"></param>
protected eod_swap_position SaveCurrentEodInitalPosi(swap_position position, trade td, DateTime settleDate, DateTime preSettleDate, List<swap_flow_event> unwindEvents)
protected eod_swap_position SaveCurrentEodInitalPosi(swap_position position, trade td, DateTime settleDate,
DateTime preSettleDate, List<swap_flow_event> unwindEvents, decimal? corporateActionBeforeQuantity = null)
{
eod_swap_position curretEod = new eod_swap_position();
var um = GetUnderlyingData(position.UnderlyingCode);
@@ -2932,7 +2976,7 @@ namespace YLErp.Modules.SwapModule
if (!hasSwapEvent && settleDate > td.StartDate.Value && curretEod.PosiQuantity > 0)
{
decimal tax = um.ValueAddedTax ?? 0;
decimal payment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, settleDate, curretEod.PosiQuantity, shortRatio, directionRatio);
decimal payment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, settleDate, curretEod.PosiQuantity, shortRatio, directionRatio, corporateActionBeforeQuantity);
payment = DividendCalc.AfterTax(payment, tax);
//var consumedDividend = CalcConsumedDividend(curretEod, unwindEvents); 首日应该没有分红
curretEod.TdPosiDividend = payment;
@@ -3370,6 +3414,91 @@ namespace YLErp.Modules.SwapModule
return retListResult;
}
/// <summary>
/// 查询 EQD-7084 新“框架合约”字段。
/// 旧查询负责筛选、排序、分页及旧字段计算;新字段只基于当前页对应的日终腿补充计算,
/// 避免改变旧接口的返回口径。
/// </summary>
public SearchListResult<EodSwapRiskNewResponse> SearchEodSwapNewList(EodSwapQueryRequest req)
{
// 新 Tab 与旧 Tab 共享同一套权限、筛选、排序和分页边界;先复用旧查询,
// 再只替换需求明确调整的展示字段,避免新接口悄然改变旧口径或查询范围。
var oldResult = SearchEodSwapList(req);
var oldRows = oldResult.rows?.ToList() ?? new List<EodSwapResponse>();
var tradeIds = oldRows.Select(x => x.position.SwapTradeId).Distinct().ToList();
var valueDates = oldRows.Select(x => x.position.ValueDate).Distinct().ToList();
if (tradeIds.Count == 0)
{
return new SearchListResult<EodSwapRiskNewResponse>(oldResult,
Enumerable.Empty<EodSwapRiskNewResponse>());
}
// 当前页的交易、日终明细和扩展信息各批量读取一次,随后在内存按“交易 + 日终日”配对。
// 不在 rows.Select 内查询数据库,避免分页结果产生 N+1 查询。
var trades = DbContext.trade
.Where(x => tradeIds.Contains(x.id))
.Select(x => new { x.id, x.StartDate, x.ExerciseDate })
.ToDictionary(x => x.id);
var eodPositionDetails = DbContext.eod_swap_position
.Where(x => tradeIds.Contains(x.SwapTradeId)
&& valueDates.Contains(x.ValueDate)
&& !x.Invalid)
.ToList();
var tradeExtends = DbContext.trade_extend
.Where(x => tradeIds.Contains(x.TradeId))
.ToList();
var rows = oldRows.Select(item =>
{
// 同一交易可出现在多个日终日;必须同时匹配 ValueDate,不能把其他日期的腿混入本行。
var details = eodPositionDetails
.Where(x => x.SwapTradeId == item.position.SwapTradeId
&& x.ValueDate == item.position.ValueDate)
.ToList();
var floatingLegs = details.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();
var interestLegs = details.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();
var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId);
// 缺少扩展信息时按“期间支付”处理,和旧接口的默认值保持一致。
var dividendPayDate = tradeExtend?.ExtendObj?.DividendPayDate ?? 1;
trades.TryGetValue(item.position.SwapTradeId, out var tradeInfo);
return new EodSwapRiskNewResponse
{
position = item.position,
TradeDate = item.TradeDate,
SwapTradeNo = item.SwapTradeNo,
ClientName = item.ClientName,
StructureType = item.StructureType,
AssetBookName = item.AssetBookName,
ClientId = item.ClientId,
SwapTradeTypeStr = item.SwapTradeTypeStr,
UnderlyingType = item.UnderlyingType,
PeriodAmount = item.PeriodAmount,
FloatingUnrealizedPnl = item.FloatingUnrealizedPnl,
InterestPaymentMethod = item.InterestPaymentMethod,
MaturityNettingValuation = item.MaturityNettingValuation,
PeriodPaymentValuation = item.PeriodPaymentValuation,
MarginInterestGain = item.MarginInterestGain,
MarginInterestLoss = item.MarginInterestLoss,
// 所有 EQD-7084 差异集中在 NewFields;上方复制的旧字段用于保留原报表的
// 基本信息、DV、期间金额及已实现收益,前端再将六个差异列绑定到 NewFields。
NewFields = CalculateEodSwapRiskNewFields(
floatingLegs,
interestLegs,
item.StructureType,
item.position.NotionalValue,
tradeInfo?.StartDate,
tradeInfo?.ExerciseDate,
item.PeriodAmount,
dividendPayDate)
};
}).ToList();
return new SearchListResult<EodSwapRiskNewResponse>(oldResult, rows);
}
/// <summary>
/// 获取互换交易日终持仓数据
/// </summary>
@@ -3448,6 +3577,29 @@ namespace YLErp.Modules.SwapModule
return retListResult;
}
/// <summary>
/// 计算 EQD-7084 新“框架合约”Tab 的字段口径。
/// 纯函数只依赖日终浮动腿、利息腿和交易级展示参数,供查询接口及无库单测共用。
/// </summary>
public static EodSwapRiskNewFields CalculateEodSwapRiskNewFields(
IEnumerable<eod_swap_position> floatingLegs,
IEnumerable<eod_swap_position> interestLegs,
string structureType,
decimal notionalValue,
DateTime? startDate,
DateTime? ExerciseDate,
decimal periodAmount,
int dividendPayDate)
=> EodPnlCalculator.CalculateEodSwapRiskNewFields(
floatingLegs,
interestLegs,
structureType,
notionalValue,
startDate,
ExerciseDate,
periodAmount,
dividendPayDate);
/// <summary>
/// 互换持仓明细查询
/// </summary>
@@ -3590,7 +3742,7 @@ namespace YLErp.Modules.SwapModule
else if (isEtf)
{
item.PeriodAmount = null;
item.DividendAmount = pendingDividend;
item.DividendAmount = -pendingDividend; // 每日估值报告是客户视角 取值与日终持仓风险相反
}
else
{
@@ -3634,9 +3786,10 @@ namespace YLErp.Modules.SwapModule
{
item.FloatRateAbs = item.position.PosiNotionalValue == 0 ? 0 : item.InterestAmount / item.position.PosiNotionalValue;
}
// 交易录入的债券类收益互换价格以小数保存,展示时转为百分比价格
// 普通收益互换录入的是数量/原始数值,不做乘 100 转换。
SetPosiPrice(item.position, item.StructureType == "普通债券类收益互换");
// 是否 ×100 由标的资产类型决定(债券价格以小数保存,展示时转为百分比价格),
// 与存储层 GetStorageDeliveryPriceRound / GetSwapValuationPrice 的 IsBond 口径一致,
// 不依赖簿记结构类型 StructureType。
SetPosiPrice(item.position);
}
return retListResult;
}
@@ -3681,10 +3834,10 @@ namespace YLErp.Modules.SwapModule
position.SwapPositionValue = -position.SwapPositionValue;
position.PosiDividendSum = -position.PosiDividendSum;
}
private void SetPosiPrice(eod_swap_position position, bool? useBondPriceScale = null)
private void SetPosiPrice(eod_swap_position position)
{
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(position.UnderlyingCode);
if (useBondPriceScale ?? (um != null && um.IsBond()))
if (um != null && um.IsBond())
{
position.PosiNetPrice *= 100;
position.UnderlyingPrice *= 100;
+55 -31
View File
@@ -207,8 +207,8 @@ namespace YLErp.Modules.SwapModule
return events;
}
/// <summary>
/// 获取交易操作历史。登记日创建但尚未到 EffectiveDate 的公司行为事件也保留
/// 由 EventData.Applied=false 表示“待生效”,保证审计日志完整可追溯
/// 获取交易操作历史。登记日创建的待生效公司行为仍保留在审计数据中
/// 但在 EffectiveDate 将其更新为 Applied=true 前不对操作历史展示
/// </summary>
/// <param name="tradeId">交易id</param>
/// <returns></returns>
@@ -218,7 +218,30 @@ namespace YLErp.Modules.SwapModule
.Where(x => x.SwapTradeId == tradeId)
.OrderByDescending(o => o.id)
.ToList();
return list;
return FilterOperationHistory(list);
}
/// <summary>
/// 过滤尚未生效的公司行为事件。非公司行为、已生效事件和无法识别的历史事件均保留,
/// 避免过滤条件误伤既有操作记录。
/// </summary>
private static List<swap_event> FilterOperationHistory(IEnumerable<swap_event> events)
{
if (events == null)
{
return new List<swap_event>();
}
return events
.Where(x => !IsPendingCorporateActionEvent(x))
.ToList();
}
private static bool IsPendingCorporateActionEvent(swap_event swapEvent)
{
return swapEvent?.EventType == (int)SwapEventTypeEnum.
&& TryDeserializeCorporateActionEventData(swapEvent, out var data)
&& !data.Applied;
}
/// <summary>
@@ -247,8 +270,8 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 公司行为说明使用稳定的键值格式,完整保留调整前后名义本金、价格、数量
/// 待实现分红和现金流变化,操作历史无需重新计算即可核对
/// 公司行为说明仅展示调整前后名义本金、期初标的价格和持仓数量
/// 便于操作历史直接比对持仓基线
/// </summary>
public static string BuildCorporateActionEventReason(CorporateActionEventData data)
{
@@ -257,37 +280,38 @@ namespace YLErp.Modules.SwapModule
return "公司行为快照为空";
}
// 使用 InvariantCulture 固定小数日期格式,说明文本不随服务器区域设置变化。
// 使用 InvariantCulture 固定小数日期格式,说明文本不随服务器区域设置变化。
string D(decimal value) => value.ToString(CultureInfo.InvariantCulture);
string Date(DateTime? value) => value.HasValue
? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
: "";
return string.Join("; ", new[]
string ActionDescription()
{
$"公司行为[{data.UnderlyingCode}]",
$"ExDividendDate={Date(data.ExDividendDate)}",
$"EffectiveDate={Date(data.EffectiveDate)}",
$"ExDividendInfoId={data.ExDividendInfoId}",
$"PositionId={data.PositionId}",
$"GiveCashAmount={D(data.GiveCashAmount)}",
$"GiveShareAmount={D(data.GiveShareAmount)}",
$"Split={(data.Split.HasValue ? D(data.Split.Value) : "")}",
$"RationedSharesAmount={D(data.RationedSharesAmount)}",
$"RationedSharesPrice={D(data.RationedSharesPrice)}",
"调整前",
$"BeforeNotional={D(data.BeforeNotional)}",
$"BeforePrice={D(data.BeforePrice)}",
$"BeforeQuantity={D(data.BeforeQuantity)}",
$"BeforePendingDividend={D(data.BeforePendingDividend)}",
"调整后",
$"AfterNotional={D(data.AfterNotional)}",
$"AfterPrice={D(data.AfterPrice)}",
$"AfterQuantity={D(data.AfterQuantity)}",
$"AfterPendingDividend={D(data.AfterPendingDividend)}",
$"CashFlowChange={D(data.CashFlowChange)}",
$"Applied={data.Applied}"
});
if (data.RationedSharesAmount != 0m)
{
return "配股";
}
if (data.GiveShareAmount != 0m)
{
return "送股";
}
if (data.Split.HasValue && data.Split.Value != 1m)
{
return "拆分";
}
if (data.GiveCashAmount != 0m)
{
// return $"产生分红:{D(data.CashFlowChange)}";
return $"产生分红";
}
return "公司行为";
}
return $"股权登记日:{Date(data.ExDividendDate)} 发生公司行为({ActionDescription()}"
+ Environment.NewLine
+ $"调整前:名义本金:{D(data.BeforeNotional)} 期初标的价格:{D(data.BeforePrice)} 持仓数量:{D(data.BeforeQuantity)}"
+ Environment.NewLine
+ $"调整后:名义本金:{D(data.AfterNotional)} 期初标的价格:{D(data.AfterPrice)} 持仓数量:{D(data.AfterQuantity)}";
}
public void DeleteEvent(int tradeId)
+102 -27
View File
@@ -10,7 +10,8 @@ namespace YLErp.Modules.SwapModule
/// 标签赋值与返还两个写入口集中在本服务,授信出入表(ClientCreditInoutService)的占用/释放由此统一触发。
/// 口径:授信值取 credit.Credit 合计(已审批+日期有效+含母公司,阶段一已折算),已使用授信取授信出入表;
/// 授信不进资金——授信部分不产生资金流水。
/// 资金标签是预付金腿上的单列(swap_position.fund_tag,逐腿):录入时存用户选择(授信/现金/未选默认现金),确认成交时系统在同列定稿。
/// 资金标签是预付金腿上的单列(swap_position.fund_tag,逐腿):录入时存用户选择(授信/现金/未选回退交易级
/// margin_fund_source,交易级也未设默认现金),确认成交时系统在同列定稿。
/// </summary>
public class SwapFundTagService : YLBaseService
{
@@ -50,10 +51,81 @@ namespace YLErp.Modules.SwapModule
return GetEffectiveCredit(clientId, valueDate) - ClientCreditInoutService.GetUsedCredit(clientId, DbContext);
}
/// <summary>
/// 保存前授信拆单(§2.3 保存前拆单,2026-08-26 业务确认:保存检查授信→不足拦截→UI 确认→拆完再保存)。
/// 按当前剩余授信对偏好授信的预付金腿(腿选授信,或腿默认回退交易级资金来源=授信)做物理拆分:
/// 原腿=可用额度 标授信、克隆现金差额腿(插回 td.swap_positions 随保存落库);额度为0/耗尽的授信腿整体定稿现金。
/// 有授信不足且未带确认标记(allowSplit=false)时抛 TradeMarginCreditSplitException——
/// controller 返回 AdditionalProcessing/MarginCreditSplit 由 UI 确认后带参重提。
/// 本方法只拆腿不定簿记:授信占用/资金流水仍在确认成交 ApplyMarginFundTags。
/// </summary>
public void PreSplitMarginLegsByCredit(trade td, bool allowSplit)
{
var marginModes = new[] { (int)InterestModeEnum., (int)InterestModeEnum. };
var preferLegs = (td.swap_positions ?? new List<swap_position>())
.Where(x => marginModes.Contains(x.InterestMode)
//不扣本金的腿不产生预付金簿记(与 SwapTradeConfirm 同口径),不参与拆分
&& (x.Obervation == null || x.Obervation.IsDeductPrincipal)
&& ConsFundTag.PreferCredit(x.FundTag, td.MarginFundSource))
.ToList();
if (preferLegs.Count == 0)
{
return;
}
var valueDate = td.TradeDate ?? DateTime.Now;
var creditAvailable = GetAvailableCredit(td.ClientId, valueDate);
var allocateLegs = preferLegs
.Select(x => new LegAmount
{
Leg = x,
Amount = Convert.ToDouble(x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1)),
PreferCredit = true
})
.Where(x => x.Amount > 0)
.OrderBy(x => x.Leg.HappenDate ?? DateTime.MaxValue)
.ThenBy(x => x.Leg.id)
.ToList();
if (allocateLegs.Count == 0)
{
return;
}
var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, creditAvailable, ignoreMoneyCheck: false);
//授信不足的腿 = 偏好授信但授信没覆盖全额(含额度为0/被前腿耗尽的整体转现金)
var shortPlans = plans.Where(p => p.CreditAmount < p.Amount).ToList();
if (shortPlans.Count == 0)
{
//额度充足:全额授信腿就法定稿授信(含"默认+交易级授信"回退解析),无拆分、无拦截
FundTagCalc.ApplySaveTimeSplit(allocateLegs, plans);
return;
}
if (!allowSplit)
{
var detail = string.Join("", shortPlans.Select(p => p.NeedSplit
? $"金额 {p.Amount:#,##0.00} → 授信 {p.CreditAmount:#,##0.00} + 现金 {p.CashAmount:#,##0.00}"
: $"金额 {p.Amount:#,##0.00} → 全额现金(可用授信不足)"));
throw new TradeMarginCreditSplitException(
$"预付金授信额度不足,剩余可用授信 {Math.Max(creditAvailable, 0):#,##0.00}{detail}。"
+ "确认后将按上述拆分保存(授信部分确认成交时占用授信额度、不产生资金流水;现金部分产生应付预付金)。");
}
var newLegs = FundTagCalc.ApplySaveTimeSplit(allocateLegs, plans);
//新现金腿插回原腿之后(列表相邻,随 SaveSwapPositions 落库并分配 PosiNumber
var splitPlans = plans.Where(p => p.NeedSplit).ToList();
for (var i = 0; i < newLegs.Count; i++)
{
newLegs[i].OptId = UserId;
newLegs[i].OptName = UserName;
newLegs[i].OptTime = DateTime.Now;
var original = splitPlans[i].Leg;
var index = td.swap_positions.IndexOf(original);
td.swap_positions.Insert(index < 0 ? td.swap_positions.Count : index + 1, newLegs[i]);
}
}
/// <summary>
/// 簿记确认时对预付金腿定稿资金标签并产生资金记录(§2.3 四种情形,逐腿)。
/// fund_tag 单列:录入时存用户选择(Credit/Cash/NULL),本方法读取选择后在同列定稿——
/// 特批全现金;授信按剩余额度分配(跨界腿拆单为 授信+现金 两条),未选/现金直接现金。
/// 特批全现金;授信分配(腿选授信,或腿未选回退交易级 margin_fund_source=授信)的腿按剩余额度占用,
/// 跨界腿拆单为 授信+现金 两条(原腿保留授信部分、差额拆出新现金腿);现金直接现金。
/// 授信腿只写授信出入表占用(占用记正数,绑定腿 position_id,冗余 trade_id),不产生资金流水;
/// 现金腿走 SaveSwapTradeClientCash 幂等 upsert 产生 应付预付金 记录。
/// marginLegs 需为已过滤(IsDeductPrincipal 等)的预付金腿(InterestMode=5/6)。
@@ -74,7 +146,9 @@ namespace YLErp.Modules.SwapModule
{
Leg = x,
Amount = Convert.ToDouble(x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1)),
PreferCredit = x.FundTag == ConsFundTag.Credit
//优先级:腿上显式选择 > 交易级 margin_fund_source 回退(§2.3 情形1> 默认现金,
//与 TradeCanBeConfirm 校验分流共用 ConsFundTag.PreferCredit 保证口径一致
PreferCredit = ConsFundTag.PreferCredit(x.FundTag, td.MarginFundSource)
})
.Where(x => x.Amount > 0)
.OrderBy(x => x.Leg.HappenDate ?? DateTime.MaxValue)
@@ -82,12 +156,12 @@ namespace YLErp.Modules.SwapModule
.ToList();
var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, creditAvailable, ignoreMoneyCheck);
//先落库拆分的新腿(需要 id 才能绑定占用记录
//先落库拆分的新现金腿(需要 id 才能绑定现金流水
foreach (var plan in plans.Where(p => p.NeedSplit))
{
plan.CreditLeg = SplitLeg(td, plan);
plan.CashLeg = SplitLeg(td, plan);
}
//标签定稿(覆盖录入选择):拆单的两条腿在 SplitLeg 内已分别标 Cash/Credit
//标签定稿(覆盖录入选择):拆单的两条腿在 SplitLeg 内已分别定稿(原腿=授信、新腿=现金)
//整腿授信→Credit、整腿现金/负应付(客户净收取)腿→Cash
foreach (var leg in marginLegs)
{
@@ -110,26 +184,27 @@ namespace YLErp.Modules.SwapModule
var happenDate = leg.HappenDate ?? td.TradeDate ?? DateTime.Now;
if (plan != null && plan.CreditAmount > 0)
{
//整腿授信 或 拆单后的授信部分:不产生资金流水,只写占用(拆单绑新拆出的授信腿)。
//授信部分(整腿授信 或 拆单后保留在原腿的可用额度部分:不产生资金流水,只写占用(占用绑原腿)。
//占用记正数(BUG-01 修正:已使用授信=Σ(amount) 占用上升;2026-08-20"与资金流水同号入金负"口径已废弃)
creditService.Occupy(td.ClientId, plan.CreditLeg?.id ?? leg.id, td.id, plan.CreditAmount, happenDate,
creditService.Occupy(td.ClientId, leg.id, td.id, plan.CreditAmount, happenDate,
plan.NeedSplit ? "簿记拆单授信部分" : "簿记授信占用");
}
//资金记录沿用既有符号口径(客户付钱为负 = -应付额):授信部分不产生流水,现金部分按差额产生
//资金记录沿用既有符号口径(客户付钱为负 = -应付额):授信部分不产生流水,
//现金部分按差额产生——拆单腿的流水绑新拆出的现金腿,整腿现金/负应付腿绑原腿
var recordAmount = plan != null
? -plan.CashAmount
: Convert.ToDouble(leg.InterestPrincipalFix * (leg.InterestDirection == 1 ? -1 : 1));
if (recordAmount != 0)
{
cashService.SaveSwapTradeClientCash(td, recordAmount, happenDate, leg.id, ClientCashInCashOut._应付预付金);
cashService.SaveSwapTradeClientCash(td, recordAmount, happenDate, plan?.CashLeg?.id ?? leg.id, ClientCashInCashOut._应付预付金);
}
}
}
/// <summary>
/// 拆单:把跨界腿拆为 授信+现金 两条。原腿保留现金部分并标 Cash(资金来源同步改现金,与最终标签一致),
/// 克隆一条授信腿(InterestPrincipalFix 按授信金额折算)标 Credit,返回新腿
/// 拆出的腿为普通初始腿,后续编辑/回退/平仓链路按既有腿处理。
/// 拆单:把跨界腿拆为 授信+现金 两条。原腿保留授信部分(可用额度)并标 Credit(占用记录绑原腿),
/// 克隆一条现金腿(授信不足的差额,InterestPrincipalFix 按现金金额折算)标 Cash,返回新腿
/// (现金流水绑新腿)。拆出的腿为普通初始腿,后续编辑/回退/平仓链路按既有腿处理。
/// </summary>
private swap_position SplitLeg(trade td, LegFundPlan plan)
{
@@ -137,22 +212,22 @@ namespace YLErp.Modules.SwapModule
//应付额 = fix × (dir==1 ? 1 : -1),反推 fix 用同一比例(±1 自反)
var payableRatio = leg.InterestDirection == 1 ? 1 : -1;
var originalFix = leg.InterestPrincipalFix;
leg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CashAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
leg.FundTag = ConsFundTag.Cash;
leg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CreditAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
leg.FundTag = ConsFundTag.Credit;
var creditLeg = leg.Clone();
creditLeg.id = 0;
creditLeg.PositionId = 0;
//授信腿倒挤 = 原 fix 现金 fixBUG-20两腿分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒)
creditLeg.InterestPrincipalFix = originalFix - leg.InterestPrincipalFix;
creditLeg.FundTag = ConsFundTag.Credit;
creditLeg.OptId = UserId;
creditLeg.OptName = UserName;
creditLeg.OptTime = DateTime.Now;
DbContext.swap_position.Add(creditLeg);
var cashLeg = leg.Clone();
cashLeg.id = 0;
cashLeg.PositionId = 0;
//现金腿倒挤 = 原 fix 授信 fix(两腿分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒)
cashLeg.InterestPrincipalFix = originalFix - leg.InterestPrincipalFix;
cashLeg.FundTag = ConsFundTag.Cash;
cashLeg.OptId = UserId;
cashLeg.OptName = UserName;
cashLeg.OptTime = DateTime.Now;
DbContext.swap_position.Add(cashLeg);
DbContext.SaveChanges();
creditLeg.PosiNumber = $"{td.TradeNumber}-{creditLeg.id}";
return creditLeg;
cashLeg.PosiNumber = $"{td.TradeNumber}-{cashLeg.id}";
return cashLeg;
}
/// <summary>
@@ -86,8 +86,19 @@ namespace YLErp.Modules.SwapModule
/// <param name="td"></param>
/// <param name="ignoreMoneyCheck"></param>
/// <returns></returns>
public trade SaveTrade(trade req, bool ignoreMoneyCheck = false)
public trade SaveTrade(trade req, bool ignoreMoneyCheck = false, bool allowMarginCreditSplit = false)
{
//资金来源必填(现金/授信,默认现金):保存前归一,兜住 DMA 自动建仓等绕过录入页的链路
if (req.TradeType == "收益互换" && string.IsNullOrWhiteSpace(req.MarginFundSource))
{
req.MarginFundSource = ConsFundTag.Cash;
}
// §2.3 保存前授信拆单(2026-08-26 业务确认):保存检查授信→不足拦截(UI 确认)→拆完再保存。
// 特批(ignoreMoneyCheck)语义为全现金不占授信,跳过拆单
if (!ignoreMoneyCheck && req.TradeType == "收益互换")
{
new SwapFundTagService(this).PreSplitMarginLegsByCredit(req, allowMarginCreditSplit);
}
var um = checkUnderlying(req);
trade dbTrade = new trade();
//交易保存处理(PrepareInitialMargin 在此把 trade_Initial_Margin 折算进 req.InitialMargin
@@ -1709,7 +1720,7 @@ namespace YLErp.Modules.SwapModule
}
fundTagSvc.ApplyMarginFundTags(td, generateMarginLegs, cashSvc, false);
//标签定稿(含可能的拆单)后重克隆实时持仓:TradeBack 的克隆先于定稿生成,
//重克隆使实时腿继承定稿标签、新拆出的授信腿也获得克隆(平仓返还分流查的是实时腿标签)
//重克隆使实时腿继承定稿标签、拆单新拆出的现金腿也获得克隆(平仓返还分流查的是实时腿标签)
InitialPosition(td);
// 合约维度盯市+无预付金腿:重建交易级(positionId=0)初始预付金记录(与 SwapTradeConfirm 一致,回退重补场景)。
// 有预付金腿的互换由上面按腿重建,不在此重复生成。
@@ -0,0 +1,17 @@
using BaseOUDAL;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 保存交易时预付金授信不足的标记异常(§2.3 保存前拆单,2026-08-26 业务确认):
/// 保存检查授信→不足拦截→UI 确认(AdditionalProcessing/MarginCreditSplit)→带参重提后
/// 按剩余授信物理拆分预付金腿(原腿=可用额度 标授信、新腿=差额 标现金)再保存。
/// 属标准业务流程,不受"允许交易特批"开关控制(与 LackOfMoney 特批协议区分)。
/// </summary>
public class TradeMarginCreditSplitException : ServiceException
{
public TradeMarginCreditSplitException(string message) : base(message)
{
}
}
}
@@ -773,9 +773,9 @@ namespace YLErp.Modules.TradeModule.DealModule
bool adjustCashDividendPrice = true)
{
// 价格调整模式除权参考价 =
// 收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价
// - -----------------------------------------------------
// (10 + 送股数 + 配股数) * 拆股倍数
// 登记日收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价
// ---------------------------------------------------------------
// (10 + 送股数 + 配股数) * 拆股倍数
// 场内链路默认继续把现金派息计入除权参考价;
// TRS Stock/Fund 现金模式显式关闭该项 :“【】” 号内数据。
var cashPriceAdjustment = adjustCashDividendPrice
@@ -784,8 +784,8 @@ namespace YLErp.Modules.TradeModule.DealModule
// 拆股倍数
var splitFactor = GetSplitFactor(info);
// 除权参考价(TRS
// 收盘价 * 10 + 配股数 * 配股价
// ------------------------------
// 登记日收盘价 * 10 + 配股数 * 配股价
// -------------------------------------
// (10 + 送股数 + 配股数) * 拆股倍数
var exDividendPrice = ((closePrice * 10m - cashPriceAdjustment
+ info.RationedSharesAmount * info.RationedSharesPrice)
@@ -985,7 +985,7 @@ namespace YLErp.Modules.TradeModule.DealModule
&& (excludedId <= 0 || O.id != excludedId));
}
private static void MergeNonZeroDividendValues(ex_dividend_info target, ex_dividend_info source)
private static void MergeDividendValues(ex_dividend_info target, ex_dividend_info source)
{
if (target == null)
{
@@ -996,26 +996,11 @@ namespace YLErp.Modules.TradeModule.DealModule
throw new ArgumentNullException(nameof(source));
}
// 同一业务键可能分别来自多行导入,或来自“数据库旧记录 + 当前导入记录”
// 每个字段独立合并:当前值非零时覆盖旧值,当前值为零时保留旧值,
// 这样派息、送股、配股数量、配股价格可以从不同来源补齐到同一行。
// 该约定将零解释为“未提供”,因此不能通过普通导入把已有字段显式清零。
if (source.GiveCashAmount != 0m)
{
target.GiveCashAmount = source.GiveCashAmount;
}
if (source.GiveShareAmount != 0m)
{
target.GiveShareAmount = source.GiveShareAmount;
}
if (source.RationedSharesAmount != 0m)
{
target.RationedSharesAmount = source.RationedSharesAmount;
}
if (source.RationedSharesPrice != 0m)
{
target.RationedSharesPrice = source.RationedSharesPrice;
}
// 数值字段(包括 0)都是有效的覆盖值
target.GiveCashAmount = source.GiveCashAmount;
target.GiveShareAmount = source.GiveShareAmount;
target.RationedSharesAmount = source.RationedSharesAmount;
target.RationedSharesPrice = source.RationedSharesPrice;
if (source.Split.HasValue)
{
// Split 为空表示本次未提供,不能按历史兼容值 1 清空或覆盖旧倍数;明确提供 1 才覆盖。
@@ -1098,7 +1083,7 @@ namespace YLErp.Modules.TradeModule.DealModule
}
// 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录
// 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。
// 后续同一业务键记录会覆盖字段,不会因生成多条数据库记录。
if (preparedIndexes.TryGetValue(businessKey, out var preparedIndex))
{
var preparedItem = preparedInfos[preparedIndex].Item;
@@ -1111,7 +1096,7 @@ namespace YLErp.Modules.TradeModule.DealModule
return false;
}
MergeNonZeroDividendValues(preparedItem, item);
MergeDividendValues(preparedItem, item);
if (item.id > 0)
{
recordKeys[item.id] = businessKey;
@@ -1159,9 +1144,10 @@ namespace YLErp.Modules.TradeModule.DealModule
}
else
{
if (checkDividendInfoExecuteStatus(dividend))
var executingTradeNumber = GetDividendInfoExecutingTradeNumber(dividend);
if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
errMsg = $"{dividend.UnderlyingCode} {dividend.ExDividendDate?.ToString("yyyy-MM-dd")}除权信息保存失败,该信息已被执行,不允许修改!";
errMsg = $"不可修改,有交易【{executingTradeNumber}】使用了该条除权除息数据";
return false;
}
var conflictingDividend = FindExDividendByBusinessKey(underlying.id, itemDate, dividend.id);
@@ -1174,10 +1160,7 @@ namespace YLErp.Modules.TradeModule.DealModule
dividend.UnderlyingCode = item.UnderlyingCode;
dividend.UnderlyingId = item.UnderlyingId;
dividend.ExDividendDate = item.ExDividendDate;
// 数据库已有记录也必须走与批次内重复行相同的合并规则:导入字段非零
// 才覆盖旧值,导入字段为零则保留数据库存量值,避免一次不完整导入
// 把旧的派息/送股/配股信息误清零。
MergeNonZeroDividendValues(dividend, item);
MergeDividendValues(dividend, item);
dividend.ValidStatus = true;
dividend.DataSource = ExDividendDataSources.Manual;
dividend.SourceUpdatedAt = sourceUpdatedAt;
@@ -1223,6 +1206,14 @@ namespace YLErp.Modules.TradeModule.DealModule
/// <param name="info"></param>
/// <returns></returns>
public bool checkDividendInfoExecuteStatus(ex_dividend_info info)
{
return !string.IsNullOrWhiteSpace(GetDividendInfoExecutingTradeNumber(info));
}
/// <summary>
/// 返回仍在引用已执行公司行为的交易编号;无引用时返回空字符串。
/// </summary>
public string GetDividendInfoExecutingTradeNumber(ex_dividend_info info)
{
// TRS 公司行为以 EffectiveDate 为真正生效边界。登记日创建待生效事件不应锁定
// 维护;只有交易已经完成 EffectiveDate(例如收盘到 7 月 30 日,而真实除权日为
@@ -1230,34 +1221,37 @@ namespace YLErp.Modules.TradeModule.DealModule
if (info?.EffectiveDate.HasValue == true)
{
var effectiveDate = info.EffectiveDate.Value.Date;
var trsTradeIds = DbContext.trade
var trsTrades = DbContext.trade
.Where(x => x.ValidState != ConsGlobal.InValid
&& x.TradeType == "收益互换"
&& x.UnderlyingCode == info.UnderlyingCode
&& x.TradeDate <= effectiveDate
&& x.ExerciseDate >= effectiveDate)
.Select(x => x.id)
.Select(x => new { x.id, x.TradeNumber })
.ToList();
if (trsTradeIds.Count > 0)
if (trsTrades.Count > 0)
{
// 是否仍被交易引用以当前有效 EOD 为准。公司行为事件本身是不可篡改
// 历史,交易回退后仍会保留;若仅凭 Applied 事件锁定,回退到登记日前
// 也无法纠错。生效日及以后还有有效 EOD 才表示当前仍已执行。
var hasAppliedEod = DbContext.eod_swap_position.Any(x =>
var trsTradeIds = trsTrades.Select(x => x.id).ToList();
var appliedTradeId = DbContext.eod_swap_position.Where(x =>
trsTradeIds.Contains(x.SwapTradeId)
&& !x.Invalid
&& x.UnderlyingCode == info.UnderlyingCode
&& x.ValueDate >= effectiveDate);
if (hasAppliedEod)
&& x.ValueDate >= effectiveDate)
.Select(x => x.SwapTradeId)
.FirstOrDefault();
if (appliedTradeId > 0)
{
return true;
return trsTrades.First(x => x.id == appliedTradeId).TradeNumber;
}
// EffectiveDate 已存在时,当前有效 EOD 是唯一执行状态来源。
// 回退会清理生效日及之后的 EOD,但不会删除 eodStatus 或不可篡改的
// 公司行为审计事件;此处不能继续落入旧的登记日 eodStatus 判断,
// 否则交易已回退仍会被错误判定为“已执行”而无法修改。
return false;
return string.Empty;
}
}
@@ -1268,20 +1262,22 @@ namespace YLErp.Modules.TradeModule.DealModule
var tradeQuery = from t in DbContext.trade.Where(O => O.UnderlyingCode == info.UnderlyingCode && O.TradeDate <= info.ExDividendDate && O.ExerciseDate >= info.ExDividendDate && O.DividendDate >= O.TradeDate)
join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus))
on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate }
select et.id;
if (tradeQuery.Any())
select t.TradeNumber;
var executingTradeNumber = tradeQuery.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
return true;
return executingTradeNumber;
}
//查询篮子标的对应交易是否执行过收盘操作;
var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.CommodityCode == "篮子标的" && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray();
tradeQuery = from t in DbContext.trade.Where(O => umList.Contains(O.UnderlyingCode) && O.TradeDate <= info.ExDividendDate && O.ExerciseDate >= info.ExDividendDate && O.DividendDate >= O.TradeDate)
join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus))
on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate }
select et.id;
if (tradeQuery.Any())
select t.TradeNumber;
executingTradeNumber = tradeQuery.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
return true;
return executingTradeNumber;
}
//查询多标的对应交易是否执行过收盘操作;
tradeQuery = from ts in DbContext.trade_swap_detail.Where(O => O.UnderlyingCode == info.UnderlyingCode)
@@ -1289,13 +1285,14 @@ namespace YLErp.Modules.TradeModule.DealModule
on ts.TradeId equals t.id
join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus))
on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate }
select et.id;
if (tradeQuery.Any())
select t.TradeNumber;
executingTradeNumber = tradeQuery.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
return true;
return executingTradeNumber;
}
}
return false;
return string.Empty;
}
public List<DividendTrade> QueryDividendTrade(DividendTradeReq req)
@@ -380,10 +380,11 @@ namespace YLErp.Modules.TradeModule.DealModule
}
var cashService = new ClientCashInCashOutService(this);
cashService.SaveSwapTradeClientCash(td, td.TradePrice ?? 0, happenDate,0);
// R4 授信/现金标签:预付金腿定稿资金标签(选授信按剩余授信分配,不足跨界腿拆单),
// R4 授信/现金标签:预付金腿定稿资金标签(按授信分配的腿——腿选授信或未选回退交易级资金来源——
// 按剩余授信分配,不足跨界腿拆单为 原腿授信+新现金腿),
// 授信部分不产生资金流水(只写授信出入表占用),现金部分产生 应付预付金 记录;特批全现金。
// 必须在 InitialPosition/AddPositionEvent 之前执行:实时持仓克隆与初始事件要继承"定稿后"的标签,
// 拆单新拆出的授信腿也要被克隆、建事件(否则平仓返还分流会查到克隆腿上的旧标签/漏腿)。
// 拆单新拆出的现金腿也要被克隆、建事件(否则平仓返还分流会查到克隆腿上的旧标签/漏腿)。
var generateMarginLegs = new List<swap_position>();
foreach (var marginPosition in td.swap_positions)
{
@@ -9,7 +9,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule;
/// <summary>
/// 构造普通收益互换结算单单行数据所需的业务输入。
/// 数据来自平仓事件及其关联交易,避免依赖平仓后可能已不存在的日终持仓。
/// 数据来自平仓事件关联交易及结算日日终持仓。
/// </summary>
public sealed class SwapSettlementBillRowInput
{
@@ -40,6 +40,9 @@ public sealed class SwapSettlementBillRowInput
/// <summary>平仓事件浮动腿记录的期末结算收益率(展示态数值)。</summary>
public decimal? ExitYtm { get; set; }
/// <summary>结算日日终持仓中的当日浮动端分红,保留原始收付方向。</summary>
public decimal CurrentDayFloatingDividend { get; set; }
/// <summary>期间付息或分红是否计入本次净额结算。</summary>
public bool IncludePeriodPaymentInNetting { get; set; }
}
@@ -61,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
@@ -69,9 +71,8 @@ public static class SwapSettlementBillRowBuilder
var payDate = input.CloseFlow.PayDate
?? throw new ServiceException("结算单缺少支付日");
// 将同一事件的普通利息与预付金利息分开;预付金本金仅统计结算日前已生效的腿。
var floatingPosition = positions.FirstOrDefault(x => x.PositionType > 0)
?? positions.FirstOrDefault(x => !ConsTrade.InterestMarginModels.Contains(x.InterestMode));
var settlementPosition = positions.FirstOrDefault(x => x.id == input.CloseFlow.PositionId);
var interestEvents = eventFlows
.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode))
.ToList();
@@ -88,25 +89,25 @@ public static class SwapSettlementBillRowBuilder
var floatingAmount = -input.CloseFlow.MarkClosePnl;
var fee = -(input.CloseFlow.TradingFee + input.CloseFlow.TradingFeePending);
var marginInterest = -marginEvents.Sum(x => x.InterestClosePnL);
var periodAmount = -input.CloseFlow.DividendIn;
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 = interestAmount + floatingAmount + fee + marginInterest
+ (input.IncludePeriodPaymentInNetting ? periodAmount : 0m);
var netSettlementAmount = -eventFlows.Sum(x => x.InterestClosePnL)
- input.CloseFlow.FloatPnlSum;
var maturitySettlementAmount = netSettlementAmount + initialMargin + additionalMargin;
var floatRateAbs = input.CloseNotionalValue == 0m
? 0m
: interestAmount / input.CloseNotionalValue;
// 同一数据库字段在模板中按标的类型拆分:现券展示期间付息,ETF 展示期间分红。
var isCashBond = ConsGlobal.InstrumentType.IsBond(input.UnderlyingInstrumentType);
var isEtf = ConsGlobal.InstrumentType.Fund.Equals(
input.UnderlyingInstrumentType,
StringComparison.OrdinalIgnoreCase);
// 此处集中完成模板字段映射和展示精度处理,生成器只负责组装原始业务数据。
return new ExcelReportModel
{
TradeNumber = input.ConfirmNo,
@@ -119,17 +120,21 @@ public static class SwapSettlementBillRowBuilder
InterestRate = interestEvents.Sum(x => x.InterestRate).ToString("0.00%"),
PosiNotionalValue = input.CloseNotionalValue.ToString("0.00"),
Quantity = input.CloseFlow.Quantity.ToString("0.00"),
DividendIn = isCashBond ? periodAmount.ToString("0.00") : string.Empty,
PeriodDividend = isEtf ? periodAmount.ToString("0.00") : string.Empty,
PosiNetPrice = ((floatingPosition?.PosiGrossPrice ?? 0m) * 100m).ToString("0.00000000"),
DividendIn = isCashBond
? input.CurrentDayFloatingDividend.ToString("0.00")
: string.Empty,
PeriodDividend = isCashBond
? string.Empty
: input.CurrentDayFloatingDividend.ToString("0.00"),
PosiNetPrice = ((settlementPosition?.PosiGrossPrice ?? 0m) * 100m).ToString("0.00000000"),
InitYtm = isCashBond && input.Trade.InitYtm.HasValue
? input.Trade.InitYtm.Value.ToString("0.####%")
: string.Empty,
ClosePrice = (input.CloseFlow.TradingAmountAvg * 100m).ToString("0.00000000"),
ExitYtm = isCashBond && input.ExitYtm.HasValue
ExitYtm = input.ExitYtm.HasValue
? input.ExitYtm.Value.ToString("0.0000")
: string.Empty,
RateDays = Math.Max(0, (eventDate - startDate).Days + 1).ToString(),
RateDays = Math.Max(0, (eventDate - startDate).Days).ToString(),
FloatRateAbs = floatRateAbs.ToString("0.0000%"),
FloatRate = floatRateAbs.ToString("0.0000%"),
InterestAmount = interestAmount.ToString("0.00"),
@@ -139,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"),
+5
View File
@@ -25,6 +25,11 @@ namespace YLErp.BLL
/// </summary>
public const string LackOfMoney = "LackOfMoney";
/// <summary>
/// 保存交易时预付金授信不足:UI 确认后按 剩余授信+现金差额 拆分预付金腿再保存(§2.3 保存前拆单)
/// </summary>
public const string MarginCreditSplit = "MarginCreditSplit";
public const string RiskWarningConfirm = "RiskWarningConfirm";
/// <summary>
@@ -306,6 +306,13 @@ namespace YLErp.Modules.UnderlyingModule
double yearTerm = (totalMonths + fractionalMonth) / 12.0;
return yearTerm;
}
/// <summary>
/// 期限计算异常提示去重:同一标的只提示一次。此前用 Console.WriteLine 直打控制台,
/// 已到期标的在预付金模板取率热路径(MarginTemplateV2RateHelper)每次调用都抛
/// "计算日期必须小于原日期",造成管理端刷屏。
/// </summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _termWarnedUnderlyings = new();
/// <summary>
/// 根据标的发行年限和剩余期限计算适用保证金率
/// 规则表格:
@@ -332,18 +339,24 @@ namespace YLErp.Modules.UnderlyingModule
{
// 获取标的信息
var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode);
if (underlying == null || !underlying.MaturityDate.HasValue)
if (underlying == null || !underlying.MaturityDate.HasValue)
return ConsMarginTerm.UnderFiveYear;
// 计算剩余期限(从估值日期到到期日期)
var remainingTermYears = getYearTerm(valueDate, underlying.MaturityDate);
// 已到期标的(到期日≤估值日):剩余期限按 0 计,直接走期限档规则(≤5年→默认档),
// 不再进 getYearTerm——其对 calcDate>sourceDate 抛"计算日期必须小于原日期",是此前高频日志的来源
var remainingTermYears = underlying.MaturityDate.Value <= valueDate
? 0
: getYearTerm(valueDate, underlying.MaturityDate);
// 根据业务规则确定适用的保证金率期限
return DetermineMarginTerm(remainingTermYears);
}
catch (Exception ex)
{
// 记录异常日志
Console.WriteLine($"计算保证金率期限时发生异常: {ex.Message}");
//兜底:按默认档(<5y)返回,取率不中断;同一标的只提示一次防刷屏
if (_termWarnedUnderlyings.TryAdd(underlyingCode, 0))
{
LogFactory.GetLogger("计算保证金率期限").Info($"【警告】标的{underlyingCode}计算保证金率期限发生异常,按默认档(<5y)兜底:{ex.Message}");
}
return ConsMarginTerm.UnderFiveYear;
}
}
+1 -1
View File
@@ -94,7 +94,7 @@
{Name:"交易市场",Rights:["基础参数管理-交易市场"],Url:"market/marketList"},
{Name:"变更记录",Rights:["基础参数管理-变更记录"],Url:"Datatrace/ClientInfoChange"},
{Name:"设置货币",Rights:["基础参数管理-设置货币"],Url:"Currency/setCurrency"},
{Name:"预付金模板V2",Rights:["基础参数管理-预付金模板V2"],Url:"margin_template_v2/margin_template_v2List"},
{Name:"预付金模板",Rights:["基础参数管理-预付金模板V2"],Url:"margin_template_v2/margin_template_v2List"},
]
},
{Name:"系统管理",Rights:["系统管理"],Icon:"menu-icon iconten"
@@ -20,11 +20,11 @@
</select>
</div>
<div class="mb-4">
<label>邮件标题模板(变量参数:{{客户名称}},{{交易日期}},{{文档编号}})</label>
<label>邮件标题模板(变量参数:{{客户名称}},{{交易日期}},{{文档编号}},{{交易编号}},{{平仓总额}})</label>
<input type="text" name="TitleTemplate" id="TitleTemplate" class="form-control" />
</div>
<div class="mb-4">
<label>邮件内容模板(变量参数:{{客户名称}},{{交易日期}},{{文档编号}})</label>
<label>邮件内容模板(变量参数:{{客户名称}},{{交易日期}},{{文档编号}},{{交易编号}},{{平仓总额}})</label>
<div id="editor"></div>
</div>
<div class="mb-4">
+33 -3
View File
@@ -106,6 +106,8 @@ namespace YLErp.Web.Controllers
TraderId = CurUser.UserId,
TraderName = CurUser.UserName,
MarginTemplateName = defaultMarginTemplateName,
//资金来源必填(现金/授信),新交易默认现金
MarginFundSource = ConsFundTag.Cash,
OpponentRole = "乙方",
OriginalStockEqvNotional = 0,
StructureType = "普通债券类收益互换",
@@ -595,18 +597,29 @@ namespace YLErp.Web.Controllers
req.id = DecryptInt(req.EncryptId);
}
//特批放行判定与确认/审批环节同口径(processtradelogController/ApprovalService):
//系统参数 允许交易特批(SpecialOperateForTrade) 开启 且 显式带 LackOfMoney 标记重提
var ignoreMoneyCheck = valuedateBLL.SystemDate.SpecialOperateForTrade == 1 && additionalProcessing == tradeBLL.LackOfMoney;
//系统参数 允许交易特批(SpecialOperateForTrade) 开启 且 显式带 LackOfMoney 标记重提
//additionalProcessing 支持逗号分隔多标记(保存前授信拆单与资金特批可链式确认):
//MarginCreditSplit=预付金授信不足拆单确认(标准流程,不受特批开关控制)
var processings = (additionalProcessing ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
var ignoreMoneyCheck = valuedateBLL.SystemDate.SpecialOperateForTrade == 1 && processings.Contains(tradeBLL.LackOfMoney);
var allowMarginCreditSplit = processings.Contains(tradeBLL.MarginCreditSplit);
try
{
bool edit = req.id != 0;
var r= swapTradeService.SaveTrade(req, ignoreMoneyCheck);
var r= swapTradeService.SaveTrade(req, ignoreMoneyCheck, allowMarginCreditSplit);
Task.Run(() =>
{
RealtimePnlCalc.RealtimeSwapPosition(new OptUserInfo(0, "互换实时持仓服务", OptUserFrom.Service));
});
return JsonSuccess("更新成功", r);
}
catch (TradeMarginCreditSplitException e)
{
//保存前授信拆单(§2.3):预付金授信不足,UI 确认后带 additionalProcessing=MarginCreditSplit
//重提,按 剩余授信+现金差额 物理拆腿后保存
LogFactory.GetLogger("交易保存").Info("保存授信不足待拆单确认:" + e.Message);
return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.MarginCreditSplit, message = e.Message });
}
catch (TradeLackOfMoneyException e)
{
//保存环节资金不足:开关开启时按确认/审批同一协议返回 AdditionalProcessing/LackOfMoney
@@ -1068,6 +1081,23 @@ namespace YLErp.Web.Controllers
var retListResult = service.SearchEodSwapList(req);
return Json(retListResult);
}
/// <summary>
/// 日终持仓-互换新框架合约查询。
/// 先与旧框架合约接口执行相同的账簿、资产单元和客户权限收敛,
/// 再返回 EQD-7084 拆分后的展示字段;不能直接绕过这些条件调用服务层。
/// </summary>
/// <param name="req"></param>
/// <returns></returns>
public JsonResult EodSwapRiskNewQuery(EodSwapQueryRequest req)
{
req.BookIds = AssetUnitModel.IntersectAssetUnits(req.AssetIdGroupList, req.BookIds).ToList();
req.UserAssets = CurUser.GetAssetUnitIds();
req.UserClients = CurUser.GetClientIdsByCurUser();
var service = new SwapEodPositionService(CurUser);
var retListResult = service.SearchEodSwapNewList(req);
return Json(retListResult);
}
#endregion
#region
/// <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)
@@ -105,9 +105,11 @@ namespace YLErp.Web.Controllers
{
return JsonError("未找到有效的除权除息信息");
}
if (new DividendService(CurUser).checkDividendInfoExecuteStatus(r))
var dividendService = new DividendService(CurUser);
var executingTradeNumber = dividendService.GetDividendInfoExecutingTradeNumber(r);
if (!string.IsNullOrWhiteSpace(executingTradeNumber))
{
return JsonError("该条除权信息已被执行,不允许删除!");
return JsonError($"不可修改,有交易【{executingTradeNumber}】使用了该条除权除息数据");
}
else
{
@@ -473,7 +473,14 @@ namespace YLErp.Web.Controllers
{
return JsonError("资产类型 必须填写");
}
// 重点功能:基金及基金专户的基金管理人必须填写,后端校验避免绕过页面校验。
model.InvestAdvisorName = model.InvestAdvisorName?.Trim();
if (model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund && string.IsNullOrEmpty(model.InvestAdvisorName))
{
return JsonError("基金管理人 必须填写");
}
model.EtfSubType = model.EtfSubType?.Trim();
if (model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund && string.IsNullOrEmpty(model.EtfSubType))
{
@@ -46,7 +46,12 @@
<a href="/swaptrade2/EodPositionRisks?index=1">日终持仓</a>
</li>
<li>
<a href="/swaptrade2/EodPositionRisks?index=2">框架合约</a>
@* index=2 固定保留历史报表与导出配置,供新旧口径并行核对。 *@
<a href="/swaptrade2/EodPositionRisks?index=2">框架合约(旧口径)</a>
</li>
<li>
@* index=3 才使用 EQD-7084 新查询与拆分字段,不能复用旧 Tab 的列配置。 *@
<a href="/swaptrade2/EodPositionRisks?index=3">框架合约</a>
</li>
</ul>
</div>
+14 -3
View File
@@ -102,6 +102,10 @@
page.Trade.StartDate = page.Trade.StartDate ? page.Trade.StartDate.substr(0, 10) : "";
page.Trade.TradeDate = page.Trade.TradeDate ? page.Trade.TradeDate.substr(0, 10) : "";
page.Trade.ExerciseDate = page.Trade.ExerciseDate ? page.Trade.ExerciseDate.substr(0, 10) : "";
//资金来源必填(现金/授信),存量空值按默认现金归一,避免下拉空值匹配不到选项
if (!page.Trade.MarginFundSource) {
page.Trade.MarginFundSource = "Cash";
}
</script>
<script src="@HtmlUtil.BasicDataJs("品种","客户","簿记","交易员")"></script>
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
@@ -289,12 +293,19 @@
<div class="form-group">
<label class="formlabel half">保证金模板</label>
<select v-model="trade.MarginTemplateName">
<option value="">请选择</option>
<option value="">默认</option>
<option v-for="item in page.swapMarginTemplateItems" :key="item.Value" :value="item.Value">
{{ item.Text }}
</option>
</select>
</div>
<div class="form-group">
<label class="formlabel half">资金来源</label>
<select v-model="trade.MarginFundSource" title="必填,默认现金。预付金腿未选资金标签(默认)时按此定稿:授信=优先占用授信额度(不足自动拆分为授信+现金两条);现金=现金">
<option value="Cash">现金</option>
<option value="Credit">授信</option>
</select>
</div>
</div>
</div>
<div class="col">
@@ -332,8 +343,8 @@
</select>
</td>
<td>
<select v-model="item.FundTag" style="width:86px;" title="资金标签:确认成交时按此选择定稿——授信检查剩余额度,不足自动拆分为授信+现金两条;未选默认现金">
<option value="">默认(现金)</option>
<select v-model="item.FundTag" style="width:86px;" title="资金标签:确认成交时按此定稿——授信检查剩余额度,不足自动拆分为授信+现金两条;默认=取交易上的资金来源">
<option value="">默认</option>
<option value="Cash">现金</option>
<option value="Credit">授信</option>
</select>
+15 -3
View File
@@ -235,6 +235,11 @@
<td>计息方式</td>
<td class="color-bule">@(TradeViewModel.GetSwapRateCalcModeDesc(trade.trade_extend.ExtendObj.InterestCalcMode))</td>
</tr>
@* IsPenaltyInterest 为 bool;存量交易未保存该字段时反序列化为 false,统一展示为“否”。 *@
<tr>
<td>是否罚息</td>
<td class="color-bule">@(trade.trade_extend.ExtendObj.IsPenaltyInterest ? "是" : "否")</td>
</tr>
@* <tr>
<td>是否开仓时收取开仓费</td>
<td class="color-bule"> @(trade.trade_extend.ExtendObj.NeedOpenFee ? "是" : "否")</td>
@@ -253,7 +258,11 @@
</tr>
<tr>
<td>保证金模板</td>
<td class="color-bule">@trade.MarginTemplateName</td>
<td class="color-bule">@(string.IsNullOrWhiteSpace(trade.MarginTemplateName) ? "默认" : trade.MarginTemplateName)</td>
</tr>
<tr>
<td>资金来源</td>
<td class="color-bule">@(trade.MarginFundSource == ConsFundTag.Credit ? "授信" : "现金")</td>
</tr>
</tbody>
</table>
@@ -288,8 +297,8 @@
<td class="@bgclass" style="width:116px !important;">@((SwapDirectionEnum)item.InterestDirection)</td>
<td>@((InterestModeEnum)item.InterestMode)</td>
<td>
@*R4 资金标签(fund_tag 单列):录入时为用户选择,确认成交后为系统定稿(授信/现金)*@
@(item.FundTag == ConsFundTag.Credit ? "授信" : "现金")
@*R4 资金标签(fund_tag 单列):录入时为用户选择(默认=取交易上的资金来源),确认成交后为系统定稿(授信/现金)*@
@(item.FundTag == ConsFundTag.Credit ? "授信" : item.FundTag == ConsFundTag.Cash ? "现金" : "默认")
</td>
<td>@item.HappenDate.OtcFormatDate()</td>
<td><span class="js-swap-common" data-value="@SwapCommonData(item.InterestPrincipalFix)" data-kind="amount"></span></td>
@@ -841,6 +850,7 @@
{
<td>期初标的交割全价%</td>
<td>期末标的交割全价%</td>
<td>期末标的结算收益率%</td>
}
else
{
@@ -864,6 +874,8 @@
{
<td><span class="js-swap-price" data-value="@SwapPriceData(closeFloat.PosiGrossPrice * multiplier)" data-instrument-type="@closeFloat.UnderlyingInstrumentType" data-field="grossPrice"></span></td>
<td><span class="js-swap-price" data-value="@SwapPriceData(closeFloat.TradingAmountAvg * multiplier)" data-instrument-type="@closeFloat.UnderlyingInstrumentType" data-field="grossPrice"></span></td>
@* 直接读取 swap_flow_event 中该次平仓浮动腿保存的收益率,不使用持仓值或重新计算值。 *@
<td><span class="js-swap-price" data-value="@SwapPriceData(closeFloat.ExitYtm)" data-instrument-type="@closeFloat.UnderlyingInstrumentType" data-field="yield"></span></td>
}
else
{
@@ -1,4 +1,4 @@
@using YLErp.Enums;
@using YLErp.Enums;
@model margin_template_v2
@{
@@ -209,14 +209,13 @@
</table>
</div>
@*规则15区间追保录入(客户原型全量手工录入,资产类型区块式):纯利率债区块固定展开4个期限档、每档一套参数;其他资产类型区块一套参数、无期限档;x/y 字段保留兼容存量数据*@
<div v-for="blk in ruleRangeBlocks" v-bind:key="'spanblk'+blk.key" class="border" style="padding:6px 10px; margin-top:10px;">
<div v-for="blk in ruleRangeBlocks" v-bind:key="'spanblk'+blk.key" class="border spanBlk" style="padding:6px 10px; margin-top:10px;">
<p style="margin:4px 0; font-weight:bold;">
参数组{{blk.no}}<span v-if="marginTemplate.UnderlyingSeperateType == @((int)UnderlyingSeperateTypeEnum.CustomInstrumentType)">资产类型:{{underlyingTypeNames(blk.ut)}}</span>
@*ETF 子类(仅纯基金区块展示):选 可转债ETF/科创债ETF 分期限档(展开4档),其他子类/不区分不分档*@
<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
@{
@@ -210,14 +210,13 @@
</table>
</div>
@*规则15区间追保录入(客户原型全量手工录入,资产类型区块式):纯利率债区块固定展开4个期限档、每档一套参数;其他资产类型区块一套参数、无期限档;x/y 字段保留兼容存量数据*@
<div v-for="blk in ruleRangeBlocks" v-bind:key="'spanblk'+blk.key" class="border" style="padding:6px 10px; margin-top:10px;">
<div v-for="blk in ruleRangeBlocks" v-bind:key="'spanblk'+blk.key" class="border spanBlk" style="padding:6px 10px; margin-top:10px;">
<p style="margin:4px 0; font-weight:bold;">
参数组{{blk.no}}<span v-if="marginTemplate.UnderlyingSeperateType == @((int)UnderlyingSeperateTypeEnum.CustomInstrumentType)">资产类型:{{underlyingTypeNames(blk.ut)}}</span>
@*ETF 子类(仅纯基金区块展示):选 可转债ETF/科创债ETF 分期限档(展开4档),其他子类/不区分不分档*@
<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;">
@@ -186,14 +186,13 @@
</table>
</div>
@*规则15区间追保录入(客户原型全量手工录入,资产类型区块式):纯利率债区块固定展开4个期限档、每档一套参数;其他资产类型区块一套参数、无期限档;x/y 字段保留兼容存量数据*@
<div v-for="blk in ruleRangeBlocks" v-bind:key="'spanblk'+blk.key" class="border" style="padding:6px 10px; margin-top:10px;">
<div v-for="blk in ruleRangeBlocks" v-bind:key="'spanblk'+blk.key" class="border spanBlk" style="padding:6px 10px; margin-top:10px;">
<p style="margin:4px 0; font-weight:bold;">
参数组{{blk.no}}<span v-if="marginTemplate.UnderlyingSeperateType == @((int)UnderlyingSeperateTypeEnum.CustomInstrumentType)">资产类型:{{underlyingTypeNames(blk.ut)}}</span>
@*ETF 子类(仅纯基金区块展示):选 可转债ETF/科创债ETF 分期限档(展开4档),其他子类/不区分不分档*@
<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) {
@@ -327,10 +327,10 @@
<input id='ValueAddedTax' class='text-box' type='number' value='@(underlying.ValueAddedTax)' name='ValueAddedTax' />
</div>
@* 仅基金及基金专户维护基金管理人 *@
@* 重点功能:仅基金及基金专户维护基金管理人,且该字段必填 *@
<div class='form-group col-6 Fund'>
<label class='formlabel'>基金管理人</label>
<input id='InvestAdvisorName' class='text-box' type='text' value='@(underlying.InvestAdvisorName)' name='InvestAdvisorName' maxlength='100' />
<input id='InvestAdvisorName' class='text-box' type='text' value='@(underlying.InvestAdvisorName)' name='InvestAdvisorName' maxlength='100' /><span style='color:red'>*</span>
</div>
@* 重点功能:ETF 子类由系统字典维护,使用 Fund 类控制显隐,并固定放在表单最后 *@
+1
View File
@@ -34,6 +34,7 @@
"CompressionType": 0, // None(0)Gzip(1)Snappy(2)Lz4(3)Zstd(4) ,
"MessageTimeoutMs": 3000, //
"ClientRateTopic": "ylClientRateTopic", //topic
"ContractTopic": "onederiv.trs.contract.v1", //TRStopic
"HedgingAccountTopic": "ylHedgingAccountTopic", //topic
"ReqAccountCapitalTopic": "ReqAccountCapital", //topic
"OnRspAccountCapitalTopic": "OnRspAccountCapital", //topic
+1
View File
@@ -34,6 +34,7 @@
"CompressionType": 0, // None(0)Gzip(1)Snappy(2)Lz4(3)Zstd(4) ,
"MessageTimeoutMs": 3000, //
"ClientRateTopic": "ylClientRateTopic", //topic
"ContractTopic": "onederiv.trs.contract.v1", //TRStopic
"HedgingAccountTopic": "ylHedgingAccountTopic", //topic
"ReqAccountCapitalTopic": "ReqAccountCapital", //topic
"OnRspAccountCapitalTopic": "OnRspAccountCapital", //topic
+1
View File
@@ -36,6 +36,7 @@
"CompressionType": 0, // None(0)Gzip(1)Snappy(2)Lz4(3)Zstd(4) ,
"MessageTimeoutMs": 3000, //
"ClientRateTopic": "ylClientRateTopic", //topic
"ContractTopic": "onederiv.trs.contract.v1", //TRStopic
"HedgingAccountTopic": "ylHedgingAccountTopic", //topic
"AccountCapitalTopicGroupId": "YiLian_OnRspAccountCapitalConsumer", //
"ReqAccountCapitalTopic": "ReqAccountCapital", //topic
+1
View File
@@ -36,6 +36,7 @@
"CompressionType": 0, // None(0)Gzip(1)Snappy(2)Lz4(3)Zstd(4) ,
"MessageTimeoutMs": 3000, //
"ClientRateTopic": "ylClientRateTopic", //topic
"ContractTopic": "onederiv.trs.contract.v1", //TRStopic
"HedgingAccountTopic": "ylHedgingAccountTopic", //topic
"AccountCapitalTopicGroupId": "YiLian_OnRspAccountCapitalConsumer", //
"ReqAccountCapitalTopic": "ReqAccountCapital", //topic
+66 -3
View File
@@ -4,7 +4,8 @@ const vm = require('vm');
function loadEodPositionRiskHelpers() {
const filePath = path.join(__dirname, '../wwwroot/Scripts/app/swaptrade/EodPositionRisks.js');
const code = fs.readFileSync(filePath, 'utf8') + '\nmodule.exports = { TradeDirectionFormat };';
const source = fs.readFileSync(filePath, 'utf8');
const code = source + '\nmodule.exports = { TradeDirectionFormat, colModelGridEodSwap, colModelGridEodSwapNew, eodSwapGroupConfig, eodSwapRiskNewGroupConfig };';
const sandbox = {
module: { exports: {} },
exports: {},
@@ -13,14 +14,25 @@ function loadEodPositionRiskHelpers() {
numberFormat() {
return function () { };
}
},
otcformat: {
trading: {
notional() { return ''; },
StockEqvNotional() { return ''; },
tradePrice() { return ''; }
}
},
swapPricePrecision: {
format() { return ''; }
}
};
vm.runInNewContext(code, sandbox, { filename: filePath });
return sandbox.module.exports;
return { helpers: sandbox.module.exports, source };
}
const { TradeDirectionFormat } = loadEodPositionRiskHelpers();
const loaded = loadEodPositionRiskHelpers();
const { TradeDirectionFormat, colModelGridEodSwap, colModelGridEodSwapNew, eodSwapGroupConfig, eodSwapRiskNewGroupConfig } = loaded.helpers;
describe('互换日终持仓交易方向', () => {
test.each([
@@ -37,3 +49,54 @@ describe('互换日终持仓交易方向', () => {
expect(TradeDirectionFormat(1, {}, { eodPosition: { PosiDirection: 0, PositionType: 1 } })).toBe('');
});
});
describe('EQD-7084 新框架合约前端接线', () => {
test('新列模型保留旧列并追加九个字段,六个展示列绑定 NewFields', () => {
const oldColumns = colModelGridEodSwap();
const newColumns = colModelGridEodSwapNew();
const oldNames = oldColumns.map(column => column.name);
const newNames = newColumns.map(column => column.name);
const replacements = {
FloatingUnrealizedPnl: 'NewFields.FloatingUnrealizedPnl',
'position.InterestPnL': 'NewFields.OrdinaryInterestPnl',
MarginInterestGain: 'NewFields.MarginInterestGain',
MarginInterestLoss: 'NewFields.MarginInterestLoss',
MaturityNettingValuation: 'NewFields.MaturityNettingValuation',
PeriodPaymentValuation: 'NewFields.PeriodPaymentValuation'
};
const newFields = [
'NewFields.UnderlyingDirection',
'NewFields.UnderlyingCode',
'NewFields.InitialPrice',
'NewFields.NotionalQuantity',
'NewFields.ContractStartDate',
'NewFields.ContractMaturityDate',
'NewFields.InterestBenchmark',
'NewFields.InterestRatePrice',
'NewFields.OpeningClosingFee'
];
expect(newColumns).toHaveLength(oldColumns.length + 9);
Object.entries(replacements).forEach(([oldName, newName]) => {
expect(newNames).toContain(newName);
expect(newNames).not.toContain(oldName);
expect(newColumns.find(column => column.name === newName).label)
.toBe(oldColumns.find(column => column.name === oldName).label);
});
oldNames
.filter(oldName => !Object.prototype.hasOwnProperty.call(replacements, oldName))
.forEach(oldName => expect(newNames).toContain(oldName));
newFields.forEach(field => expect(newNames).toContain(field));
});
test('index=2 保留旧 endpoint/configindex=3 使用独立 endpoint/config 且界面不启用分组', () => {
expect(loaded.source).toContain("queryurl = '/swaptrade2/EodSwapRiskQuery';");
expect(loaded.source).toContain("cloumnTargetName = \"eodSwapList\";");
expect(loaded.source).toContain("queryurl = '/swaptrade2/EodSwapRiskNewQuery';");
expect(loaded.source).toContain("cloumnTargetName = \"eodSwapRiskNewList\";");
expect(loaded.source).toContain('eodSwapRiskNewExportColumnNames');
expect(loaded.source).not.toMatch(/main\.initCollapsibleGroupHeaders\s*\(/);
expect(eodSwapGroupConfig).not.toBe(eodSwapRiskNewGroupConfig);
expect(eodSwapRiskNewGroupConfig.some(group => group.columns.includes('NewFields.InitialPrice'))).toBe(true);
});
});
@@ -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) {
@@ -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) {
@@ -1,6 +1,7 @@
var queryurl = '/swaptrade2/EodPositionRiskQuery';
var cloumnTargetName = "eodSwapPositionList";
var eodSwapExportColumnNames = [];
var eodSwapRiskNewExportColumnNames = [];
$(function () {
var PostData = {};
$("#DateValueDate").datepicker({
@@ -24,6 +25,18 @@ $(function () {
}).map(function (col) {
return col.name;
});
} else if (page.tabIndex == 3) {
// 新旧口径并行:独立 endpoint、列设置 key 与标准导出列,避免用户在新 Tab 调列后影响旧报表。
queryurl = '/swaptrade2/EodSwapRiskNewQuery';
$("#myTab li:first").removeClass("active");
$("#myTab li:eq(2)").addClass("active");
cloumnTargetName = "eodSwapRiskNewList";
colModelGrid = colModelGridEodSwapNew();
eodSwapRiskNewExportColumnNames = colModelGrid.filter(function (col) {
return !col.optionHide;
}).map(function (col) {
return col.name;
});
}
PostData.ValueDate = $("#DateValueDate").val();
var grid = jQuery('#listGrid').jqGrid({
@@ -44,7 +57,8 @@ $(function () {
pagerpos: 'left',
rowNum: 25,
rowList: [25, 50, 100, 200, 10000],
footerrow: page.tabIndex == 2,
// 两个框架合约 Tab 都需要承载后端返回的 DV 汇总;普通日终持仓维持原行为。
footerrow: page.tabIndex == 2 || page.tabIndex == 3,
loadComplete: gridComplete,
onPaging: onJqgridPaging,
grouping: true
@@ -687,6 +701,58 @@ function colModelGridEodSwap() {
return colModelGrid;
}
// EQD-7084 新框架合约:复用旧列定义,只替换新口径字段并追加新增列。
function colModelGridEodSwapNew() {
var colModelGrid = colModelGridEodSwap().map(function (col) {
return Object.assign({}, col);
});
// 替换后仍保留旧字段 index:后端沿用旧查询处理排序,NewFields 只是显示用的计算字段。
function replaceColumn(oldName, newName) {
var column = colModelGrid.find(function (col) { return col.name === oldName; });
if (column) {
column.name = newName;
// 新字段在服务端计算,沿用旧列的数据库排序字段,保持分页/排序请求有效。
column.index = oldName;
}
}
function newColumn(name, label, formatter, index) {
return {
name: name,
label: label,
index: index || name,
width: 150,
align: 'center',
formatter: formatter,
sortable: false
};
}
// 插入点必须在原“名义本金”前,使新需求字段与旧字段的业务阅读顺序、标准导出顺序一致。
var contractInfoIndex = colModelGrid.findIndex(function (col) {
return col.name === 'position.NotionalValue';
});
colModelGrid.splice(contractInfoIndex, 0,
newColumn('NewFields.UnderlyingDirection', '标的多空(浮动端)'),
newColumn('NewFields.UnderlyingCode', '标的代码'),
newColumn('NewFields.InitialPrice', '期初价格', InitialPriceFormat),
newColumn('NewFields.NotionalQuantity', '名义数量', otcformat.trading.notional),
newColumn('NewFields.ContractStartDate', '合约起始日', 'date'),
newColumn('NewFields.ContractMaturityDate', '合约到期日', 'date'),
newColumn('NewFields.InterestBenchmark', '利息端基准'),
newColumn('NewFields.InterestRatePrice', '利率端价格', PercentFormat),
newColumn('NewFields.OpeningClosingFee', '开平仓费用', StockEqvNotionalFormat));
replaceColumn('FloatingUnrealizedPnl', 'NewFields.FloatingUnrealizedPnl');
replaceColumn('position.InterestPnL', 'NewFields.OrdinaryInterestPnl');
replaceColumn('MarginInterestGain', 'NewFields.MarginInterestGain');
replaceColumn('MarginInterestLoss', 'NewFields.MarginInterestLoss');
replaceColumn('MaturityNettingValuation', 'NewFields.MaturityNettingValuation');
replaceColumn('PeriodPaymentValuation', 'NewFields.PeriodPaymentValuation');
return colModelGrid;
}
//框架合约分组配置(对应需求《估值模块V1》2.2 字段定义)
//columns 使用 colModel.name;组内列在 colModel 中必须连续
var eodSwapGroupConfig = [
@@ -699,14 +765,27 @@ var eodSwapGroupConfig = [
{ title: '估值与实现收益', columns: ['position.dv01', 'InterestPaymentMethod', 'MaturityNettingValuation', 'PeriodPaymentValuation', 'position.RealizedPnL'] }
];
// 新 Tab 页面不渲染可折叠分组表头(产品已要求取消界面分组);
// 此配置只服务“导出标准格式”,因此必须与旧 Tab 分开维护而不能删除。
var eodSwapRiskNewGroupConfig = [
{ title: '基本信息', columns: ['position.ValueDate', 'AssetBookName', 'ClientName', 'SwapTradeNo', 'StructureType', 'SwapTradeTypeStr', 'UnderlyingType'] },
{ title: '新增字段', columns: ['NewFields.UnderlyingDirection', 'NewFields.UnderlyingCode', 'NewFields.InitialPrice', 'NewFields.NotionalQuantity', 'NewFields.ContractStartDate', 'NewFields.ContractMaturityDate', 'NewFields.InterestBenchmark', 'NewFields.InterestRatePrice', 'NewFields.OpeningClosingFee'] },
{ title: '名义本金', columns: ['position.NotionalValue', 'position.NotionalValueLong', 'position.NotionalValueShort'] },
{ title: '标的市值', columns: ['position.MarketValueLong', 'position.MarketValueShort'] },
{ title: '浮动端', columns: ['NewFields.FloatingUnrealizedPnl', 'PeriodAmount'] },
{ title: '利息端', columns: ['NewFields.OrdinaryInterestPnl'] },
{ title: '保证金', columns: ['position.InitMarginGain', 'position.PostionMarginGain', 'position.InitMarginLoss', 'position.PostionMarginLoss', 'NewFields.MarginInterestGain', 'NewFields.MarginInterestLoss'] },
{ title: '估值与实现收益', columns: ['position.dv01', 'InterestPaymentMethod', 'NewFields.MaturityNettingValuation', 'NewFields.PeriodPaymentValuation', 'position.RealizedPnL'] }
];
function gridComplete() {
var jgrid = $(this);
if (arguments[0].Sum) {
jgrid.footerData("set", { 'position.dv01': arguments[0].Sum["DV"] });
}
//框架合约Tab:列设置应用完成后补充期间付息提示
if (page.tabIndex == 2) {
// 两个框架合约 Tab 均保留 DV footer 与列设置;界面使用普通单层表头
if (page.tabIndex == 2 || page.tabIndex == 3) {
var defer = main.setcolumnChooser(jgrid, cloumnTargetName);
$.when(defer).done(function () {
jgrid.jqGrid('setLabel', 'PeriodAmount', null, null, {
@@ -734,13 +813,21 @@ function starttradeView(id) {
function exportVisibleColumns() {
var jgrid = jQuery('#listGrid');
var dateStr = $("#DateValueDate").val() || '';
var tabName = page.tabIndex == 2 ? '框架合约' : '日终持仓';
var tabName = page.tabIndex == 2
? '框架合约(旧口径)'
: page.tabIndex == 3 ? '框架合约' : '日终持仓';
var fileName = '日终持仓风险_互换_' + tabName + (dateStr ? '_' + dateStr : '');
if (page.tabIndex != 2) {
if (page.tabIndex != 2 && page.tabIndex != 3) {
main.exportVisibleColumnsToExcel(jgrid, fileName, null);
return;
}
// 虽然新 Tab 不展示分组表头,标准格式导出仍按需求输出分组标题和固定列顺序。
var groupConfig = page.tabIndex == 3 ? eodSwapRiskNewGroupConfig : eodSwapGroupConfig;
var standardColumnNames = page.tabIndex == 3
? eodSwapRiskNewExportColumnNames
: eodSwapExportColumnNames;
layer.open({
type: 1,
title: '选择导出方式',
@@ -753,7 +840,7 @@ function exportVisibleColumns() {
'</div>',
success: function (layero, index) {
layero.find('.js-export-eod-swap-standard').on('click', function () {
exportEodSwapRows(jgrid, fileName, eodSwapGroupConfig, eodSwapExportColumnNames);
exportEodSwapRows(jgrid, fileName, groupConfig, standardColumnNames);
layer.close(index);
});
layero.find('.js-export-eod-swap-visible').on('click', function () {
@@ -810,6 +897,13 @@ function RealizedPnlFormat(cellValue, options, rowObject) {
function StockEqvNotionalFormat(cellValue, options, rowObject) {
return otcformat.trading.StockEqvNotional(cellValue);
}
function InitialPriceFormat(cellValue, options, rowObject) {
// 类型来自 NewFields(不再是旧 eodPosition 嵌套对象),以便债券按全价精度、非债券按普通价格精度展示。
var instrumentType = rowObject
&& rowObject.NewFields
&& rowObject.NewFields.UnderlyingInstrumentType;
return swapPricePrecision.format(cellValue, instrumentType, 'grossPrice');
}
function NullableStockEqvNotionalFormat(cellValue, options, rowObject) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '';
@@ -46,7 +46,9 @@ const vue = new Vue({
}
},
created() {
this.multiplier = this.deal.StructureType == '普通债券类收益互换' ? 100 : 1;
// 是否 ×100 由浮动腿标的资产类型决定(债券价格以小数保存,展示时转为百分比),
// 与存储层 SetPosiPrice/GetStorageDeliveryPriceRound 的 IsBond 口径一致,不依赖簿记结构类型。
this.multiplier = this.IsBond(swapInstrumentType) ? 100 : 1;
this.initDeal();
this.setValueDate();
},
@@ -817,17 +817,35 @@ const vue = new Vue({
"补充协议编号": $("#SupProtocolCode").val()
};
this.trade.trade_extend.ExtendJson = JSON.stringify(this.trade.trade_extend.ExtendObj);
//R4 保存环节资金校验:资金不足且系统开启"允许交易特批"时,服务端按确认/审批同一协议返回
//AdditionalProcessing/LackOfMoney——弹"交易特批"确认,带 additionalProcessing=LackOfMoney 重提放行
//保存环节两类拦截确认(服务端按确认/审批同一协议返回 AdditionalProcessing):
//1) MarginCreditSplit 预付金授信不足——确认后按 剩余授信+现金差额 拆腿再保存(标准流程);
//2) LackOfMoney 资金不足且系统开启"允许交易特批"——弹"交易特批",特批放行。
//两类可链式发生(拆单后现金仍不足再走特批),确认标记累积在 query 上一并带上
var thisObj = this;
var confirmedProcessings = [];
var doSave = function (additionalProcessing) {
var url = "/swaptrade2/tradeEditJson";
if (!main.isEmpty(additionalProcessing)) {
url += "?additionalProcessing=" + additionalProcessing;
if (!main.isEmpty(additionalProcessing) && confirmedProcessings.indexOf(additionalProcessing) < 0) {
confirmedProcessings.push(additionalProcessing);
}
if (confirmedProcessings.length) {
url += "?additionalProcessing=" + confirmedProcessings.join(",");
}
main.post(url, thisObj.trade).done(function (resp) {
if (resp.obj && resp.obj.proccessType == "AdditionalProcessing") {
if (resp.obj.type == "LackOfMoney") {
if (resp.obj.type == "MarginCreditSplit") {
var splitContent = '<div style="padding:10px">' + resp.obj.message + '</div>';
main.open2("提示",
splitContent,
{
area: ["460px", "260px"],
btn: ['确认拆分', '取消'],
yes: function (index, layero) {
layer.close(index);
doSave("MarginCreditSplit");
}
});
} else if (resp.obj.type == "LackOfMoney") {
var htmlContent = '<div style="padding:10px">' + resp.obj.message + '</div>';
var lackMoneyConfirmLayer = main.open2("提示",
htmlContent,
@@ -64,7 +64,7 @@ const vue = new Vue({
marginList: [],
initPosiNetPrice: 0,
multiplier: 1,
// EQD-6953 簿记模板=普通债券类收益互换 时启用 期末交割全价↔结算收益率(ExitYtm) 互算
// EQD-6953 浮动腿标的为债券时启用 期末交割全价↔结算收益率(ExitYtm) 互算
isBondTRS: false,
// 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。
// oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。
@@ -77,13 +77,15 @@ const vue = new Vue({
minStartDate() {
return this.deal.StartDate;
},
// EQD-6953簿记模板为普通债券类收益互换 且 浮动腿标的为债券 时,才展示 源/AUTO/REV 标识并允许互算
// EQD-6953:浮动腿标的为债券时,才展示 源/AUTO/REV 标识并允许互算
isBondUnwindLeg() {
return this.isBondTRS && !!this.floatPosition && this.IsBond(this.floatPosition.UnderlyingInstrumentType);
}
},
created() {
this.isBondTRS = this.deal.StructureType == '普通债券收益互换';
// 是否债券收益互换由浮动腿标的资产类型决定(×100 展示口径与存储层 SetPosiPrice 的 IsBond 一致),
// 不依赖簿记结构类型 StructureType。
this.isBondTRS = this.IsBond(swapInstrumentType);
this.multiplier = this.isBondTRS ? 100 : 1;
this.initDeal();
this.setValueDate(this.deal.ValueDate);
@@ -41,6 +41,10 @@ function saveInfo(dataId, rowId) {
"url": "/ex_dividend_info/SaveDividend",
"extraparam": data,
"restoreAfterError": true,
"afterrestorefunc": function (rid) {
$("#edit_" + rid).show();
$("#add_" + rid).hide();
},
"mtype": "POST"
});
$.jgrid.ajaxOptions = ajaxOptions;
@@ -135,6 +135,12 @@ function saveData() {
return main.alert("资产品种类型 必须填写!");
}
// 重点功能:基金及基金专户必须填写基金管理人,空格内容也视为未填写。
if (data.UnderlyingInstrumentType === "Fund" &&
(!data.InvestAdvisorName || !data.InvestAdvisorName.trim())) {
return main.alert("基金管理人 必须填写!");
}
// 重点功能:ETF 子类只对基金及基金专户显示并必填,先在前端阻止无效提交。
if (data.UnderlyingInstrumentType === "Fund" && !data.EtfSubType) {
return main.alert("ETF 子类 必须填写!");
@@ -88,3 +88,15 @@ p {
padding-top: 5px;
padding-bottom: unset;
}
/*区间追保结构区块:单元格里多个数字输入与文字并列,全局 input[type=text] 152px 会把单元格内容挤成两行,调窄保证单行*/
.spanBlk table input[type=text] {
width: 50px;
padding-left: 2px;
padding-right: 2px;
}
/*同区块表格单元格边距:bootstrap .table 默认 0.75rem,压缩使表格更紧凑*/
.spanBlk table th, .spanBlk table td {
padding: 2px 4px;
}