feat(bond): 支持债券与股票基金公司行为现金流计算的差异化处理 - init2
- 修改 CalcPayment 方法添加 useBondPriceScale 参数区分债券和股票/基金的金额计算口径 - 债券利息按每100元面值票息通过BondPriceConverter转为入库金额,股票基金分红直接计算 - 在BondPaymentService中添加详细的参数说明文档注释 - 更新SwapDealService中分红计算逻辑,根据标的类型自动选择合适的金额转换方式 - 新增CorporateActionEventLifecycleTest单元测试验证公司行为事件生命周期管理 - 添加SplitCorporateActionTddTest测试验证拆合股功能 - 优化FundCorporateActionRollbackAndUnwindTest扩展到股票类型测试 - 更新前端OperationHistory页面表格列宽和显示格式支持更长的说明信息
This commit is contained in:
@@ -748,44 +748,68 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
|
||||
internal readonly struct CorporateActionFactors
|
||||
{
|
||||
public CorporateActionFactors(decimal priceRatio, decimal shareFactor)
|
||||
public CorporateActionFactors(decimal priceRatio)
|
||||
{
|
||||
PriceRatio = priceRatio;
|
||||
ShareFactor = shareFactor;
|
||||
}
|
||||
|
||||
public decimal PriceRatio { get; }
|
||||
public decimal ShareFactor { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一计算公司行为的价格系数和数量系数。价格系数沿用原股票除权公式;
|
||||
/// 数量仅受送股/拆合股影响,配股仍只进入价格公式,保持现有业务口径不变。
|
||||
/// 按 Excel 公式计算公司行为的除权系数。
|
||||
/// GiveShareAmount 只表示每 10 份的送股数量,Split 表示独立的拆/合股倍数;
|
||||
/// Split 为空按 1 兼容历史记录。TRS Stock/Fund 使用 PriceRatio 同时调整期初价格
|
||||
/// 和持仓数量,不再维护独立的旧数量系数。
|
||||
/// <para>
|
||||
/// 送股例子:收盘价 100、每 10 份送 10 份、无现金/配股时,除权参考价为 50,
|
||||
/// PriceRatio=100/50=2,ShareFactor=2。调用方据此把 1000 份/期初价 100 调整为
|
||||
/// 2000 份/50;数量与价格反向变化,期初名义本金仍为 100000。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 现金例子:收盘价 100、每 10 份派现 10、税率 0 时,除权参考价为 99,
|
||||
/// ShareFactor 仍为 1,所以数量不变,只把期初价按 100/99 的价格系数下调。
|
||||
/// 现金分红不参与 TRS Stock/Fund 的期初价格公司行为系数;现金权益由既有分红流水单独处理。
|
||||
/// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static CorporateActionFactors CalculateCorporateActionFactors(
|
||||
ex_dividend_info info,
|
||||
decimal closePrice,
|
||||
decimal dividendRate)
|
||||
decimal dividendRate,
|
||||
bool adjustCashDividendPrice = true)
|
||||
{
|
||||
// (收盘价 * 10) - 现金分红 * (1 - 税率) + (配股数量 * 配股价格)
|
||||
// -------------------------------------------------------
|
||||
// (10 + 送股数量 + 配股数量)
|
||||
var exDividendPrice = (closePrice * 10m - info.GiveCashAmount * (1m - dividendRate)
|
||||
// 价格调整模式除权参考价 =
|
||||
// 收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价
|
||||
// - -----------------------------------------------------
|
||||
// (10 + 送股数 + 配股数) * 拆股倍数
|
||||
// 场内链路默认继续把现金派息计入除权参考价;
|
||||
// TRS Stock/Fund 现金模式显式关闭该项 :“【】” 号内数据。
|
||||
var cashPriceAdjustment = adjustCashDividendPrice
|
||||
? info.GiveCashAmount * (1m - dividendRate)
|
||||
: 0m;
|
||||
// 拆股倍数
|
||||
var splitFactor = GetSplitFactor(info);
|
||||
// 除权参考价(TRS) :
|
||||
// 收盘价 * 10 + 配股数 * 配股价
|
||||
// ------------------------------
|
||||
// (10 + 送股数 + 配股数) * 拆股倍数
|
||||
var exDividendPrice = ((closePrice * 10m - cashPriceAdjustment
|
||||
+ info.RationedSharesAmount * info.RationedSharesPrice)
|
||||
/ (10m + info.GiveShareAmount + info.RationedSharesAmount);
|
||||
/ (10m + info.GiveShareAmount + info.RationedSharesAmount))
|
||||
/ splitFactor;
|
||||
// 除权系数 = 股权登记日收盘价 / 除权除息参考价
|
||||
var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice;
|
||||
var shareFactor = 1m + info.GiveShareAmount / 10m;
|
||||
return new CorporateActionFactors(priceRatio, shareFactor);
|
||||
return new CorporateActionFactors(priceRatio);
|
||||
}
|
||||
|
||||
private static decimal GetSplitFactor(ex_dividend_info info)
|
||||
{
|
||||
if (info == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(info));
|
||||
}
|
||||
if (info.Split.HasValue && info.Split.Value <= 0m)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(info.Split), "拆/合股倍数必须大于 0");
|
||||
}
|
||||
|
||||
// Split 为空表示未提供拆合股信息,按 1 兼容历史记录;例如 Split=0.1 时,
|
||||
// 1000 份/100 元调整为 100 份/1000 元。0 或负数无法表达有效份额比例,直接拒绝。
|
||||
return info.Split ?? 1m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -798,10 +822,9 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
}
|
||||
|
||||
/**
|
||||
* 在没有现金分红和配股时:
|
||||
* 拆合股:10*closePrice / 10+GiveShareAmount
|
||||
* 调整后数量 = 原数量 × 除权系数
|
||||
* 调整后价格 = 原价格 ÷ 除权系数
|
||||
* GiveShareAmount 表示每 10 份送股数量,Split 表示独立拆/合股倍数(空值按 1);
|
||||
* 调整后数量 = 原数量 × (1 + GiveShareAmount / 10) × Split;
|
||||
* 调整后价格 = 原价格 ÷ 上述数量系数(配股只参与非现金价格公式)。
|
||||
*/
|
||||
private decimal GetRatioDecimal(ex_dividend_info info)
|
||||
{
|
||||
@@ -833,9 +856,12 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
/// <returns></returns>
|
||||
public double GetPositionAmount(double amount, ex_dividend_info info)
|
||||
{
|
||||
// 数量只按送股/拆合股调整,现金分红和配股不增加持仓数量;10 送 10 时
|
||||
// 1000 份变为 2000 份,价格系数由 GetRatioDecimal 单独计算,不能在此重复套用。
|
||||
var result = (decimal)amount * (1m + info.GiveShareAmount / 10m);
|
||||
// 这是旧场内/兼容链路的数量接口;TRS Stock/Fund 不走这里,而是在
|
||||
// SwapEodPositionService 中按 Excel公式 使用 PriceRatio。旧链路数量只按
|
||||
// 送股和独立拆合股调整,现金分红和配股不增加持仓数量。
|
||||
var result = (decimal)amount
|
||||
* (1m + info.GiveShareAmount / 10m)
|
||||
* GetSplitFactor(info);
|
||||
return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
@@ -900,6 +926,7 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
: (DateTime?)null,
|
||||
GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0,
|
||||
GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0,
|
||||
Split = decimal.TryParse(getColValueFromTable(dt.Rows[i], "拆/合股倍数"), out var split) ? split : (decimal?)null,
|
||||
RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0,
|
||||
RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0,
|
||||
OptId = OptUser.UserId,
|
||||
@@ -922,6 +949,10 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
{
|
||||
throw new ServiceException($"第{i + 1}行真实除权日不正确");
|
||||
}
|
||||
if (info.Split.HasValue && info.Split.Value <= 0m)
|
||||
{
|
||||
throw new ServiceException($"第{i + 1}行拆/合股倍数必须大于0");
|
||||
}
|
||||
if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date)
|
||||
{
|
||||
throw new ServiceException($"第{i + 1}行真实除权日不应早于股权登记日");
|
||||
@@ -985,6 +1016,11 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
{
|
||||
target.RationedSharesPrice = source.RationedSharesPrice;
|
||||
}
|
||||
if (source.Split.HasValue)
|
||||
{
|
||||
// Split 为空表示本次未提供,不能按历史兼容值 1 清空或覆盖旧倍数;明确提供 1 才覆盖。
|
||||
target.Split = source.Split.Value;
|
||||
}
|
||||
if (source.EffectiveDate.HasValue)
|
||||
{
|
||||
// EffectiveDate 是日期语义,导入/接口可能带时分秒;统一只保留自然日。
|
||||
@@ -1026,6 +1062,11 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
errMsg = "股权登记日信息不存在";
|
||||
return false;
|
||||
}
|
||||
if (item.Split.HasValue && item.Split.Value <= 0m)
|
||||
{
|
||||
errMsg = "拆/合股倍数必须大于0";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间
|
||||
// 能命中同一个自然日业务键,也与数据库的一行模型保持一致。
|
||||
@@ -1049,6 +1090,12 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6);
|
||||
item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6);
|
||||
item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6);
|
||||
if (item.Split.HasValue)
|
||||
{
|
||||
// 拆合股比例可能为 0.01、0.001 等小数,保留 12 位避免导入时
|
||||
// 被 6 位金额精度截断;日期字段则在上方统一归一化为自然日。
|
||||
item.Split = OtcFormatHelper.FormatValue(item.Split.Value, 12);
|
||||
}
|
||||
|
||||
// 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录
|
||||
// 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。
|
||||
@@ -1177,6 +1224,37 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
/// <returns></returns>
|
||||
public bool checkDividendInfoExecuteStatus(ex_dividend_info info)
|
||||
{
|
||||
// TRS 公司行为以 EffectiveDate 为真正生效边界。登记日创建待生效事件不应锁定
|
||||
// 维护;只有交易已经完成 EffectiveDate(例如收盘到 7 月 30 日,而真实除权日为
|
||||
// 7 月 29 日)才禁止修改,避免修改后无法解释已落库的调整前后快照。
|
||||
if (info?.EffectiveDate.HasValue == true)
|
||||
{
|
||||
var effectiveDate = info.EffectiveDate.Value.Date;
|
||||
var trsTradeIds = DbContext.trade
|
||||
.Where(x => x.ValidState != ConsGlobal.InValid
|
||||
&& x.TradeType == "收益互换"
|
||||
&& x.UnderlyingCode == info.UnderlyingCode
|
||||
&& x.TradeDate <= effectiveDate
|
||||
&& x.ExerciseDate >= effectiveDate)
|
||||
.Select(x => x.id)
|
||||
.ToList();
|
||||
if (trsTradeIds.Count > 0)
|
||||
{
|
||||
// 是否仍被交易引用以当前有效 EOD 为准。公司行为事件本身是不可篡改
|
||||
// 历史,交易回退后仍会保留;若仅凭 Applied 事件锁定,回退到登记日前
|
||||
// 也无法纠错。生效日及以后还有有效 EOD 才表示当前仍已执行。
|
||||
var hasAppliedEod = DbContext.eod_swap_position.Any(x =>
|
||||
trsTradeIds.Contains(x.SwapTradeId)
|
||||
&& !x.Invalid
|
||||
&& x.UnderlyingCode == info.UnderlyingCode
|
||||
&& x.ValueDate >= effectiveDate);
|
||||
if (hasAppliedEod)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var eodStatus = DbContext.eodStatus.Where(O => O.ValueDate == info.ExDividendDate && O.OptDate > info.OptDate).Any();
|
||||
if (eodStatus)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user