feat(trade): 新增真实除权日字段并完善基金公司行为处理 init
- 添加 EffectiveDate 字段用于标识真实除权生效日 - 实现公司行为价格系数和数量系数统一计算方法 - 增加基金除权回退和平仓基线恢复功能 - 完善除权日验证逻辑,确保真实除权日不早于股权登记日 - 重构平仓流程,支持按有效EOD基线重新计算损益和现金 - 添加基金拆合股和现金分红的特殊处理逻辑 - 增加单元测试验证各种公司行为场景下的正确性
This commit is contained in:
@@ -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=2,ShareFactor=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)
|
||||
|
||||
Reference in New Issue
Block a user