refactor(dividend): 优化除权除息计算逻辑并改进数据类型精度

- 将除权除息相关数值字段从 double 类型改为 decimal 类型以提高精度
- 重构了红利比率计算方法 GetRatio,新增 GetRatioDecimal 方法使用 decimal 计算
- 修改价格和持仓数量计算逻辑,统一使用 decimal 进行高精度运算
- 更新数据库查询逻辑,将篮子标的判断从 IsBasket() 方法改为 CommodityCode 条件
- 优化 AddDividendInfos 方法中的批量处理逻辑,增加业务键冲突检测
- 添加数据源标识字段 DataSource 和来源更新时间字段 SourceUpdatedAt
- 新增 FindExDividendByBusinessKey 和 MergeNonZeroDividendValues 辅助方法
- 更新结算服务中除权除息信息的获取方式,使用字典查找替代 LINQ JOIN
- 修复前端保存除权信息时的响应处理逻辑
- 为基金类型也开放除权功能,不仅限于股票类型
- 添加单元测试验证篮子标的查询翻译逻辑的正确性
This commit is contained in:
张名锐
2026-08-13 10:53:57 +08:00
parent 304e04d60a
commit 5a51d66970
8 changed files with 296 additions and 80 deletions
@@ -28,7 +28,8 @@ namespace YLErp.Modules.TradeModule.DealModule
public List<BodTradePosition> Execute(DateTime settleDate, IEnumerable<EodTradePosition> positions)
{
var result = new List<BodTradePosition>();
var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V);
var dict = GetExDividendQuery(settleDate)
.ToDictionary(K => K.UnderlyingId, V => V);
foreach (var item in positions)
{
double cost = item.Cost,
@@ -76,7 +77,8 @@ namespace YLErp.Modules.TradeModule.DealModule
useSaveTrades = new List<trade>();
useSaveUndedrlyings = new List<underlying_manager>();
var result = new List<bod_trade>();
var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V);
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; });
foreach (var t in trades)
@@ -713,9 +715,15 @@ namespace YLErp.Modules.TradeModule.DealModule
{
return 0;
}
var ratio = overrideDividendRatio != null ? overrideDividendRatio.Value : GetRatio(info);
double? result = price / ratio;
return Math.Round(result ?? 0, 4, MidpointRounding.AwayFromZero);
var decimalRatio = overrideDividendRatio.HasValue
? (decimal)overrideDividendRatio.Value
: GetRatioDecimal(info);
if (decimalRatio == 0)
{
return 0;
}
var result = (decimal)price / decimalRatio;
return (double)Math.Round(result, 4, MidpointRounding.AwayFromZero);
}
/// <summary>
@@ -725,10 +733,17 @@ namespace YLErp.Modules.TradeModule.DealModule
/// <returns></returns>
public double GetRatio(ex_dividend_info info)
{
var dividendRate = valuedateBLL.SystemDate.DividendRate / 100;
return (double)GetRatioDecimal(info);
}
private decimal GetRatioDecimal(ex_dividend_info info)
{
var dividendRate = (decimal)valuedateBLL.SystemDate.DividendRate / 100m;
var closePrice = new EodPriceProvider(info.ExDividendDate.Value).GetPrice(info.UnderlyingCode, SettlementTypeEnum.ClosePrice);
var cDivdPrice = (closePrice * 10.0 - (info.GiveCashAmount * (1 - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / (10 + info.GiveShareAmount + info.RationedSharesAmount);
return closePrice / cDivdPrice;
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;
}
/// <summary>
@@ -751,18 +766,20 @@ namespace YLErp.Modules.TradeModule.DealModule
/// <returns></returns>
public double GetPositionAmount(double amount, ex_dividend_info info)
{
double? result = amount * (1 + info.GiveShareAmount / 10.0);
return Math.Round(result ?? 0, 12);
var result = (decimal)amount * (1m + info.GiveShareAmount / 10m);
return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero);
}
public IQueryable<ex_dividend_info> GetExDividendQuery(DateTime valueDate)
{
return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == valueDate);
return DbContext.ex_dividend_info
.Where(O => O.ValidStatus && O.ExDividendDate == valueDate);
}
public IQueryable<ex_dividend_info> GetExDividendQuery(DateTime dateStart, DateTime dateEnd)
{
return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd);
return DbContext.ex_dividend_info
.Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd);
}
public IEnumerable<ex_dividend_info> GetExDividends(DateTime valueDate, params int[] underlyingIds)
@@ -772,7 +789,7 @@ namespace YLErp.Modules.TradeModule.DealModule
{
query = query.Where(n => underlyingIds.Contains(n.UnderlyingId));
}
return query.ToArray();
return query;
}
public IQueryable<ex_dividend_info> GetExDividendInfos(string underlyingCode)
@@ -797,17 +814,17 @@ namespace YLErp.Modules.TradeModule.DealModule
{
throw new ServiceException("请使用正确的模板上传");
}
var dict = new Dictionary<string, ex_dividend_info>();
var dividendInfos = new List<ex_dividend_info>();
for (var i = 0; i < dt.Rows.Count; i++)
{
var info = new ex_dividend_info
{
UnderlyingCode = dt.Rows[i]["股票代码"]?.ToString(),
ExDividendDate = DateTime.TryParse(getColValueFromTable(dt.Rows[i], "股权登记日"), out var date) ? date : DateTime.MinValue,
GiveCashAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0,
GiveShareAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0,
RationedSharesAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0,
RationedSharesPrice = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0,
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,
RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0,
OptId = OptUser.UserId,
OptName = OptUser.UserName,
OptDate = DateTime.Now
@@ -824,9 +841,9 @@ namespace YLErp.Modules.TradeModule.DealModule
{
throw new ServiceException($"第{i + 1}行股权登记日不正确");
}
dict[$"{info.ExDividendDate}{info.UnderlyingCode}"] = info;
dividendInfos.Add(info);
}
if (!AddDividendInfos(dict.Values, out var errMsg))
if (!AddDividendInfos(dividendInfos, out var errMsg))
{
throw new ServiceException(errMsg);
}
@@ -841,48 +858,149 @@ namespace YLErp.Modules.TradeModule.DealModule
return "";
}
private ex_dividend_info FindExDividendByBusinessKey(int underlyingId, DateTime exDividendDate, int excludedId = 0)
{
return DbContext.ex_dividend_info.FirstOrDefault(O => O.UnderlyingId == underlyingId
&& O.ExDividendDate >= exDividendDate
&& O.ExDividendDate < exDividendDate.AddDays(1)
&& (excludedId <= 0 || O.id != excludedId));
}
private static void MergeNonZeroDividendValues(ex_dividend_info target, ex_dividend_info source)
{
if (target == null)
{
throw new ArgumentNullException(nameof(target));
}
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
if (source.GiveCashAmount != 0m)
{
target.GiveCashAmount = source.GiveCashAmount;
}
if (source.GiveShareAmount != 0m)
{
target.GiveShareAmount = source.GiveShareAmount;
}
if (source.RationedSharesAmount != 0m)
{
target.RationedSharesAmount = source.RationedSharesAmount;
}
if (source.RationedSharesPrice != 0m)
{
target.RationedSharesPrice = source.RationedSharesPrice;
}
}
public bool AddDividendInfos(IEnumerable<ex_dividend_info> infos, out string errMsg)
{
try
{
var keys = infos.Select(O => $"{O.ExDividendDate?.ToString("yyyy-MM-dd")}{O.UnderlyingCode}");
var ids = infos.Select(O => O.id).ToHashSet();
var data = from dividendDb in DbContext.ex_dividend_info.Where(O => keys.Contains(O.ExDividendDate + O.UnderlyingCode) && O.ValidStatus)
where !ids.Contains(dividendDb.id)
select dividendDb;
if (data.Any())
var dividendInfos = infos?.ToList();
if (dividendInfos == null || dividendInfos.Count == 0)
{
var dd = data.Select(O => O.UnderlyingCode + "_" + O.ExDividendDate).ToArray();
errMsg = string.Join(",", dd) + "已存在除息信息,请修改原数据";
errMsg = "没有可保存的除权除息信息";
return false;
}
var basketList =
DataCacheProvider.GetUnderlyingDataSource()
.AsQueryable().Where(O => O.IsBasket() && O.SubData != null)
.Select(O => new { O.UnderlyingCode, O.SubData });
IEnumerable<eod_stock_price> priceList = null;
foreach (var item in infos)
var preparedInfos = new List<(ex_dividend_info Item, underlying_manager Underlying, DateTime ExDividendDate)>();
var preparedIndexes = new Dictionary<(int UnderlyingId, DateTime ExDividendDate), int>();
var recordKeys = new Dictionary<int, (int UnderlyingId, DateTime ExDividendDate)>();
foreach (var item in dividendInfos)
{
if (item == null || string.IsNullOrWhiteSpace(item.UnderlyingCode))
{
errMsg = "标的代码信息不存在";
return false;
}
var underlying = underlying_managerBLL.GetByCode(item.UnderlyingCode);
if (underlying == null)
{
errMsg = $"{item.UnderlyingCode} 标的信息不存在";
return false;
}
if (!item.ExDividendDate.HasValue)
{
errMsg = "股权登记日信息不存在";
return false;
}
var exDividendDate = item.ExDividendDate.Value.Date;
var businessKey = (underlying.id, exDividendDate);
if (item.id > 0
&& recordKeys.TryGetValue(item.id, out var existingRecordKey)
&& existingRecordKey != businessKey)
{
errMsg = "同一除权信息不能重复保存";
return false;
}
item.UnderlyingId = underlying.id;
item.GiveCashAmount = item.GiveCashAmount.FormatValue(6);
item.RationedSharesAmount = item.RationedSharesAmount.FormatValue(6);
item.RationedSharesPrice = item.RationedSharesPrice.FormatValue(6);
item.GiveShareAmount = item.GiveShareAmount.FormatValue(6);
item.ValidStatus = true;
item.OptId = OptUser.UserId;
item.OptName = OptUser.UserName;
item.OptDate = DateTime.Now;
var dividend = item.id > 0 ? DbContext.ex_dividend_info.Where(O => O.id == item.id).FirstOrDefault() : null;
item.ExDividendDate = exDividendDate;
item.GiveCashAmount = OtcFormatHelper.FormatValue(item.GiveCashAmount, 6);
item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6);
item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6);
item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6);
if (preparedIndexes.TryGetValue(businessKey, out var preparedIndex))
{
var preparedItem = preparedInfos[preparedIndex].Item;
// 批量保存除权信息时,检测同一业务键(标的+日期)下是否存在冲突的数据库记录。
if ((preparedItem.id == 0) != (item.id == 0)
|| preparedItem.id > 0 && item.id > 0 && preparedItem.id != item.id)
{
errMsg = $"{item.UnderlyingCode} {exDividendDate:yyyy-MM-dd}除权信息不能合并不同记录";
return false;
}
MergeNonZeroDividendValues(preparedItem, item);
if (item.id > 0)
{
recordKeys[item.id] = businessKey;
}
continue;
}
if (item.id > 0)
{
recordKeys[item.id] = businessKey;
}
preparedIndexes.Add(businessKey, preparedInfos.Count);
preparedInfos.Add((item, underlying, exDividendDate));
}
var basketList =
DataCacheProvider.GetUnderlyingDataSource()
.AsQueryable().Where(O => O.CommodityCode == "篮子标的" && O.SubData != null)
.Select(O => new { O.UnderlyingCode, O.SubData });
IEnumerable<eod_stock_price> priceList = null;
foreach (var prepared in preparedInfos)
{
var item = prepared.Item;
var underlying = prepared.Underlying;
var itemDate = prepared.ExDividendDate;
var dividend = item.id > 0
? DbContext.ex_dividend_info.FirstOrDefault(O => O.id == item.id)
: FindExDividendByBusinessKey(underlying.id, itemDate);
if (dividend == null)
{ DbContext.ex_dividend_info.Add(item); }
{
if (item.id > 0)
{
errMsg = "未找到要修改的除权除息信息";
return false;
}
item.DataSource = ExDividendDataSources.Manual;
item.SourceUpdatedAt = null;
item.ValidStatus = true;
item.OptId = OptUser.UserId;
item.OptName = OptUser.UserName;
item.OptDate = DateTime.Now;
DbContext.ex_dividend_info.Add(item);
}
else
{
if (checkDividendInfoExecuteStatus(dividend))
@@ -890,6 +1008,13 @@ namespace YLErp.Modules.TradeModule.DealModule
errMsg = $"{dividend.UnderlyingCode} {dividend.ExDividendDate?.ToString("yyyy-MM-dd")}除权信息保存失败,该信息已被执行,不允许修改!";
return false;
}
var conflictingDividend = FindExDividendByBusinessKey(underlying.id, itemDate, dividend.id);
if (conflictingDividend != null)
{
errMsg = $"{item.UnderlyingCode} {itemDate:yyyy-MM-dd}除权信息已存在,不能修改为该业务键";
return false;
}
var sourceUpdatedAt = dividend.SourceUpdatedAt;
dividend.UnderlyingCode = item.UnderlyingCode;
dividend.UnderlyingId = item.UnderlyingId;
dividend.ExDividendDate = item.ExDividendDate;
@@ -897,21 +1022,23 @@ namespace YLErp.Modules.TradeModule.DealModule
dividend.RationedSharesAmount = item.RationedSharesAmount;
dividend.RationedSharesPrice = item.RationedSharesPrice;
dividend.GiveShareAmount = item.GiveShareAmount;
dividend.ValidStatus = item.ValidStatus;
dividend.OptId = item.OptId;
dividend.OptName = item.OptName;
dividend.OptDate = item.OptDate;
dividend.ValidStatus = true;
dividend.DataSource = ExDividendDataSources.Manual;
dividend.SourceUpdatedAt = sourceUpdatedAt;
dividend.OptId = OptUser.UserId;
dividend.OptName = OptUser.UserName;
dividend.OptDate = DateTime.Now;
}
if (!basketList.Any())
{
continue;
}
var codes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode);
if (!codes.Any())
var basketCodes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode);
if (!basketCodes.Any())
{
continue;
}
var removePriceList = DbContext.eod_stock_price.Where(O => codes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate);
var removePriceList = DbContext.eod_stock_price.Where(O => basketCodes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate);
if (!removePriceList.Any())
{
continue;
@@ -954,7 +1081,7 @@ namespace YLErp.Modules.TradeModule.DealModule
return true;
}
//查询篮子标的对应交易是否执行过收盘操作;
var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.IsBasket() && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray();
var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.CommodityCode == "篮子标的" && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray();
tradeQuery = from t in DbContext.trade.Where(O => umList.Contains(O.UnderlyingCode) && O.TradeDate <= info.ExDividendDate && O.ExerciseDate >= info.ExDividendDate && O.DividendDate >= O.TradeDate)
join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus))
on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate }