Merge remote-tracking branch 'origin/glms/feature/1.4.2' into glms/feature/1.4.2-margin
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,94 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
return (double)GetRatioDecimal(info);
|
||||
}
|
||||
|
||||
internal readonly struct CorporateActionFactors
|
||||
{
|
||||
public CorporateActionFactors(decimal priceRatio)
|
||||
{
|
||||
PriceRatio = priceRatio;
|
||||
}
|
||||
|
||||
public decimal PriceRatio { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 Excel 公式计算公司行为的除权系数。
|
||||
/// GiveShareAmount 只表示每 10 份的送股数量,Split 表示独立的拆/合股倍数;
|
||||
/// Split 为空按 1 兼容历史记录。TRS Stock/Fund 使用 PriceRatio 同时调整期初价格
|
||||
/// 和持仓数量,不再维护独立的旧数量系数。
|
||||
/// <para>
|
||||
/// 现金分红不参与 TRS Stock/Fund 的期初价格公司行为系数;现金权益由既有分红流水单独处理。
|
||||
/// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static CorporateActionFactors CalculateCorporateActionFactors(
|
||||
ex_dividend_info info,
|
||||
decimal closePrice,
|
||||
decimal dividendRate,
|
||||
bool adjustCashDividendPrice = true)
|
||||
{
|
||||
// 价格调整模式除权参考价 =
|
||||
// 收盘价 * 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))
|
||||
/ splitFactor;
|
||||
// 除权系数 = 股权登记日收盘价 / 除权除息参考价
|
||||
var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice;
|
||||
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>
|
||||
/// 将系统配置中的百分数税率转换为公司行为公式使用的小数税率。
|
||||
/// 例如配置 13 表示 13%,返回 0.13;送股系数不使用该税率,只有现金分红的税后金额使用。
|
||||
/// </summary>
|
||||
internal decimal GetDividendTaxRateDecimal()
|
||||
{
|
||||
return (decimal)valuedateBLL.SystemDate.DividendRate / 100m;
|
||||
}
|
||||
|
||||
/**
|
||||
* GiveShareAmount 表示每 10 份送股数量,Split 表示独立拆/合股倍数(空值按 1);
|
||||
* 调整后数量 = 原数量 × (1 + GiveShareAmount / 10) × Split;
|
||||
* 调整后价格 = 原价格 ÷ 上述数量系数(配股只参与非现金价格公式)。
|
||||
*/
|
||||
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 +856,19 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
/// <returns></returns>
|
||||
public double GetPositionAmount(double amount, ex_dividend_info info)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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 +907,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,8 +920,13 @@ 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,
|
||||
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,
|
||||
@@ -841,6 +945,18 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
{
|
||||
throw new ServiceException($"第{i + 1}行股权登记日不正确");
|
||||
}
|
||||
if (!info.EffectiveDate.HasValue)
|
||||
{
|
||||
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}行真实除权日不应早于股权登记日");
|
||||
}
|
||||
dividendInfos.Add(info);
|
||||
}
|
||||
if (!AddDividendInfos(dividendInfos, out var errMsg))
|
||||
@@ -900,6 +1016,17 @@ 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 是日期语义,导入/接口可能带时分秒;统一只保留自然日。
|
||||
// 为空时不覆盖数据库已有值,避免旧记录在不完整导入中丢失真实生效日。
|
||||
target.EffectiveDate = source.EffectiveDate.Value.Date;
|
||||
}
|
||||
}
|
||||
|
||||
public bool AddDividendInfos(IEnumerable<ex_dividend_info> infos, out string errMsg)
|
||||
@@ -935,10 +1062,19 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
errMsg = "股权登记日信息不存在";
|
||||
return false;
|
||||
}
|
||||
if (item.Split.HasValue && item.Split.Value <= 0m)
|
||||
{
|
||||
errMsg = "拆/合股倍数必须大于0";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存前统一截断时间部分,确保 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)
|
||||
@@ -954,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);
|
||||
}
|
||||
|
||||
// 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录
|
||||
// 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。
|
||||
@@ -1082,6 +1224,43 @@ 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;
|
||||
}
|
||||
|
||||
// EffectiveDate 已存在时,当前有效 EOD 是唯一执行状态来源。
|
||||
// 回退会清理生效日及之后的 EOD,但不会删除 eodStatus 或不可篡改的
|
||||
// 公司行为审计事件;此处不能继续落入旧的登记日 eodStatus 判断,
|
||||
// 否则交易已回退仍会被错误判定为“已执行”而无法修改。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var eodStatus = DbContext.eodStatus.Where(O => O.ValueDate == info.ExDividendDate && O.OptDate > info.OptDate).Any();
|
||||
if (eodStatus)
|
||||
{
|
||||
|
||||
@@ -263,6 +263,10 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
td.OptName = UserName;
|
||||
td.OptDate = OptDate;
|
||||
int passStatus = new ApprovalProcessService(OptUser).CheckTradeApprovalStep(td, OptUser.UserId);
|
||||
if (passStatus != 0 && passStatus != -3)
|
||||
{
|
||||
new TradeApprovalOAService(this).EnsureForCurrentNode(td);
|
||||
}
|
||||
if (passStatus == 0)
|
||||
{
|
||||
td.TradeStatus = ConsTrade.确认成交;
|
||||
|
||||
@@ -56,6 +56,23 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
throw new ServiceException("该交易状态已变更,不能被审批,请先刷新页面");
|
||||
}
|
||||
|
||||
if (!req.isFromOa && (req.status == "pass" || req.status == "reject"))
|
||||
{
|
||||
var currentNode = TradeProcessByCategory(td)
|
||||
.Where(x => x.order == td.ProcessOrderId && (x.node == 0 || x.node == td.ProcessOrderBranch))
|
||||
.OrderByDescending(x => x.node == td.ProcessOrderBranch)
|
||||
.FirstOrDefault();
|
||||
if (currentNode?.isOaApproval == true)
|
||||
{
|
||||
var oa = DbContext.tradeApprovalOaResult.FirstOrDefault(x => x.trade_id == td.id
|
||||
&& x.approval_process_id == currentNode.id
|
||||
&& x.is_valid
|
||||
&& (x.status == "提交成功" || x.status == "同步中" || x.status == "归档中"));
|
||||
if (oa != null && !new TradeApprovalOAService(this).ArchiveForLocalAction(oa, out var errorMessage))
|
||||
throw new ServiceException(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.status == "pass")
|
||||
{
|
||||
return TradePass(req, td);
|
||||
@@ -179,6 +196,13 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
return result;
|
||||
}
|
||||
|
||||
// 关联 OA 的节点必须先成功创建 OA 流程,才允许本地审批推进到该节点。
|
||||
if (nextOrder.isOaApproval && td.TradeType == "收益互换")
|
||||
{
|
||||
// OA 创建是节点通过后的外部动作;明确失败只记录在 OA 结果表,不能回滚本地节点通过。
|
||||
new TradeApprovalOAService(this).CreateForNode(td, nextOrder);
|
||||
}
|
||||
|
||||
//-----------------------------------------------
|
||||
// 继续审批流转
|
||||
//-----------------------------------------------
|
||||
@@ -788,6 +812,7 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
public bool notNeedOperationHistory;
|
||||
public bool isPartialExercise;
|
||||
public bool isExpire = false;
|
||||
public bool isFromOa;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var td in revokeTrades)
|
||||
{
|
||||
ArchiveActiveOaOrThrow(td);
|
||||
}
|
||||
|
||||
var tradeids = revokeTrades.Select(n => n.id);
|
||||
var revokeTrade_cashs = DbContext.trade_cash.Where(c => c.ValidState == "InValid" && !c.IsDeleted && tradeids.Contains(c.TradeId)).ToList();
|
||||
revokeTrade_cashs.ForEach(x =>
|
||||
@@ -94,6 +99,7 @@
|
||||
var Withdraws = DbContext.trade.Where(t => tradeIds.Contains(t.id)).ToList();
|
||||
foreach (var td in Withdraws)
|
||||
{
|
||||
ArchiveActiveOaOrThrow(td);
|
||||
var changesTradeStatus = DbContext.TradeAuditLog.Where(x => (x.OptType == "确认交易" || x.OptType == "交易特批-确认交易") && x.TradeId == td.id).OrderByDescending(x => x.id)?.FirstOrDefault()?.Changes;
|
||||
td.TradeStatus = changesTradeStatus == ConsTrade.新增待确认 ? ConsTrade.新增待确认 : ConsTrade.修改待确认;
|
||||
td.CheckStatus = null;
|
||||
@@ -103,6 +109,12 @@
|
||||
}
|
||||
DbContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void ArchiveActiveOaOrThrow(trade td)
|
||||
{
|
||||
if (!new TradeApprovalOAService(this).ArchiveActiveForTrade(td, out var errorMessage))
|
||||
throw new ServiceException(errorMessage);
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取包含黑箱结构的子交易集合
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user