EQD-6967 fix(eod): 收盘缺失价检查按客户作用域收敛持仓分支

- EodCheckSettlePrice 抽出 public static QueryPositionUnderlyingCodes,
  增加 clienIds 过滤:client-scoped 收盘不再把其他客户/系统级(ClientId=0)
  持仓标的纳入"检查标的结算价格缺失"。
- clienIds==null(系统级全结算)时短路为 true,零回归。
- class 改 public 便于单测。
- 新增 EodCheckSettlePriceClientScopeTest 确定性回归测试(红绿双段验证)。
- 代码补充 EQD-6967 防御行为详细注释(仅聚焦客户作用域收敛)。
- 防御范围说明:场内 exchange_trade 无 ClientId 列,场内分支噪声需靠结算模式选
  '场外交易'(IsSettleExchangeTrades=false)规避,本提交不覆盖该分支。
This commit is contained in:
hjhan
2026-08-06 13:56:31 +08:00
parent c7900adca1
commit 2dcd17f557
2 changed files with 157 additions and 12 deletions
@@ -0,0 +1,116 @@
using YLErp.Modules.EodModule.SettlementModule;
namespace YLErp.Modules.EodModule
{
/// <summary>
/// 验证 EodCheckSettlePrice 的持仓分支按 ClientIds 收敛:
/// 给定收盘客户时,不应再把“仅属于其他客户”的上一交易日持仓标的纳入结算价缺失检查。
///
/// 采用确定性夹具:插入两条合成持仓(客户A持标的A、客户B持标的B),直接调用抽出的
/// static 查询方法断言过滤语义,finally 中清理,避免依赖测试库现有数据形状。
/// 若 underlying_manager 无足够的对冲类型标的,则 Assert.Inconclusive 跳过。
/// </summary>
[TestClass]
public class EodCheckSettlePriceClientScopeTest : UnitTestBase
{
[TestMethod]
public void PositionUnderlyingQuery_ExcludesOtherClients_WhenClientIdsGiven()
{
using var db = DbContextFactory.GetYLDbContext();
// 选两个存在的、非期货的标的(避开 GetFutureTypes,保证通过方法内部的期货到期过滤);
// 持仓 TradeType 固定为"股票"(属于 TradeTypesForHedge),才能进入结算价检查。
var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes();
var underlyings = db.underlying_manager
.Where(u => u.UnderlyingCode != null && !futureTypes.Contains(u.UnderlyingInstrumentType))
.Take(5)
.ToList();
if (underlyings.Count < 2)
{
Assert.Inconclusive("underlying_manager 无足够的非期货标的,跳过");
return;
}
var uA = underlyings[0];
var uB = underlyings[1];
// 复用一条现有持仓的 BookId/TradeId,确保外键合法(若存在)
var sample = db.eod_trade_position.FirstOrDefault(p => p.BookId != 0);
int bookId = sample?.BookId ?? 1;
int tradeId = sample?.TradeId ?? 0;
// 合成日期与客户,避免与测试库真实数据冲突
var preSettleDate = new DateTime(2026, 5, 1);
var settleDate = new DateTime(2026, 5, 2);
int clientA = 900001;
int clientB = 900002;
var rows = new List<eod_trade_position>
{
new eod_trade_position
{
ValueDate = preSettleDate,
ClientId = clientA,
UnderlyingCode = uA.UnderlyingCode,
UnderlyingId = uA.id,
TradeType = "股票",
BookId = bookId,
TradeId = tradeId,
Amount = 1,
HedgeUniqueCode = "UT_CLIENTSCOPE_A"
},
new eod_trade_position
{
ValueDate = preSettleDate,
ClientId = clientB,
UnderlyingCode = uB.UnderlyingCode,
UnderlyingId = uB.id,
TradeType = "股票",
BookId = bookId,
TradeId = tradeId,
Amount = 1,
HedgeUniqueCode = "UT_CLIENTSCOPE_B"
}
};
foreach (var r in rows)
{
r.OptId = 0;
r.OptName = "UT_CLIENTSCOPE";
r.OptDate = DateTime.Now;
}
try
{
db.eod_trade_position.AddRange(rows);
db.SaveChanges();
var fullSet = EodCheckSettlePrice.QueryPositionUnderlyingCodes(db, preSettleDate, settleDate, null)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var filteredA = EodCheckSettlePrice.QueryPositionUnderlyingCodes(db, preSettleDate, settleDate, new List<int> { clientA })
.ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.IsTrue(fullSet.Contains(uA.UnderlyingCode), "全客户结果应包含客户A的标的");
Assert.IsTrue(fullSet.Contains(uB.UnderlyingCode), "全客户结果应包含客户B的标的");
Assert.IsTrue(filteredA.Contains(uA.UnderlyingCode), "按客户A收敛后仍应包含客户A的标的");
// 关键断言:修复点——按客户A收敛后不应再包含“仅属客户B”的标的
Assert.IsFalse(filteredA.Contains(uB.UnderlyingCode),
"修复验证失败:按客户A收敛后仍包含仅属客户B的持仓标的(ClientId 过滤未生效)");
}
finally
{
// 清理合成数据,使测试库状态不变
foreach (var r in rows)
{
var exist = db.eod_trade_position.FirstOrDefault(x =>
x.ValueDate == preSettleDate && x.ClientId == r.ClientId &&
x.UnderlyingCode == r.UnderlyingCode && x.HedgeUniqueCode == r.HedgeUniqueCode);
if (exist != null)
{
db.eod_trade_position.Remove(exist);
}
}
db.SaveChanges();
}
}
}
}
@@ -1,5 +1,5 @@
using System.Linq;
using System.Linq.Dynamic.Core;
using YLErp.BLL;
using YLErp.Configuration.Enums;
using YLErp.Modules.DataProviderModule;
namespace YLErp.Modules.EodModule.SettlementModule
@@ -7,7 +7,7 @@ namespace YLErp.Modules.EodModule.SettlementModule
/// <summary>
/// 检查当日结算价,是否全部进系统。
/// </summary>
class EodCheckSettlePrice : EodSettleServiceBaseV2
public class EodCheckSettlePrice : EodSettleServiceBaseV2
{
public const string Step = "检查标的结算价格缺失";
@@ -58,7 +58,7 @@ namespace YLErp.Modules.EodModule.SettlementModule
allQuery = DbContext.trade.Where(tradePredicate).Select(n => n.UnderlyingCode).Distinct();
if (PS.Config.ErpElement.ForwardTradePriceModel == Configuration.Enums.ForwardTradePriceModel.STANDARD)
if (PS.Config.ErpElement.ForwardTradePriceModel == ForwardTradePriceModel.STANDARD)
{
allQuery = allQuery.Union(DbContext.trade.Where(tradePredicate).Where(x => x.BasisUnderlyingCode != null && x.BasisUnderlyingCode != "").Select(n => n.BasisUnderlyingCode).Distinct());
}
@@ -88,14 +88,8 @@ namespace YLErp.Modules.EodModule.SettlementModule
allQuery = allQuery == null ? exTradeQuery.Distinct() : allQuery.Union(exTradeQuery.Distinct());
var preSettleDate = _context.PreSettleDate;
//最后一个交易日持仓信息
var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes();
var positionQuery = from t in DbContext.eod_trade_position
join um in DbContext.underlying_manager on t.UnderlyingCode equals um.UnderlyingCode
where t.ValueDate == preSettleDate && t.Amount != 0
&& ConsTrade.TradeTypesForHedge.Contains(t.TradeType)
&& (!futureTypes.Contains(um.UnderlyingInstrumentType) || um.MaturityDate >= settleDate)
select t.UnderlyingCode;
// 持仓分支传入 clienIds:使上一交易日持仓标的按当前收盘客户作用域收敛(防御点详见 QueryPositionUnderlyingCodes)。
var positionQuery = QueryPositionUnderlyingCodes(DbContext, preSettleDate, settleDate, clienIds);
allQuery = allQuery.Union(positionQuery.Distinct());
}
@@ -130,6 +124,41 @@ namespace YLErp.Modules.EodModule.SettlementModule
_context.RaiseError(Step, "标的代码:" + codes);
}
}
/// <summary>
/// 取上一交易日持仓中需结算价的标的(持仓分支)。
/// 抽成 static 以便单测直接覆盖 ClientId 收敛语义。
///
/// ── 防御行为(EQD-6967)─────────────────────────────────────────────
/// 收盘缺失价检查在“按客户作用域结算(ClientIds 非 null)”时,本应只校验
/// 当前收盘客户自己的标的,而不应把“其他客户”或“系统级(ClientId=0)”的
/// 上一交易日持仓标的误报为缺失价。历史上持仓分支完全未引用 ClientId,
/// 导致客户 A 收盘时被其他客户/系统级持仓的标的噪声干扰(见缺陷现象)。
///
/// 本方法通过 clienIds 过滤做收敛,与 OTC 分支、客户产品分支已有的
/// ClientId 过滤语义保持一致:
/// · clienIds == null → 系统级全量结算,短路为 true(不收窄,零回归);
/// · clienIds != null → 仅返回属于指定客户的持仓标的(Contains(t.ClientId)),
/// 过滤掉其他客户及系统级(ClientId=0)持仓噪声。
/// 注意:场外交易结算模式(IsSettleExchangeTrades=false)下持仓分支整块跳过,
/// 此时本方法不会被调用,属操作层面的规避而非修复。
/// ───────────────────────────────────────────────────────────────────
/// </summary>
public static IQueryable<string> QueryPositionUnderlyingCodes(YLContext db, DateTime preSettleDate, DateTime settleDate, IEnumerable<int> clienIds)
{
var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes();
return from t in db.eod_trade_position
join um in db.underlying_manager on t.UnderlyingCode equals um.UnderlyingCode
where t.ValueDate == preSettleDate && t.Amount != 0
&& ConsTrade.TradeTypesForHedge.Contains(t.TradeType)
&& (!futureTypes.Contains(um.UnderlyingInstrumentType) || um.MaturityDate >= settleDate)
// 【防御点·EQD-6967】按客户作用域收敛持仓标的:
// clienIds==null → 系统级全量结算,不收窄(零回归);
// clienIds!=null → 仅保留指定客户持仓,过滤掉其他客户/系统级(ClientId=0)持仓噪声。
&& (clienIds == null || clienIds.Contains(t.ClientId))
select t.UnderlyingCode;
}
/// <summary>
/// 获取互换标的
/// </summary>