feat(bond): 支持债券与股票基金公司行为现金流计算的差异化处理 - init2
- 修改 CalcPayment 方法添加 useBondPriceScale 参数区分债券和股票/基金的金额计算口径 - 债券利息按每100元面值票息通过BondPriceConverter转为入库金额,股票基金分红直接计算 - 在BondPaymentService中添加详细的参数说明文档注释 - 更新SwapDealService中分红计算逻辑,根据标的类型自动选择合适的金额转换方式 - 新增CorporateActionEventLifecycleTest单元测试验证公司行为事件生命周期管理 - 添加SplitCorporateActionTddTest测试验证拆合股功能 - 优化FundCorporateActionRollbackAndUnwindTest扩展到股票类型测试 - 更新前端OperationHistory页面表格列宽和显示格式支持更长的说明信息
This commit is contained in:
@@ -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_info,Copy/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 / M,Q' = 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* 来自
|
||||
// 调整前 EOD,After* 来自生效日当前 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_info,EffectiveDate 收盘通过 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/100,8 月 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;
|
||||
|
||||
Reference in New Issue
Block a user