feat(bond): 支持债券与股票基金公司行为现金流计算的差异化处理 - init2

- 修改 CalcPayment 方法添加 useBondPriceScale 参数区分债券和股票/基金的金额计算口径
- 债券利息按每100元面值票息通过BondPriceConverter转为入库金额,股票基金分红直接计算
- 在BondPaymentService中添加详细的参数说明文档注释
- 更新SwapDealService中分红计算逻辑,根据标的类型自动选择合适的金额转换方式
- 新增CorporateActionEventLifecycleTest单元测试验证公司行为事件生命周期管理
- 添加SplitCorporateActionTddTest测试验证拆合股功能
- 优化FundCorporateActionRollbackAndUnwindTest扩展到股票类型测试
- 更新前端OperationHistory页面表格列宽和显示格式支持更长的说明信息
This commit is contained in:
张名锐
2026-08-19 17:48:34 +08:00
parent ddf233678d
commit aa3548e77f
19 changed files with 1342 additions and 104 deletions
@@ -91,8 +91,8 @@ namespace YLErp.Modules.SwapModule
if (realtimePosition == null
|| eodPosition == null
|| realtimePosition.PosiDirection <= 0
|| realtimePosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund
|| eodPosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund)
|| !IsTrsCorporateActionInstrument(realtimePosition.UnderlyingInstrumentType)
|| !IsTrsCorporateActionInstrument(eodPosition.UnderlyingInstrumentType))
{
return false;
}
@@ -370,10 +370,27 @@ namespace YLErp.Modules.SwapModule
return DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode);
}
/// <summary>计算债券付息(生产: BondPaymentService;测试: 返回固定值)</summary>
/// <summary>
/// 计算期间现金流(生产: BondPaymentService;测试: 返回固定值)。
/// BondPaymentService 的默认仍是债券百分比价格口径;TRS Stock/Fund 的公司行为
/// 分红行按每 10 份金额入库,因此必须显式关闭 BondPriceConverter 的 /100 换算。
/// 标的资料缺失时沿用债券口径,避免把未知历史数据放大 100 倍。
/// </summary>
protected virtual decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
return new BondPaymentService(UserInfo).CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
var underlying = GetUnderlyingData(underlyingCode);
// 本期现金分红只覆盖 TRS Stock/Fund。其他非债券(期货、期权等)虽然也不属于
// 债券,但尚未接入本现金分红表,继续使用默认债券换算,避免扩大改造范围。
var useBondPriceScale = underlying == null
|| !IsCorporateActionInstrument(underlying.UnderlyingInstrumentType);
return new BondPaymentService(UserInfo).CalcPayment(
underlyingCode,
fromDate,
toDate,
qty,
shortRatio,
directionRatio,
useBondPriceScale);
}
// ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)----
@@ -444,6 +461,48 @@ namespace YLErp.Modules.SwapModule
.ToList();
}
/// <summary>
/// 查询登记日或真实生效日命中的公司行为。保留 FindExDividendInfos 这个
/// 可替换入口,测试和历史调用方可以继续注入内存数据。
/// </summary>
protected virtual List<ex_dividend_info> FindCorporateActionInfos(DateTime settleDate)
{
return DbContext.ex_dividend_info
.Where(x => x.ValidStatus
&& ((x.ExDividendDate.HasValue && x.ExDividendDate.Value == settleDate.Date)
|| (x.EffectiveDate.HasValue && x.EffectiveDate.Value == settleDate.Date)))
.ToList();
}
/// <summary>查询交易已有公司行为事件,用于登记日/生效日幂等匹配。</summary>
protected virtual List<swap_event> FindCorporateActionEvents(int swapTradeId)
{
return DbContext.swap_event
.Where(x => x.SwapTradeId == swapTradeId
&& x.EventType == (int)SwapEventTypeEnum.
&& !x.Invalid)
.ToList();
}
/// <summary>更新已存在的公司行为事件;默认只标记实体,统一由收盘事务保存。</summary>
protected virtual void UpdateCorporateActionEventRecord(swap_event swapEvent)
{
UpdateDbOption(swapEvent);
}
/// <summary>
/// 查找登记日公司行为。登记日只创建待生效审计事件,不参与当日持仓系数计算;
/// EffectiveDate 到达后才由 FindExDividendInfos 命中并改变 Stock/Fund 基线。
/// </summary>
protected virtual List<ex_dividend_info> FindRegistrationExDividendInfos(DateTime settleDate)
{
return FindCorporateActionInfos(settleDate)
.Where(x => x.ValidStatus
&& x.ExDividendDate.HasValue
&& x.ExDividendDate.Value.Date == settleDate.Date)
.ToList();
}
/// <summary>
/// 获取公司行为公式使用的收盘价。
/// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日
@@ -474,6 +533,17 @@ namespace YLErp.Modules.SwapModule
return new DividendService(this).GetDividendTaxRateDecimal();
}
public static bool IsCorporateActionInstrument(string instrumentType)
{
// TRS 公司行为本期只覆盖 Stock/Fund。TBonds 等类型继续走原债券付息链路,
// 这里不能用“非空标的类型”放宽,否则会把期权、期货等未验证品种一并启用。
return string.Equals(instrumentType, ConsGlobal.InstrumentType.Fund, StringComparison.OrdinalIgnoreCase)
|| string.Equals(instrumentType, ConsGlobal.InstrumentType.Stock, StringComparison.OrdinalIgnoreCase);
}
private static bool IsTrsCorporateActionInstrument(string instrumentType)
=> IsCorporateActionInstrument(instrumentType);
#endregion
/// <summary>
@@ -522,7 +592,19 @@ namespace YLErp.Modules.SwapModule
var completedFlowEvents = FindCompletedFlowEvents(tradeIds);
// 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘,
// 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。
var exDividendInfos = FindExDividendInfos(settleDate);
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);
@@ -530,6 +612,16 @@ 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);
if (duplicateRegistration != null)
{
// 登记日现金权益不能依赖数据库返回顺序取 First;同一标的同一登记日
// 有多条有效记录时,系统无法证明应采用哪一条派现金额,必须中止收盘。
throw new InvalidOperationException($"标的【{duplicateRegistration.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效登记日记录");
}
var exDividendByCode = exDividendInfos.ToDictionary(
x => x.UnderlyingCode,
x => x,
@@ -573,17 +665,32 @@ 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>();//自动互换利息腿信息
// 处理浮动腿前先准备当日开盘基线:登记日 8 月 14 日 EOD 仍保存
// 1000 份/100 元,8 月 17 日收盘时先把上一 EOD 的基线转换为
// 处理浮动腿前先准备当日开盘基线:登记日 EOD 仍保存
// 1000 份/100 元,除权日收盘时先把上一 EOD 的基线转换为
// 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。
// 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到
// 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。
// 重置基线
var openingEodPositions = PrepareFundOpeningEodPositions(
eodPositions,
exDividendByCode,
settleDate);
// 构建公司行为前eod持仓
var corporateActionBeforePositions = BuildCorporateActionBeforePositions(
eodPositions,
posiList);
// 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线,应用生效日公司行为。
// 有上一份 EOD 时沿用 PrepareFundOpeningEodPositions,避免重复套系数。
var floatPositionsForCompose = eodPositions.Count == 0
? PrepareInitialCorporateActionPositions(posiList, exDividendByCode, settleDate)
: posiList;
// 处理浮动腿归档
var curEodPosis = DealFloatPositions(
posiList,
floatPositionsForCompose,
realPosiList,
openingEodPositions,
todyEodPositions,
@@ -591,14 +698,17 @@ namespace YLErp.Modules.SwapModule
td,
preSettleDate,
flowEvents);
// 公司行为 - 分红
// Fund 现金分红在 EffectiveDate 当日收盘即完成结算:
// TdPosiDividend 展示当日金额,RealizedDividend 累计已实现金额,
// 不把同一笔金额留在 PosiDividendSum 待实现字段中
ApplyFundCashDividends(
// 现金分红不在登记日直接读取 ex_dividend_info 累加。
// 同步任务会把 GiveCashAmount/10 写入 bond_payment_infoCopy/Update EOD 在
// EffectiveDate 通过 CalcBondPayment 命中该行并生成 TdPosiDividend。
// 这样登记日快照不提前变化,也不会与债券付息/平仓链路重复计算。
RecordCorporateActionEvents(
td,
curEodPosis,
eodPositions,
exDividendByCode,
corporateActionBeforePositions,
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);
@@ -665,11 +775,11 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 对 Fund 浮动腿应用一条已按 EffectiveDate 筛选的公司行为。
/// 对 TRS Stock/Fund 浮动腿应用一条已按 EffectiveDate 筛选的份额/价格公司行为。
/// 此方法用于直接测试/兼容已有调用方;正式收盘链路通过
/// PrepareFundOpeningEodPositions 在处理当日流水前执行同一动作。
/// 该步骤只改 EOD 持仓,不生成现金分红流水;现金分红通过期初价下调进入浮动端损益,
/// 若同时再写 TdPosiDividend 会重复计入。
/// 该步骤只改 EOD 持仓的份额/价格基线,不生成现金分红流水;现金模式下现金分红
/// 不下调期初价格,而是由同步任务写入 bond_payment_info,后续付息链路单独计入。
/// <para>
/// 幂等例子:原持仓 1000 份、期初价 100,每 10 份送 10 份。首次收盘得到 2000 份/50;
/// 同日重跑时,若该腿没有新流水,先从前一日 EOD 恢复 1000/100,再计算为 2000/50
@@ -698,7 +808,7 @@ namespace YLErp.Modules.SwapModule
foreach (var position in positions)
{
if (position.PosiDirection <= 0
|| position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund
|| !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType)
|| string.IsNullOrWhiteSpace(position.UnderlyingCode)
|| !exDividendByCode.TryGetValue(position.UnderlyingCode, out var dividendInfo)
|| !dividendInfo.EffectiveDate.HasValue
@@ -739,22 +849,25 @@ namespace YLErp.Modules.SwapModule
$"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】的除权份额参数导致除数为 0");
}
// 计算除权系数
var factors = DividendService.CalculateCorporateActionFactors(
dividendInfo,
corporateActionClosePrice,
dividendTaxRate);
if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0)
dividendTaxRate,
adjustCashDividendPrice: false);
if (factors.PriceRatio <= 0)
{
throw new InvalidOperationException(
$"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】计算得到无效除权系数");
}
// PriceRatio 是“除权前收盘价 / 除权参考价”,所以期初价格要除以它;ShareFactor
// 只来自送股/拆合股。10 送 10 时 1000 份/100 变为 2000 份/50,名义本金仍为 100000
// 每 10 份派现 10 时数量不变、价格基准降为 99,名义本金变为 99000,后续平一半只能扣 49500。
// Excel 公式 口径:PriceRatio 是“登记日收盘价 / 除权参考价”,
// 因此期初价格和持仓数量都使用同一个系数:P' = P / MQ' = Q * M。
// 配股已经进入 价格参考价,所以即使没有送股,配股也会调整 TRS 数量;
// 现金分红不影响 TRS Stock/Fund 期初价格,现金权益由独立分红字段处理。
var originalQuantity = position.PosiQuantity;
position.PosiQuantity = Math.Round(
originalQuantity * factors.ShareFactor,
originalQuantity * factors.PriceRatio,
12,
MidpointRounding.AwayFromZero);
position.TdChangedQty = position.PosiQuantity - originalQuantity;
@@ -815,10 +928,297 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 将 Fund 当日现金分红记入 EOD 已实现分红。
/// 构造审计事件的调整前快照。优先克隆上一 EOD,保证后续调整不会污染历史实体;
/// 交易首日没有 EOD 时才从初始持仓复制,并把累计分红/已实现字段初始化为 0。
/// </summary>
private static List<eod_swap_position> BuildCorporateActionBeforePositions(
IReadOnlyCollection<eod_swap_position> previousPositions,
IReadOnlyCollection<swap_position> initialPositions)
{
if (previousPositions != null && previousPositions.Count > 0)
{
return previousPositions
.Where(x => x != null)
.Select(x => x.Clone())
.ToList();
}
return (initialPositions ?? Array.Empty<swap_position>())
.Where(x => x != null)
.Select(x => new eod_swap_position
{
PositionId = x.PositionId,
UnderlyingCode = x.UnderlyingCode,
UnderlyingInstrumentType = x.UnderlyingInstrumentType,
PosiDirection = x.PosiDirection,
PositionType = x.PositionType,
ContractSize = x.ContractSize,
CountRatio = x.CountRatio,
PosiQuantity = x.PosiQuantity,
PosiGrossPrice = x.PosiGrossPrice,
PosiNetPrice = x.PosiNetPrice,
PosiNetFeePrice = x.PosiNetFeePrice,
PosiNetNoFeePrice = x.PosiNetNoFeePrice,
PosiNotionalValue = x.PosiNotionalValue,
PosiTradingFee = x.PosiTradingFee,
PosiFeePending = x.PosiTradingFeePending,
PosiDividendSum = 0m,
RealizedDividend = 0m,
PosiStatus = x.PosiQuantity == 0m ? 1 : 0
})
.ToList();
}
/// <summary>
/// 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线。
/// 不直接修改初始持仓实体,避免重收盘或后续流程再次读取时重复套用系数。
/// </summary>
private List<swap_position> PrepareInitialCorporateActionPositions(
IReadOnlyCollection<swap_position> initialPositions,
IReadOnlyDictionary<string, ex_dividend_info> exDividendByCode,
DateTime settleDate)
{
var positions = (initialPositions ?? Array.Empty<swap_position>())
.Where(x => x != null)
.Select(x => x.Clone())
.ToList();
if (positions.Count == 0 || exDividendByCode == null || exDividendByCode.Count == 0)
{
return positions;
}
foreach (var position in positions)
{
if (position.PosiDirection <= 0
|| !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType)
|| string.IsNullOrWhiteSpace(position.UnderlyingCode)
|| !exDividendByCode.TryGetValue(position.UnderlyingCode, out var info))
{
continue;
}
var closePrice = GetFundCorporateActionClosePrice(info, position.PosiGrossPrice);
ApplyFundCorporateActionToPosition(
position,
info,
closePrice,
GetDividendTaxRate());
}
return positions;
}
/// <summary>
/// 写入公司行为生命周期审计事件。
/// 登记日:保存调整前快照并标记 Applied=false
/// 真实除权日:使用上一 EOD 与当前 EOD 补齐调整后快照并标记 Applied=true。
/// 事件数据只追加/补齐,不删除已生效记录,
/// 便于交易回退后通过 BackId 关联新的回退记录。
/// </summary>
protected virtual void RecordCorporateActionEvents(
trade td,
IReadOnlyCollection<eod_swap_position> currentPositions,
IReadOnlyCollection<eod_swap_position> previousPositions,
IReadOnlyCollection<ex_dividend_info> registrationInfos,
IReadOnlyCollection<ex_dividend_info> effectiveInfos,
DateTime settleDate)
{
if (td == null || currentPositions == null)
{
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))
.GroupBy(x => new
{
x.id,
x.UnderlyingCode,
ExDividendDate = x.ExDividendDate?.Date,
EffectiveDate = x.EffectiveDate?.Date
})
.Select(x => x.First())
.ToList();
if (infos.Count == 0)
{
return;
}
var existingEvents = FindCorporateActionEvents(td.id);
foreach (var current in currentPositions.Where(x => x != null && x.PosiDirection > 0
&& IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType)))
{
var info = infos.FirstOrDefault(x => string.Equals(
x.UnderlyingCode,
current.UnderlyingCode,
StringComparison.OrdinalIgnoreCase));
if (info == null)
{
continue;
}
// 公司行为事件只使用“公司行为记录主键 + PositionId”作为幂等键。
var matchingEvents = existingEvents
.Select(x => new { Event = x, Data = DeserializeCorporateActionEventData(x.EventData) })
.Where(x => x.Data != null
&& info.id > 0
&& x.Data.ExDividendInfoId == info.id
&& x.Data.PositionId == current.PositionId)
.ToList();
var eventData = matchingEvents.FirstOrDefault(x => !x.Data.Applied)
?? matchingEvents.FirstOrDefault();
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);
// 如果没有匹配到事件或事件未生效,则创建新事件。
if (eventData == null || (!isEffective && eventData.Data.Applied))
{
// 创建新事件
var pending = BuildCorporateActionEventData(
info,
previous ?? current,
isEffective ? current : null,
applied: isEffective);
// 生命周期事件的发生日固定为登记日,EffectiveDate 只表示 Q/P 基线切换日。
// 这样回退后重收盘仍能按原登记日排序和追溯,不会把同一事件拆成两条历史。
var eventDate = info.ExDividendDate?.Date
?? info.EffectiveDate?.Date
?? settleDate.Date;
var created = AddSwapEvent(
eventDate,
td.id,
(int)SwapEventTypeEnum.,
JsonConvert.SerializeObject(pending),
0,
false,
BuildCorporateActionReason(pending));
if (created == null)
{
created = new swap_event();
}
// 测试接缝和历史实现可能返回只带 id 的实体;统一补齐字段,
// 确保同一收盘事务内的生效步骤能找到刚创建的事件。
created.EventType = (int)SwapEventTypeEnum.;
created.SwapTradeId = td.id;
created.ValueDate = eventDate;
created.EventData = JsonConvert.SerializeObject(pending);
created.EventReason = BuildCorporateActionReason(pending);
existingEvents.Add(created);
continue;
}
// 如果不是生效日或事件已生效,则跳过。
if (!isEffective || eventData.Data.Applied)
{
continue;
}
// 生效日只补齐同一事件的 Before/After 快照,不重新套系数:Before* 来自
// 调整前 EODAfter* 来自生效日当前 EOD,current 已由开盘基线处理完成。
eventData.Data.BeforeNotional = previous?.PosiNotionalValue ?? eventData.Data.BeforeNotional;
eventData.Data.BeforePrice = previous?.PosiGrossPrice ?? eventData.Data.BeforePrice;
eventData.Data.BeforeQuantity = previous?.PosiQuantity ?? eventData.Data.BeforeQuantity;
eventData.Data.BeforePendingDividend = previous?.PosiDividendSum ?? eventData.Data.BeforePendingDividend;
eventData.Data.AfterNotional = current.PosiNotionalValue;
eventData.Data.AfterPrice = current.PosiGrossPrice;
eventData.Data.AfterQuantity = current.PosiQuantity;
eventData.Data.AfterPendingDividend = current.PosiDividendSum;
eventData.Data.CashFlowChange = current.RealizedDividend - (previous?.RealizedDividend ?? current.RealizedDividend);
eventData.Data.Applied = true;
eventData.Event.EventData = JsonConvert.SerializeObject(eventData.Data);
eventData.Event.EventReason = BuildCorporateActionReason(eventData.Data);
UpdateCorporateActionEventRecord(eventData.Event);
}
}
public static CorporateActionEventData BuildCorporateActionEventData(
ex_dividend_info info,
eod_swap_position previous,
eod_swap_position current,
bool applied)
{
return new CorporateActionEventData
{
ExDividendInfoId = info.id,
PositionId = (current ?? previous).PositionId,
UnderlyingCode = (current ?? previous).UnderlyingCode,
ExDividendDate = info.ExDividendDate,
EffectiveDate = info.EffectiveDate,
GiveCashAmount = info.GiveCashAmount,
GiveShareAmount = info.GiveShareAmount,
Split = info.Split,
RationedSharesAmount = info.RationedSharesAmount,
RationedSharesPrice = info.RationedSharesPrice,
BeforeNotional = previous?.PosiNotionalValue ?? 0m,
BeforePrice = previous?.PosiGrossPrice ?? 0m,
BeforeQuantity = previous?.PosiQuantity ?? 0m,
AfterNotional = applied ? current?.PosiNotionalValue ?? 0m : 0m,
AfterPrice = applied ? current?.PosiGrossPrice ?? 0m : 0m,
AfterQuantity = applied ? current?.PosiQuantity ?? 0m : 0m,
BeforePendingDividend = previous?.PosiDividendSum ?? 0m,
AfterPendingDividend = applied ? current?.PosiDividendSum ?? 0m : 0m,
CashFlowChange = applied ? (current?.RealizedDividend ?? 0m) - (previous?.RealizedDividend ?? 0m) : 0m,
Applied = applied,
};
}
public static bool ShouldCreateCorporateActionEvent(
IEnumerable<swap_event> events,
ex_dividend_info info,
long positionId)
{
if (info == null)
{
return false;
}
// 幂等键与收盘事件匹配保持一致,只认 ExDividendInfoId + PositionId。
// 无法反序列化或缺少 ExDividendInfoId 的存量事件均不参与匹配。
return !(events ?? Enumerable.Empty<swap_event>()).Any(x =>
{
if (!SwapEventService.TryDeserializeCorporateActionEventData(x, out var data))
{
return false;
}
return info.id > 0
&& data.ExDividendInfoId == info.id
&& data.PositionId == positionId;
});
}
private static CorporateActionEventData DeserializeCorporateActionEventData(string eventData)
{
if (string.IsNullOrWhiteSpace(eventData))
{
return null;
}
try
{
return JsonConvert.DeserializeObject<CorporateActionEventData>(eventData);
}
catch (JsonException)
{
return null;
}
}
private static string BuildCorporateActionReason(CorporateActionEventData data)
{
return SwapEventService.BuildCorporateActionEventReason(data);
}
/// <summary>
/// 兼容旧测试/扩展调用的直接现金分红辅助方法。
/// GiveCashAmount 按每 10 份金额计算:1000 份、每 10 份派 10,结果为 1000。
/// 现金分红在生效日 EOD 即执行,因此 PosiDividendSum 不增加本次金额,
/// 同时从除权价格变化产生的 PosiMtmPnL 中剥离,避免收益重复计算。
/// 当前生产 SwapPositionCompose 不再调用此方法:公司行为现金分红由同步任务
/// 写入 bond_payment_infoEffectiveDate 收盘通过 CalcBondPayment 进入 EOD
/// 以避免登记日提前入账及与债券付息链路重复。保留方法是为了不破坏已有测试替身
/// 或外部扩展类的编译契约;新增业务代码不得再直接传入 ex_dividend_info。
/// </summary>
protected void ApplyFundCashDividends(
IReadOnlyCollection<eod_swap_position> currentEodPositions,
@@ -827,7 +1227,6 @@ namespace YLErp.Modules.SwapModule
DateTime settleDate)
{
if (currentEodPositions == null
|| previousEodPositions == null
|| exDividendByCode == null
|| exDividendByCode.Count == 0)
{
@@ -835,20 +1234,21 @@ namespace YLErp.Modules.SwapModule
}
var dividendTaxRate = GetDividendTaxRate();
var previousList = previousEodPositions ?? Array.Empty<eod_swap_position>();
foreach (var current in currentEodPositions)
{
if (current == null
|| current.PosiDirection == 0
|| current.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund
|| !IsTrsCorporateActionInstrument(current.UnderlyingInstrumentType)
|| string.IsNullOrWhiteSpace(current.UnderlyingCode)
|| !exDividendByCode.TryGetValue(current.UnderlyingCode, out var dividendInfo)
|| !dividendInfo.EffectiveDate.HasValue
|| dividendInfo.EffectiveDate.Value.Date != settleDate.Date)
|| !dividendInfo.ExDividendDate.HasValue
|| dividendInfo.ExDividendDate.Value.Date != settleDate.Date)
{
continue;
}
var previous = previousEodPositions.FirstOrDefault(
var previous = previousList.FirstOrDefault(
x => x != null && x.PositionId == current.PositionId);
var entitlementQuantity = previous?.PosiQuantity ?? current.PosiQuantity;
var directionRatio = DirectionRatio.ReceivePay(current.PosiDirection);
@@ -859,18 +1259,16 @@ namespace YLErp.Modules.SwapModule
* directionRatio
: 0m;
// 当日浮动端分红
// 当日浮动端分红。公司行为现金分红采用现金模式:不调期初价格,
// 只增加待实现分红,支付日仍由既有 DealDividends/付息链路结算。
current.TdPosiDividend = RoundMoney(currentDividend);
var previousDividendSum = previous?.PosiDividendSum ?? 0m;
// 浮动端平仓盈亏·分红未实现 = 未实现分红总和 - 当日浮动端平仓盈亏·分红
// 浮动端平仓盈亏·分红未实现 = 前日待实现 + 当日公司行为分红
// - 当日已实现分红;本次公司行为尚未支付,因此不能写入 RealizedDividend。
current.PosiDividendSum = current.PosiQuantity > 0m
? RoundMoney(previousDividendSum - current.TdCloseDividend)
? RoundMoney(previousDividendSum + current.TdPosiDividend - current.TdCloseDividend)
: 0m;
// 浮动端平仓盈亏·盯市未实现 = 盯市未实现 - 当日浮动端分红
// current.PosiMtmPnL = RoundMoney(current.PosiMtmPnL - current.TdPosiDividend);
// 浮动端已实现·分红 = 已实现分红 + 当日浮动端分红
current.RealizedDividend = RoundMoney(current.RealizedDividend + current.TdPosiDividend);
// 浮动端已实现·盈亏 = 盈亏 + 当日浮动端分红
// 现金模式不从 PosiMtmPnL 剥离分红:价格没有被除权,分红只存在于待实现字段。
current.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum(
current.PosiMtmPnL,
current.PosiDividendSum,
@@ -883,10 +1281,12 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 将一条真实生效日公司行为应用到盘中实时 Fund 浮动腿。
/// 将一条真实生效日公司行为应用到盘中实时 TRS Stock/Fund 浮动腿。
/// 盘中先复制严格早于 valueDate 的 EOD,再调用此方法;因此重复调用时每次都会
/// 从同一份除权前 EOD 重新恢复,不会把 1000/100 重复变成 4000/25。
/// 例:8 月 14 日 EOD 为 1000/1008 月 17 日生效的 10 送 10 会得到 2000/50。
/// 现金模式调用公式时使用 adjustCashDividendPrice=false,现金权益只进入分红字段,
/// 不改变 Stock/Fund 的期初价格。
/// </summary>
public static bool ApplyFundCorporateActionToPosition(
swap_position position,
@@ -897,7 +1297,7 @@ namespace YLErp.Modules.SwapModule
if (position == null
|| dividendInfo == null
|| position.PosiDirection <= 0
|| position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund
|| !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType)
|| corporateActionClosePrice <= 0)
{
return false;
@@ -906,8 +1306,9 @@ namespace YLErp.Modules.SwapModule
var factors = DividendService.CalculateCorporateActionFactors(
dividendInfo,
corporateActionClosePrice,
dividendTaxRate);
if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0)
dividendTaxRate,
adjustCashDividendPrice: false);
if (factors.PriceRatio <= 0)
{
throw new InvalidOperationException(
$"Fund 标的【{position.UnderlyingCode}】计算得到无效除权系数");
@@ -915,7 +1316,7 @@ namespace YLErp.Modules.SwapModule
var originalQuantity = position.PosiQuantity;
position.PosiQuantity = Math.Round(
originalQuantity * factors.ShareFactor,
originalQuantity * factors.PriceRatio,
12,
MidpointRounding.AwayFromZero);
position.PosiGrossPrice = Math.Round(
@@ -1203,9 +1604,10 @@ namespace YLErp.Modules.SwapModule
var hasDividend = curEodPositions.Any(x => x.PosiDividendSum != 0);
if (!hasDividend) return;
// ApplyFundCorporateActions 已经把 Fund 的现金分红写入除权后的期初价格/名义本金;
// 这里处理的是持仓期间累计的付息/分红结算流水。两者同时把同一现金再写入
// PosiDividendSum 会重复实现,故公司行为步骤不会在此处直接填充该字段。
// 公司行为现金分红与债券付息共用既有待实现/支付链路:公司行为步骤只把金额
// 累加到 PosiDividendSum,这里仍按交易约定的 DividendPayDate 生成支付流水。
// 公司行为不会调整 Stock/Fund 的期初价格;因此不能再把现金分红从 PosiMtmPnL
// 中剥离或当作已实现收益提前写入。
var dividendPayDateOffset = tradeExtend?.ExtendObj?.DividendPayDate ?? 1;
if (dividendPayDateOffset <= 0) return;