feat(trade): 新增真实除权日字段并完善基金公司行为处理 init

- 添加 EffectiveDate 字段用于标识真实除权生效日
- 实现公司行为价格系数和数量系数统一计算方法
- 增加基金除权回退和平仓基线恢复功能
- 完善除权日验证逻辑,确保真实除权日不早于股权登记日
- 重构平仓流程,支持按有效EOD基线重新计算损益和现金
- 添加基金拆合股和现金分红的特殊处理逻辑
- 增加单元测试验证各种公司行为场景下的正确性
This commit is contained in:
张名锐
2026-08-18 11:20:56 +08:00
parent 04f4468be5
commit aa1a13d7a3
11 changed files with 1582 additions and 18 deletions
+281 -4
View File
@@ -243,6 +243,246 @@ namespace YLErp.Modules.SwapModule
return td.ExerciseDate.Value.AddDays(-1);
}
/// <summary>查询交易当前有效的初始腿和实时腿。测试可返回内存快照,避免初始化测试触库。</summary>
protected virtual List<swap_position> FindActiveSwapPositions(int tradeId)
{
return DbContext.swap_position
.Where(x => x.SwapTradeId == tradeId && !x.Invalid)
.ToList();
}
/// <summary>
/// 找到平仓数据对应的实时浮动腿。正式路径以 PositionId 绑定,缺失时才按标的代码兜底;
/// 这样后台不会把前端传入的价格当成权威基线。测试可 override 为内存持仓。
/// </summary>
protected virtual swap_position FindRealtimeFloatPosition(UnwindData unwindData)
{
if (unwindData == null)
{
return null;
}
var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode));
var query = DbContext.swap_position
.Where(x => x.SwapTradeId == unwindData.SwapTradeId && !x.IsInitial && !x.Invalid
&& !string.IsNullOrEmpty(x.UnderlyingCode));
if (floatEvent?.PositionId > 0)
{
var byPositionId = query.FirstOrDefault(x => x.PositionId == floatEvent.PositionId);
if (byPositionId != null)
{
return byPositionId;
}
}
if (!string.IsNullOrEmpty(floatEvent?.UnderlyingCode))
{
var byCode = query.FirstOrDefault(x => x.UnderlyingCode == floatEvent.UnderlyingCode);
if (byCode != null)
{
return byCode;
}
}
return query.FirstOrDefault();
}
/// <summary>查询 valueDate 当日已经生效的最近有效 Fund EOD。</summary>
protected virtual eod_swap_position FindLatestFundEodPosition(
int tradeId,
long positionId,
DateTime valueDate)
{
return new SwapEodPositionService(this)
.GetLatestValidEodPosition(tradeId, positionId, valueDate);
}
/// <summary>
/// 查询 valueDate 当天真正生效的 Fund 公司行为。
/// ExDividendDate 只是登记日,盘中基线不能按登记日提前切换;只有
/// EffectiveDate == valueDate 时才把上一 EOD 的 Q/P 转成当日 BOD 的除权后 Q/P。
/// </summary>
protected virtual ex_dividend_info FindFundCorporateAction(
string underlyingCode,
DateTime valueDate)
{
return DbContext.ex_dividend_info.FirstOrDefault(x => x.ValidStatus
&& x.UnderlyingCode == underlyingCode
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value == valueDate.Date);
}
/// <summary>
/// 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。
/// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。
/// </summary>
protected virtual decimal GetFundCorporateActionClosePrice(
ex_dividend_info dividendInfo,
decimal fallbackPrice)
{
if (!dividendInfo.ExDividendDate.HasValue)
{
return fallbackPrice;
}
var closePrice = new EodPriceProvider(dividendInfo.ExDividendDate.Value)
.GetPrice(dividendInfo.UnderlyingCode, SettlementTypeEnum.ClosePrice);
return Convert.ToDecimal(closePrice);
}
/// <summary>读取 Fund 现金分红税率;单元测试可固定为 0,避免依赖系统日期配置。</summary>
protected virtual decimal GetFundDividendTaxRate()
=> new DividendService(this).GetDividendTaxRateDecimal();
/// <summary>
/// 判断最新 EOD 之后是否已有同一浮动腿的完成流水。若有,说明当日实时持仓已发生部分平仓/互换,
/// 不能再把较早 EOD 的数量覆盖回来,否则会抹掉当日成交结果。
/// </summary>
protected virtual bool HasCompletedFlowAfterFundEod(
int tradeId,
long positionId,
DateTime eodDate,
DateTime valueDate)
{
var asOfDate = valueDate.Date;
return DbContext.swap_flow_event.Any(x => x.SwapTradeId == tradeId
&& x.PositionId == positionId
&& x.DataState == (int)SwapFlowDateStateEnum.
&& x.EventDate > eodDate
&& x.EventDate <= asOfDate);
}
/// <summary>
/// 恢复实时 Fund 浮动腿到截至指定日有效的 EOD 基线。
/// 这是唯一允许把 EOD 公司行为结果带入盘中平仓的入口:10 送 10 后 EOD 是 2000 份/50
/// 时,下一日直接使用 2000/50,不再把前端可能传入的 1000/100 或已除权价格重复套系数。
/// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Fund、无 EOD
/// 和固定/利息腿均返回 false,沿用原逻辑。
/// </summary>
protected virtual bool TryRestoreEffectiveFundPosition(
swap_position position,
DateTime valueDate)
{
// 只对收取方向的 Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。
// 无历史 EOD 或最新 EOD 后已有完成流水时返回 false,由调用方保持实时持仓原值,
// 不伪造一份快照,也不把较早的 2000 份/50 覆盖掉当日已经部分平仓后的实时数量。
if (position == null
|| position.PosiDirection <= 0
|| position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund)
{
return false;
}
var eodPosition = FindLatestFundEodPosition(
position.SwapTradeId,
position.PositionId,
valueDate);
if (eodPosition == null
|| HasCompletedFlowAfterFundEod(
position.SwapTradeId,
position.PositionId,
eodPosition.ValueDate,
valueDate))
{
return false;
}
if (!SwapEodPositionService.RestoreFundPositionFromEod(position, eodPosition))
{
return false;
}
// 最近 EOD 已经处于生效日或更晚时,说明该快照本身已经是除权后基线,
// 不能再次套系数。只有“最近 EOD < EffectiveDate <= valueDate”时,
// 才在盘中恢复后补一次公司行为。
var corporateAction = FindFundCorporateAction(
position.UnderlyingCode,
valueDate);
if (corporateAction?.EffectiveDate > eodPosition.ValueDate.Date
&& corporateAction.EffectiveDate.Value.Date <= valueDate.Date)
{
var closePrice = GetFundCorporateActionClosePrice(
corporateAction,
position.PosiGrossPrice);
if (closePrice <= 0)
{
throw new ServiceException(
$"Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权");
}
var dividendTaxRate = GetFundDividendTaxRate();
SwapEodPositionService.ApplyFundCorporateActionToPosition(
position,
corporateAction,
closePrice,
dividendTaxRate);
}
return true;
}
/// <summary>
/// 在直接提交前复核前端平仓数据。基线恢复成功时同步浮动流水价格、有效数量和名义本金,
/// 并拒绝 CloseQty 超过有效 EOD 数量;全平请求则把数量规范为当前有效全部持仓。
/// </summary>
protected virtual bool TryRestoreEffectiveFundPosition(
UnwindData unwindData,
DateTime valueDate)
{
var position = FindRealtimeFloatPosition(unwindData);
if (!TryRestoreEffectiveFundPosition(position, valueDate))
{
return false;
}
var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode));
var effectiveQty = position.PosiQuantity;
var requestedQty = unwindData.CloseQty;
var fullClose = unwindData.CloseMethod == (int)CloseMethodEnum.
|| unwindData.ClosePercent >= 1m;
// CloseQty 是部分平仓请求的数量口径;全平请求忽略前端缓存的旧数量,统一取 EOD 有效数量。
// 例如 10 送 10 后 EOD 为 2000 份/50,前端仍传 1000 份时,全平必须落成 2000 份,
// 否则会遗留 1000 份;现金派现后若 EOD 名义本金为 99000,平一半应按 49500 扣减。
// 若交易级余额仍沿用旧值 100000,再扣有效平仓额 49500,就会错误留下 50500。
if (requestedQty < 0m || (!fullClose && requestedQty > effectiveQty))
{
throw new ServiceException(
$"Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}");
}
var closeQty = fullClose ? effectiveQty : requestedQty;
var closeNotional = fullClose
? position.PosiNotionalValue
: Math.Round(
closeQty * position.PosiGrossPrice * position.ContractSize,
ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
unwindData.PositionQty = effectiveQty;
unwindData.PosiNotionalValue = position.PosiNotionalValue;
unwindData.CloseQty = closeQty;
unwindData.CloseNotionalValue = closeNotional;
if (!fullClose)
{
unwindData.ClosePercent = unwindData.NotionalValue > 0m
? closeNotional / unwindData.NotionalValue
: (effectiveQty == 0m ? 0m : closeQty / effectiveQty);
}
if (floatEvent != null)
{
floatEvent.PosiGrossPrice = position.PosiGrossPrice;
floatEvent.PosiNetPrice = position.PosiNetPrice;
floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice;
floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice;
floatEvent.Quantity = closeQty;
floatEvent.PositionQty = effectiveQty - closeQty;
floatEvent.ContractSize = position.ContractSize;
// EOD 恢复会改变入场基准和有效平仓数量;按当前平仓价重算前端派生盈亏。
// FloatPnlSum 是只读属性,由 MarkClosePnl、费用和分红自动派生,不能直接写入。
RecalculateNormalizedUnwindAmounts(unwindData);
}
return true;
}
#endregion
public SwapDealService(OptUserInfo optUser) : base(optUser)
@@ -300,7 +540,7 @@ namespace YLErp.Modules.SwapModule
public UnwindData InitUnwind(int tradeId)
{
var td = DbContext.trade.Find(tradeId);
var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid);
var positions = FindActiveSwapPositions(tradeId);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode);
bool commodity = ConsGlobal.InstrumentType.CalcTypeIsFutures(um.UnderlyingInstrumentType);
List<int> eventTyps = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
@@ -310,6 +550,11 @@ namespace YLErp.Modules.SwapModule
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault();
// Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50
// 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。
var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 恢复失败表示非 Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值,
// 继续原有盘中流程,避免用不完整快照制造数量/价格。
var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps);
var hasProcess = HasTradeProcess();
swap_flow_event floatEvent = new swap_flow_event();
@@ -344,7 +589,11 @@ namespace YLErp.Modules.SwapModule
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional);
// 现金分红会调整 EOD 期初价但不改数量,因此持仓名义本金可能从 100000 变为 99000。
// 只有 Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。
unwindData.PosiNotionalValue = restoredFundBaseline
? position.PosiNotionalValue
: Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
unwindData.CloseMethod = (int)CloseMethodEnum.;
@@ -525,7 +774,7 @@ namespace YLErp.Modules.SwapModule
{
var checkEventTypes = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
var td = DbContext.trade.Find(tradeId);
var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid);
var positions = FindActiveSwapPositions(tradeId);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode);
List<int> eventTypes = new List<int>() { (int)SwapFlowEventTypeEnum., (int)SwapFlowEventTypeEnum. };
var maxIncomeValueDate = GetMaxIncomeValueDate(td);
@@ -534,6 +783,10 @@ namespace YLErp.Modules.SwapModule
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
// 收益结算与手工平仓共用 Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。
var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD
// 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。
//var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期
var preDealDate = GetPreDealDate(tradeId, dealDate, eventTypes);
var hasProcess = HasTradeProcess();
@@ -570,7 +823,9 @@ namespace YLErp.Modules.SwapModule
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional);
unwindData.PosiNotionalValue = restoredFundBaseline
? position.PosiNotionalValue
: Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
unwindData.ClosePercent = unwindData.PosiNotionalValue / unwindData.NotionalValue;
@@ -1574,6 +1829,18 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
// 提交时再次从有效 EOD/实时腿复核基线,不能只相信前端缓存的数量和价格。
var restoredFundBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate);
// 这是直接提交路径的最后一道复核。若返回 false(非 Fund、无快照、或 EOD 后已有完成流水),
// 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。
if (restoredFundBaseline)
{
// 正式提交必须让交易级余额与同一 Fund EOD 基线一致,再执行原有扣减。
// 例:派现后有效名义本金为 99000,平掉一半 49500 后应剩 49500
// 若仍从 trade 旧值 100000 扣减,会错误留下 50500。
td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue);
td.TradeAmount = Convert.ToDouble(unwindData.PositionQty);
}
NormalizeNotionalValues(unwindData);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_平仓");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
@@ -1635,6 +1902,8 @@ namespace YLErp.Modules.SwapModule
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
// 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Fund EOD。
// 因此它不具备手工 SwapUnwind 的 EOD 复核保护,生产上需确保自动流水已在正确的 EOD 基线之后生成。
var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType)
? ConsGlobal.PriceRound
: ConsGlobal.SwapDeliveryPriceRound;
@@ -2047,6 +2316,9 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
// 正常页面先由 InitIncome 读取最近有效 Fund EOD;本提交方法本身不再重读快照,
// 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权
// 不会在这里回写,属于审批链路的残余风险。
ValidateIncomeValueDate(unwindData, td);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_互换");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
@@ -2083,6 +2355,9 @@ namespace YLErp.Modules.SwapModule
{
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
// 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Fund EOD 重建数量和价格。
// 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线;
// 直接提交路径的 EOD 复核不覆盖此审批路径。
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
NormalizeEventUnwindDate(swapEvent.unwindData);
NormalizeNotionalValues(swapEvent.unwindData);
@@ -2177,6 +2452,8 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
// 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Fund EOD 复核。
// 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。
if (eventType == (int)SwapEventTypeEnum.)
{
ValidateIncomeValueDate(unwindData, td);