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
@@ -20,6 +20,11 @@ namespace YLErp.Modules.TradeModule.DealModule
}
public DividendService(YLBaseService baseService) : base(baseService)
{
}
/// <summary>
/// 场内交易除权除息
/// </summary>
@@ -80,7 +85,9 @@ namespace YLErp.Modules.TradeModule.DealModule
var dict = GetExDividendQuery(settleDate)
.ToDictionary(K => K.UnderlyingId, V => V);
var tradeIds = trades.Select(O => O.id);
var dividendRatioDict = new DbRecordChangesService<TradeChanges>(this).GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate).ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; });
var dividendRatioDict = new DbRecordChangesService<TradeChanges>(this)
.GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate)
.ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; });
foreach (var t in trades)
{
var bodTrade = new bod_trade();
@@ -102,7 +109,9 @@ namespace YLErp.Modules.TradeModule.DealModule
{
annualizeFactor = t.trade_snowball.AnnualizeFactor2;
}
t.StockEqvNotionalReal = t.StockEqvNotionalReal == 0 ? TradeHelper.GetStockEqvNotionalReal(t.OriginalStockEqvNotional, t.ParticipationRate, annualizeFactor) : t.StockEqvNotionalReal;
t.StockEqvNotionalReal = t.StockEqvNotionalReal == 0
? TradeHelper.GetStockEqvNotionalReal(t.OriginalStockEqvNotional, t.ParticipationRate, annualizeFactor)
: t.StockEqvNotionalReal;
t.OriginalNotional = t.StockEqvNotionalReal / t.SpotPrice;
t.TradeOriginalAmount = t.OriginalNotional / (t.CountRatio ?? 1);
//不管是不是名义本金方式了结,都应该按照比例了结。--时嬴政
@@ -722,6 +731,7 @@ namespace YLErp.Modules.TradeModule.DealModule
{
return 0;
}
// 价格 / 系数
var result = (decimal)price / decimalRatio;
return (double)Math.Round(result, 4, MidpointRounding.AwayFromZero);
}
@@ -736,14 +746,71 @@ namespace YLErp.Modules.TradeModule.DealModule
return (double)GetRatioDecimal(info);
}
internal readonly struct CorporateActionFactors
{
public CorporateActionFactors(decimal priceRatio, decimal shareFactor)
{
PriceRatio = priceRatio;
ShareFactor = shareFactor;
}
public decimal PriceRatio { get; }
public decimal ShareFactor { get; }
}
/// <summary>
/// 统一计算公司行为的价格系数和数量系数。价格系数沿用原股票除权公式;
/// 数量仅受送股/拆合股影响,配股仍只进入价格公式,保持现有业务口径不变。
/// <para>
/// 送股例子:收盘价 100、每 10 份送 10 份、无现金/配股时,除权参考价为 50,
/// PriceRatio=100/50=2ShareFactor=2。调用方据此把 1000 份/期初价 100 调整为
/// 2000 份/50;数量与价格反向变化,期初名义本金仍为 100000。
/// </para>
/// <para>
/// 现金例子:收盘价 100、每 10 份派现 10、税率 0 时,除权参考价为 99,
/// ShareFactor 仍为 1,所以数量不变,只把期初价按 100/99 的价格系数下调。
/// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。
/// </para>
/// </summary>
internal static CorporateActionFactors CalculateCorporateActionFactors(
ex_dividend_info info,
decimal closePrice,
decimal dividendRate)
{
// (收盘价 * 10) - 现金分红 * (1 - 税率) + (配股数量 * 配股价格)
// -------------------------------------------------------
// (10 + 送股数量 + 配股数量)
var exDividendPrice = (closePrice * 10m - info.GiveCashAmount * (1m - dividendRate)
+ info.RationedSharesAmount * info.RationedSharesPrice)
/ (10m + info.GiveShareAmount + info.RationedSharesAmount);
var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice;
var shareFactor = 1m + info.GiveShareAmount / 10m;
return new CorporateActionFactors(priceRatio, shareFactor);
}
/// <summary>
/// 将系统配置中的百分数税率转换为公司行为公式使用的小数税率。
/// 例如配置 13 表示 13%,返回 0.13;送股系数不使用该税率,只有现金分红的税后金额使用。
/// </summary>
internal decimal GetDividendTaxRateDecimal()
{
return (decimal)valuedateBLL.SystemDate.DividendRate / 100m;
}
/**
* 在没有现金分红和配股时:
* 拆合股:10*closePrice / 10+GiveShareAmount
* 调整后数量 = 原数量 × 除权系数
* 调整后价格 = 原价格 ÷ 除权系数
*/
private decimal GetRatioDecimal(ex_dividend_info info)
{
var dividendRate = (decimal)valuedateBLL.SystemDate.DividendRate / 100m;
// 除权系数依赖除权登记日收盘价;调用方若在收盘前或使用非标准日期调用,
// EodPriceProvider 可能拿不到价格并返回无效系数,不能把该情况默认为 1。
var dividendRate = GetDividendTaxRateDecimal();
var closePrice = new EodPriceProvider(info.ExDividendDate.Value).GetPrice(info.UnderlyingCode, SettlementTypeEnum.ClosePrice);
var decimalClosePrice = (decimal)closePrice;
var cDivdPrice = (decimalClosePrice * 10m - (info.GiveCashAmount * (1m - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) /
(10m + info.GiveShareAmount + info.RationedSharesAmount);
return cDivdPrice == 0 ? 0 : decimalClosePrice / cDivdPrice;
return CalculateCorporateActionFactors(info, decimalClosePrice, dividendRate).PriceRatio;
}
/// <summary>
@@ -766,12 +833,16 @@ 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);
return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero);
}
public IQueryable<ex_dividend_info> GetExDividendQuery(DateTime valueDate)
{
// 该查询沿用作业的“日期已归一化”约定,要求 valueDate 与存量 ExDividendDate
// 同为当天 00:00;自然日业务键的时分秒兼容由保存路径 FindExDividendByBusinessKey 负责。
return DbContext.ex_dividend_info
.Where(O => O.ValidStatus && O.ExDividendDate == valueDate);
}
@@ -810,7 +881,9 @@ namespace YLErp.Modules.TradeModule.DealModule
public void ImportDividendInfos(Stream stream)
{
var dt = new ExcelHelper().ExcelToDataTable(stream, null, true);
if (!dt.Columns.Contains("股票代码") || !dt.Columns.Contains("股权登记日"))
if (!dt.Columns.Contains("股票代码")
|| !dt.Columns.Contains("股权登记日")
|| !dt.Columns.Contains("真实除权日"))
{
throw new ServiceException("请使用正确的模板上传");
}
@@ -821,6 +894,10 @@ namespace YLErp.Modules.TradeModule.DealModule
{
UnderlyingCode = dt.Rows[i]["股票代码"]?.ToString(),
ExDividendDate = DateTime.TryParse(getColValueFromTable(dt.Rows[i], "股权登记日"), out var date) ? date : DateTime.MinValue,
EffectiveDate = DateTime.TryParse(
getColValueFromTable(dt.Rows[i], "真实除权日"), out var effectiveDate)
? effectiveDate
: (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,
RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0,
@@ -841,6 +918,14 @@ namespace YLErp.Modules.TradeModule.DealModule
{
throw new ServiceException($"第{i + 1}行股权登记日不正确");
}
if (!info.EffectiveDate.HasValue)
{
throw new ServiceException($"第{i + 1}行真实除权日不正确");
}
if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date)
{
throw new ServiceException($"第{i + 1}行真实除权日不应早于股权登记日");
}
dividendInfos.Add(info);
}
if (!AddDividendInfos(dividendInfos, out var errMsg))
@@ -900,6 +985,12 @@ namespace YLErp.Modules.TradeModule.DealModule
{
target.RationedSharesPrice = source.RationedSharesPrice;
}
if (source.EffectiveDate.HasValue)
{
// EffectiveDate 是日期语义,导入/接口可能带时分秒;统一只保留自然日。
// 为空时不覆盖数据库已有值,避免旧记录在不完整导入中丢失真实生效日。
target.EffectiveDate = source.EffectiveDate.Value.Date;
}
}
public bool AddDividendInfos(IEnumerable<ex_dividend_info> infos, out string errMsg)
@@ -939,6 +1030,10 @@ namespace YLErp.Modules.TradeModule.DealModule
// 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间
// 能命中同一个自然日业务键,也与数据库的一行模型保持一致。
var exDividendDate = item.ExDividendDate.Value.Date;
if (item.EffectiveDate.HasValue)
{
item.EffectiveDate = item.EffectiveDate.Value.Date;
}
var businessKey = (underlying.id, exDividendDate);
if (item.id > 0
&& recordKeys.TryGetValue(item.id, out var existingRecordKey)