feat(bond): 支持股票和基金现金分红纳入债券支付计算 - 收紧过度开发行为

- 实现股票/基金现金分红数据从 ex_dividend_info 同步到 BondPayment
- 新增公司行为去重机制,避免镜像任务完成后重复计息
- 统一现金分红存储口径为"每 10 份派现金额",保持与同步任务一致性
- 修改 CalcPayment 方法,股票/基金分红需除以 10 转换实际现金金额
- 添加单元测试验证债券票息和股票/基金分红的不同计算方式
- 更新文档注释说明"每 10 份派现金额"存储规范
- 修复公司行为生效日处理逻辑,确保正确应用除权系数
- 扩展测试覆盖股票类证券的公司行为处理场景
This commit is contained in:
张名锐
2026-08-20 13:30:36 +08:00
parent aa3548e77f
commit b2f4e16782
13 changed files with 384 additions and 368 deletions
@@ -0,0 +1,51 @@
using YLErp.DBModels;
namespace YLErp.Modules.EodModule
{
[TestClass]
public class BondPaymentServiceCalculationTest
{
private static BondPaymentService CreateService()
{
return new BondPaymentService(
new OptUserInfo(0, nameof(BondPaymentServiceCalculationTest), OptUserFrom.UnitTest));
}
[TestMethod]
public void CalcPayment_BondCoupon_KeepsPerHundredScale()
{
var payments = new List<BondPayment>
{
new BondPayment { payment_interest = 1m }
};
// 债券票息 1 表示每 100 元面值付 1 元:1 * 1000 / 100 = 10。
var actual = CreateService().CalcPayment(
payments,
qty: 1000m,
longRatio: 1m,
payDirection: 1m);
Assert.AreEqual(10m, actual);
}
[TestMethod]
public void CalcPayment_StockOrFundDividend_DoesNotApplyBondScale()
{
var payments = new List<BondPayment>
{
new BondPayment { payment_interest = 10m }
};
// GiveCashAmount=10(每 10 份派 10)时,payment_interest 直接存 10
// 持仓 1000 份的现金分红 = 10 * 1000 / 10 = 1000,不能再套债券报价的 /100 换算。
var actual = CreateService().CalcPayment(
payments,
qty: 1000m,
longRatio: 1m,
payDirection: 1m,
useBondPriceScale: false);
Assert.AreEqual(1000m, actual);
}
}
}
@@ -113,7 +113,7 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
public void OperationHistory_FiltersPendingCorporateActionOnly()
public void OperationHistory_PreservesPendingCorporateActionForAudit()
{
var info = CreateAction(80, ConsGlobal.InstrumentType.Stock);
var pendingData = SwapEodPositionService.BuildCorporateActionEventData(
@@ -133,12 +133,12 @@ namespace YLErp.Modules.SwapModule
new swap_event { id = 3, EventType = (int)SwapEventTypeEnum., EventData = "{}" }
};
var visible = SwapEventService.FilterOperationHistory(events);
Assert.AreEqual(2, visible.Count);
CollectionAssert.DoesNotContain(visible.Select(x => x.id).ToList(), 1L);
CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 2L);
CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 3L);
// 操作历史不再隐藏登记日待生效事件;Applied=false 是事件状态,不是展示过滤条件。
Assert.AreEqual(3, events.Count);
Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[0], out var pendingSnapshot));
Assert.IsFalse(pendingSnapshot.Applied);
Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[1], out var appliedSnapshot));
Assert.IsTrue(appliedSnapshot.Applied);
}
[TestMethod]
@@ -158,7 +158,7 @@ namespace YLErp.Modules.SwapModule
var info = CreateAction(81, ConsGlobal.InstrumentType.Stock);
info.GiveShareAmount = 10m;
var applied = SwapEodPositionService.ApplyFundCorporateActionToPosition(
var applied = SwapEodPositionService.ApplyCorporateActionToPosition(
position,
info,
100m,
@@ -77,6 +77,38 @@ namespace YLErp.Modules.SwapModule
"未恢复基线时不得擅自改写前端请求,沿用既有当日实时流程");
}
[TestMethod]
public void FCA_UW_008_股票TRS平仓恢复有效Eod基线()
{
var realtime = CreateRealtimeFundPosition();
realtime.UnderlyingCode = "STOCK.TEST";
realtime.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock;
realtime.PosiQuantity = 1000m;
realtime.PosiGrossPrice = 100m;
realtime.PosiNetPrice = 100m;
realtime.PosiNetFeePrice = 100m;
realtime.PosiNetNoFeePrice = 100m;
realtime.PosiNotionalValue = 100000m;
var eod = CreateEod(ExDate, 2000m, 50m);
eod.UnderlyingCode = "STOCK.TEST";
eod.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock;
var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false);
var unwindData = CreateFullCloseUnwindData();
unwindData.ValueDate = ExDate;
unwindData.UnwindDate = ExDate.AddDays(1);
Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate));
Assert.AreEqual(2000m, realtime.PosiQuantity,
"Stock TRS 生效日盘中平仓应使用有效 EOD 数量,不能继续使用除权前实时数量");
Assert.AreEqual(50m, realtime.PosiGrossPrice,
"Stock TRS 生效日盘中平仓应使用有效 EOD 价格");
Assert.AreEqual(2000m, unwindData.PositionQty);
Assert.AreEqual(2000m, unwindData.CloseQty);
Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice);
}
[TestMethod]
public void FCA_UW_005_生效日盘中恢复前一Eod后再套除权()
{
@@ -14,7 +14,7 @@ namespace YLErp.Modules.SwapModule
var position = CreateFundPosition();
var info = CreateCorporateAction(split: 10m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(1000m, position.PosiQuantity);
@@ -27,7 +27,7 @@ namespace YLErp.Modules.SwapModule
var position = CreateFundPosition();
var info = CreateCorporateAction(split: 0.1m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(10m, position.PosiQuantity);
@@ -40,7 +40,7 @@ namespace YLErp.Modules.SwapModule
var position = CreateFundPosition();
var info = CreateCorporateAction(giveShare: 10m, split: null);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(200m, position.PosiQuantity);
@@ -54,7 +54,7 @@ namespace YLErp.Modules.SwapModule
var info = CreateCorporateAction(giveShare: 5m, split: 2m);
// (1 + 5 / 10) * 2 = 3100 份/100 元变为 300 份/约 33.333333333 元。
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(300m, position.PosiQuantity);
@@ -67,7 +67,7 @@ namespace YLErp.Modules.SwapModule
var position = CreateFundPosition();
var info = CreateCorporateAction(cash: 10m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(100m, position.PosiQuantity);
@@ -82,7 +82,7 @@ namespace YLErp.Modules.SwapModule
rationedSharesAmount: 1m,
rationedSharesPrice: 50m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition(
position, info, 100m, 0m));
// Excel L-NL=(100*10+1*50)/(10+1)=95.4545...M=100/L
@@ -97,7 +97,7 @@ namespace YLErp.Modules.SwapModule
var info = CreateCorporateAction(split: 0m);
Assert.ThrowsException<ArgumentOutOfRangeException>(() =>
SwapEodPositionService.ApplyFundCorporateActionToPosition(
SwapEodPositionService.ApplyCorporateActionToPosition(
CreateFundPosition(), info, 100m, 0m));
}
@@ -107,7 +107,7 @@ namespace YLErp.Modules.SwapModule
var info = CreateCorporateAction(split: -1m);
Assert.ThrowsException<ArgumentOutOfRangeException>(() =>
SwapEodPositionService.ApplyFundCorporateActionToPosition(
SwapEodPositionService.ApplyCorporateActionToPosition(
CreateFundPosition(), info, 100m, 0m));
}
@@ -97,19 +97,10 @@ namespace YLErp.Modules.SwapModule
public void ExecuteFundCorporateActions(
IReadOnlyCollection<eod_swap_position> positions,
IReadOnlyCollection<eod_swap_position> previousEodPositions,
IReadOnlyCollection<swap_flow_event> flowEvents,
IReadOnlyCollection<ex_dividend_info> dividendInfos)
{
ApplyFundCorporateActions(
ApplyCorporateActions(
positions,
previousEodPositions,
flowEvents,
dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase),
SettleDate);
ApplyFundCashDividends(
positions,
previousEodPositions,
dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase),
SettleDate);
}
@@ -315,8 +306,6 @@ namespace YLErp.Modules.SwapModule
service.ExecuteFundCorporateActions(
new[] { actual },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
Assert.AreEqual(2000m, actual.PosiQuantity);
@@ -381,25 +370,24 @@ namespace YLErp.Modules.SwapModule
new List<swap_flow_event>(),
price: 100m);
service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m));
var todayEod = previousEod.Clone();
todayEod.ValueDate = SettleDate;
todayEod.UnderlyingPrice = 100m;
// 生产重收盘每次都会从上一日 EOD clone 出新的当日基线,再应用一次公司行为;
// 底层 ApplyCorporateActions 只负责处理调用方提供的未调整基线,不再承担恢复旧基线的测试兼容职责。
var firstRunEod = previousEod.Clone();
firstRunEod.ValueDate = SettleDate;
firstRunEod.UnderlyingPrice = 100m;
service.ExecuteFundCorporateActions(new[] { firstRunEod }, service.ExDividendInfos);
service.ExecuteFundCorporateActions(
new[] { todayEod },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
service.ExecuteFundCorporateActions(
new[] { todayEod },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
var rerunEod = previousEod.Clone();
rerunEod.ValueDate = SettleDate;
rerunEod.UnderlyingPrice = 100m;
service.ExecuteFundCorporateActions(new[] { rerunEod }, service.ExDividendInfos);
Assert.AreEqual(2000m, todayEod.PosiQuantity);
Assert.AreEqual(1000m, todayEod.TdChangedQty);
Assert.AreEqual(50m, todayEod.PosiGrossPrice);
Assert.AreEqual(100000m, todayEod.PosiNotionalValue);
Assert.AreEqual(2000m, firstRunEod.PosiQuantity);
Assert.AreEqual(1000m, firstRunEod.TdChangedQty);
Assert.AreEqual(50m, firstRunEod.PosiGrossPrice);
Assert.AreEqual(100000m, firstRunEod.PosiNotionalValue);
Assert.AreEqual(firstRunEod.PosiQuantity, rerunEod.PosiQuantity);
Assert.AreEqual(firstRunEod.PosiGrossPrice, rerunEod.PosiGrossPrice);
}
[TestMethod]
@@ -427,8 +415,6 @@ namespace YLErp.Modules.SwapModule
service.ExecuteFundCorporateActions(
new[] { actual },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
Assert.AreEqual(1000m, actual.PosiQuantity);
@@ -559,8 +545,6 @@ namespace YLErp.Modules.SwapModule
service.ExecuteFundCorporateActions(
new[] { actual },
new[] { previousEod },
Array.Empty<swap_flow_event>(),
service.ExDividendInfos);
Assert.AreEqual(10m, actual.PosiQuantity,
@@ -65,8 +65,6 @@ namespace YLErp.Modules.SwapModule
decimal fallbackPrice)
=> fallbackPrice;
protected override decimal GetFundDividendTaxRate() => 0m;
public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate)
=> TryRestoreEffectiveFundPosition(unwindData, valueDate);
@@ -84,15 +84,6 @@ namespace YLErp.Modules.SwapModule
return 1.0; // 本币,汇率=1
}
protected override List<ex_dividend_info> FindExDividendInfos(DateTime settleDate)
{
return ExDividendInfos
.Where(x => x.ValidStatus
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value.Date == settleDate.Date)
.ToList();
}
protected override List<ex_dividend_info> FindCorporateActionInfos(DateTime settleDate)
{
return ExDividendInfos
@@ -102,15 +93,6 @@ namespace YLErp.Modules.SwapModule
.ToList();
}
protected override List<ex_dividend_info> FindRegistrationExDividendInfos(DateTime settleDate)
{
return ExDividendInfos
.Where(x => x.ValidStatus
&& x.ExDividendDate.HasValue
&& x.ExDividendDate.Value.Date == settleDate.Date)
.ToList();
}
protected override List<swap_event> FindCorporateActionEvents(int swapTradeId)
{
return CorporateActionEvents
@@ -124,11 +106,6 @@ namespace YLErp.Modules.SwapModule
decimal fallbackPrice)
=> fallbackPrice;
protected override decimal GetDividendTaxRate()
{
return 0m;
}
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
{
ClientCashCalls.Add((amount, action));
@@ -1,4 +1,4 @@
using BaseOUDAL;
using BaseOUDAL;
using DocumentFormat.OpenXml.Bibliography;
using ExcelDataReader.Log;
using YLErp.DBModels;
@@ -99,6 +99,52 @@ namespace YLErp.Modules.EodModule
public List<BondPayment> GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate)
{
var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode && x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList();
// 让 Copy/Update EOD 始终只依赖 BondPaymentService,而不必在收盘链路直接累加 ex_dividend_info。
// 口径约定:bond_payment_info.payment_interest 对 Stock/Fund 统一按“每 10 份派现金额”存储,
// 即直接存 ex_dividend_info.GiveCashAmount 原值,不做 /10
// 最后的 /10 由 CalcPayment 非债券分支完成。
// 去重:镜像任务完成后的正式记录带 jsid = -dividend.id;此处先按该负号标记,
// 或用“同一实际付息日 + 同一派现金额”兜底去重,避免镜像完成后重复计息。
var corporatePayments = (from dividend in DbContext.ex_dividend_info.AsNoTracking()
join underlying in DbContext.underlying_manager.AsNoTracking()
on dividend.UnderlyingCode equals underlying.UnderlyingCode
where dividend.ValidStatus
&& dividend.EffectiveDate.HasValue
&& dividend.EffectiveDate.Value > startDate
&& dividend.EffectiveDate.Value <= endDate
&& dividend.GiveCashAmount != 0
&& dividend.UnderlyingCode == underlyingCode
&& (underlying.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Stock
|| underlying.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund)
select dividend).ToList();
foreach (var dividend in corporatePayments)
{
// 按“每 10 份派现金额”口径,直接存 GiveCashAmount 原值,与同步任务/CalcPayment 保持一致。
var paymentInterest = dividend.GiveCashAmount;
var hasMirroredPayment = result.Any(payment =>
payment.jsid == -dividend.id
|| (payment.payment_date.HasValue
&& payment.payment_date.Value.Date == dividend.EffectiveDate.Value.Date
&& payment.payment_interest == paymentInterest));
if (hasMirroredPayment)
{
continue;
}
result.Add(new BondPayment
{
underlyingCode = dividend.UnderlyingCode,
payment_date_pl = dividend.EffectiveDate,
payment_date = dividend.EffectiveDate,
payment_interest = paymentInterest,
paying_price = paymentInterest,
channel_source = ExDividendDataSources.Manual,
jsid = -dividend.id,
create_time = dividend.OptDate,
update_time = dividend.OptDate
});
}
return result;
}
@@ -142,8 +188,8 @@ namespace YLErp.Modules.EodModule
/// <param name="useBondPriceScale">
/// 是否按债券报价的百分比口径换算。债券的 payment_interest 是每 100 元面值的票息,
/// 需要继续通过 BondPriceConverter 转成入库金额;Fund/Stock 的公司行为现金分红
/// 在 bond_payment_info 中按每 10 份存储,payment_interest * qty 已经是实际现金,
/// 不能再做一次 /100。默认 true 是为了保持所有历史债券调用方的原有口径。
/// 在 bond_payment_info 中按每 10 份派现金额”存储,payment_interest * qty / 10 才是实际现金,
/// 不能再套债券的 /100。默认 true 是为了保持所有历史债券调用方的原有口径。
/// </param>
/// <returns></returns>
public decimal CalcPayment(
@@ -156,11 +202,12 @@ namespace YLErp.Modules.EodModule
var interest = payments.Sum(s => s.payment_interest ?? 0);
var paymentAmount = interest * qty;
// 债券:interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额。
// Fund/Stock 公司行为:interest 已由【同步任务】写成 GiveCashAmount/10
// ×qty 就是“每 10 份派现额 × 持仓份”,必须保留原金额,不能套债券的 /100。
// Fund/Stock 公司行为:payment_interest 存的是“每 10 份派现金额”(GiveCashAmount 原值)
// interest × qty 得到“每 10 份派现额 × 持仓份”,需再 ÷10 才是实际现金;
// 既不能套债券的 /100,也不能直接返回 paymentAmount(那样会放大 10 倍)。
var actualAmount = useBondPriceScale
? BondPriceConverter.ToStorage(paymentAmount)
: paymentAmount;
: paymentAmount / 10;
return actualAmount * longRatio * payDirection;
}
}
+32 -35
View File
@@ -285,7 +285,7 @@ namespace YLErp.Modules.SwapModule
return query.FirstOrDefault();
}
/// <summary>查询 valueDate 当日已经生效的最近有效 Fund EOD。</summary>
/// <summary>查询 valueDate 当日已经生效的最近有效 Stock/Fund EOD。</summary>
protected virtual eod_swap_position FindLatestFundEodPosition(
int tradeId,
long positionId,
@@ -296,7 +296,7 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 查询 valueDate 当天真正生效的 Fund 公司行为。
/// 查询 valueDate 当天真正生效的 Stock/Fund 公司行为。
/// ExDividendDate 只是登记日,盘中基线不能按登记日提前切换;只有
/// EffectiveDate == valueDate 时才把上一 EOD 的 Q/P 转成当日 BOD 的除权后 Q/P。
/// </summary>
@@ -311,7 +311,7 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。
/// Stock/Fund 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。
/// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。
/// </summary>
protected virtual decimal GetFundCorporateActionClosePrice(
@@ -328,10 +328,6 @@ namespace YLErp.Modules.SwapModule
return Convert.ToDecimal(closePrice);
}
/// <summary>读取 Fund 现金分红税率;单元测试可固定为 0,避免依赖系统日期配置。</summary>
protected virtual decimal GetFundDividendTaxRate()
=> new DividendService(this).GetDividendTaxRateDecimal();
/// <summary>
/// 判断最新 EOD 之后是否已有同一浮动腿的完成流水。若有,说明当日实时持仓已发生部分平仓/互换,
/// 不能再把较早 EOD 的数量覆盖回来,否则会抹掉当日成交结果。
@@ -351,22 +347,22 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 恢复实时 Fund 浮动腿到截至指定日有效的 EOD 基线。
/// 恢复实时 Stock/Fund 浮动腿到截至指定日有效的 EOD 基线。
/// 这是唯一允许把 EOD 公司行为结果带入盘中平仓的入口:10 送 10 后 EOD 是 2000 份/50
/// 时,下一日直接使用 2000/50,不再把前端可能传入的 1000/100 或已除权价格重复套系数。
/// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Fund、无 EOD
/// 和固定/利息腿均返回 false,沿用原逻辑。
/// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Stock/Fund、无
/// EOD 和固定/利息腿均返回 false,沿用原逻辑。
/// </summary>
protected virtual bool TryRestoreEffectiveFundPosition(
swap_position position,
DateTime valueDate)
{
// 只对收取方向的 Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。
// 只对收取方向的 Stock/Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。
// 无历史 EOD 或最新 EOD 后已有完成流水时返回 false,由调用方保持实时持仓原值,
// 不伪造一份快照,也不把较早的 2000 份/50 覆盖掉当日已经部分平仓后的实时数量。
if (position == null
|| position.PosiDirection <= 0
|| position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund)
|| !SwapEodPositionService.IsCorporateActionInstrument(position.UnderlyingInstrumentType))
{
return false;
}
@@ -405,15 +401,16 @@ namespace YLErp.Modules.SwapModule
if (closePrice <= 0)
{
throw new ServiceException(
$"Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权");
$"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权");
}
var dividendTaxRate = GetFundDividendTaxRate();
SwapEodPositionService.ApplyFundCorporateActionToPosition(
// TODO: 现金模式不使用税率参与 Q/P 除权;价格调整模式启用后再根据需求 考虑接入该配置。
// var dividendTaxRate = GetFundDividendTaxRate();
SwapEodPositionService.ApplyCorporateActionToPosition(
position,
corporateAction,
closePrice,
dividendTaxRate);
0m);
}
return true;
@@ -445,7 +442,7 @@ namespace YLErp.Modules.SwapModule
if (requestedQty < 0m || (!fullClose && requestedQty > effectiveQty))
{
throw new ServiceException(
$"Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}");
$"Stock/Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}");
}
var closeQty = fullClose ? effectiveQty : requestedQty;
@@ -550,10 +547,10 @@ namespace YLErp.Modules.SwapModule
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault();
// Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50
// Stock/Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50
// 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。
var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 恢复失败表示非 Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值,
var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 恢复失败表示非 Stock/Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值,
// 继续原有盘中流程,避免用不完整快照制造数量/价格。
var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps);
var hasProcess = HasTradeProcess();
@@ -590,8 +587,8 @@ namespace YLErp.Modules.SwapModule
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
// 现金分红会调整 EOD 期初价但不改数量,因此持仓名义本金可能从 100000 变为 99000。
// 只有 Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。
unwindData.PosiNotionalValue = restoredFundBaseline
// 只有 Stock/Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。
unwindData.PosiNotionalValue = restoredCorporateActionBaseline
? position.PosiNotionalValue
: Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
@@ -783,8 +780,8 @@ namespace YLErp.Modules.SwapModule
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
// 收益结算与手工平仓共用 Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。
var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 收益结算与手工平仓共用 Stock/Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。
var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate);
// 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD
// 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。
//var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期
@@ -823,7 +820,7 @@ namespace YLErp.Modules.SwapModule
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = restoredFundBaseline
unwindData.PosiNotionalValue = restoredCorporateActionBaseline
? position.PosiNotionalValue
: Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
@@ -1829,13 +1826,13 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
// 提交时再次从有效 EOD/实时腿复核基线,不能只相信前端缓存的数量和价格。
var restoredFundBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate);
// 这是直接提交路径的最后一道复核。若返回 false(非 Fund、无快照、或 EOD 后已有完成流水),
// 提交时再次从有效 EOD/实时腿复核 Stock/Fund 基线,不能只相信前端缓存的数量和价格。
var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate);
// 这是直接提交路径的最后一道复核。若返回 false(非 Stock/Fund、无快照、或 EOD 后已有完成流水),
// 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。
if (restoredFundBaseline)
if (restoredCorporateActionBaseline)
{
// 正式提交必须让交易级余额与同一 Fund EOD 基线一致,再执行原有扣减。
// 正式提交必须让交易级余额与同一 Stock/Fund EOD 基线一致,再执行原有扣减。
// 例:派现后有效名义本金为 99000,平掉一半 49500 后应剩 49500
// 若仍从 trade 旧值 100000 扣减,会错误留下 50500。
td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue);
@@ -1902,7 +1899,7 @@ namespace YLErp.Modules.SwapModule
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
td.trade_extend = tradeExtend;
var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault();
// 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Fund EOD。
// 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Stock/Fund EOD。
// 因此它不具备手工 SwapUnwind 的 EOD 复核保护,生产上需确保自动流水已在正确的 EOD 基线之后生成。
var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType)
? ConsGlobal.PriceRound
@@ -2007,7 +2004,7 @@ namespace YLErp.Modules.SwapModule
int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode);
// 债券付息按每百元票息存储,继续走 BondPriceConverterStock/Fund 的公司行为
// 现金分红按每 10 份金额存储,实际现金就是 payment_interest * qty,不能 /100。
// 现金分红按每 10 份金额存储,实际现金 = payment_interest * qty / 10,不能 /100。
// 标的资料缺失时保持旧债券口径,避免未知标的的历史平仓金额被放大。
var useBondPriceScale = um == null
|| !SwapEodPositionService.IsCorporateActionInstrument(um.UnderlyingInstrumentType);
@@ -2326,7 +2323,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
// 正常页面先由 InitIncome 读取最近有效 Fund EOD;本提交方法本身不再重读快照,
// 正常页面先由 InitIncome 读取最近有效 Stock/Fund EOD;本提交方法本身不再重读快照,
// 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权
// 不会在这里回写,属于审批链路的残余风险。
ValidateIncomeValueDate(unwindData, td);
@@ -2365,7 +2362,7 @@ namespace YLErp.Modules.SwapModule
{
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
// 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Fund EOD 重建数量和价格。
// 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Stock/Fund EOD 重建数量和价格。
// 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线;
// 直接提交路径的 EOD 复核不覆盖此审批路径。
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
@@ -2462,7 +2459,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeEventUnwindDate(unwindData);
// 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Fund EOD 复核。
// 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Stock/Fund EOD 复核。
// 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。
if (eventType == (int)SwapEventTypeEnum.)
{
@@ -78,7 +78,7 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 从已经确认的 Fund EOD 快照恢复实时浮动腿的有效基线。
/// 从已经确认的 Stock/Fund EOD 快照恢复实时浮动腿的有效基线。
/// 该方法只复制 EOD 已落库的数量、价格、名义本金及累计分红/待结费用,不再次计算
/// 公司行动系数,因此是幂等的。例:原 1000 份、期初价 100,10 送 10 后 EOD 为
/// 2000 份、50;下一日盘中直接恢复 2000/50,不能再变成 4000/25。
@@ -112,7 +112,7 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 查询 valueDate 之前最近一份有效 Fund EOD 快照,作为盘中操作的日初基线。
/// 查询 valueDate 之前最近一份有效 Stock/Fund EOD 快照,作为盘中操作的日初基线。
/// 必须严格使用 &lt; valueDate:试算日当天的 EOD 可能尚未完成,或是重收盘留下的待重建数据,
/// 不能反向覆盖盘中实时持仓。例:D 日 10 送 10 后 EOD 为 2000 份/50D+1 盘中读取 D
/// D 日盘中只读取 D-1,不会误把 D 日半成品当成已生效基线。Invalid 明细始终排除。
@@ -446,25 +446,7 @@ namespace YLErp.Modules.SwapModule
.ToList();
}
/// <summary>
/// 查找结算日有效的公司行为记录;测试可替换为内存数据。
/// settleDate 必须是收盘作业使用的日期边界(通常为 00:00:00),这里沿用完整
/// DateTime 相等匹配;历史数据若带时分秒或为空,不会被静默归入当天,需在作业前
/// 通过数据预检查处理,而不是让收盘在错误基线上继续计算。
/// </summary>
protected virtual List<ex_dividend_info> FindExDividendInfos(DateTime settleDate)
{
return DbContext.ex_dividend_info
.Where(x => x.ValidStatus
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value == settleDate.Date)
.ToList();
}
/// <summary>
/// 查询登记日或真实生效日命中的公司行为。保留 FindExDividendInfos 这个
/// 可替换入口,测试和历史调用方可以继续注入内存数据。
/// </summary>
/// <summary>查询登记日或真实生效日命中的有效公司行为。</summary>
protected virtual List<ex_dividend_info> FindCorporateActionInfos(DateTime settleDate)
{
return DbContext.ex_dividend_info
@@ -490,19 +472,6 @@ namespace YLErp.Modules.SwapModule
UpdateDbOption(swapEvent);
}
/// <summary>
/// 查找登记日公司行为。登记日只创建待生效审计事件,不参与当日持仓系数计算;
/// EffectiveDate 到达后才由 FindExDividendInfos 命中并改变 Stock/Fund 基线。
/// </summary>
protected virtual List<ex_dividend_info> FindRegistrationExDividendInfos(DateTime settleDate)
{
return FindCorporateActionInfos(settleDate)
.Where(x => x.ValidStatus
&& x.ExDividendDate.HasValue
&& x.ExDividendDate.Value.Date == settleDate.Date)
.ToList();
}
/// <summary>
/// 获取公司行为公式使用的收盘价。
/// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日
@@ -523,16 +492,6 @@ namespace YLErp.Modules.SwapModule
return Convert.ToDecimal(closePrice);
}
/// <summary>
/// 公司行为现金分红使用的系统税率,小数形式。
/// 系统配置按百分数存储(例如 13 表示 13%),公司行为公式需要 0.13;送股本身
/// 不受该税率影响;此处只负责读取并换算,不在异常时擅自默认为 0。
/// </summary>
protected virtual decimal GetDividendTaxRate()
{
return new DividendService(this).GetDividendTaxRateDecimal();
}
public static bool IsCorporateActionInstrument(string instrumentType)
{
// TRS 公司行为本期只覆盖 Stock/Fund。TBonds 等类型继续走原债券付息链路,
@@ -700,7 +659,7 @@ namespace YLErp.Modules.SwapModule
flowEvents);
// 现金分红不在登记日直接读取 ex_dividend_info 累加。
// 同步任务会把 GiveCashAmount/10 写入 bond_payment_infoCopy/Update EOD 在
// 同步任务会把 GiveCashAmount(每 10 份派现金额)写入 bond_payment_infoCopy/Update EOD 在
// EffectiveDate 通过 CalcBondPayment 命中该行并生成 TdPosiDividend。
// 这样登记日快照不提前变化,也不会与债券付息/平仓链路重复计算。
RecordCorporateActionEvents(
@@ -710,6 +669,9 @@ namespace YLErp.Modules.SwapModule
registrationInfos,
exDividendInfos,
settleDate);
// 登记日 EOD 仍保存除权前快照,但下一交易日开盘读取的实时浮动腿需要
// 先切换到生效后的 Q/P。该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大。
UpdateRealtimeCorporateActionPositions(td, curEodPosis, registrationInfos, exDividendInfos, settleDate);
var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue);
var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue);
var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice);
@@ -741,7 +703,7 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Fund 公司行为。
/// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Stock/Fund 公司行为。
/// 原始上一 EOD 只读保留在数据库中,确保登记日 EOD 报表仍展示除权前 Q/P。
/// 例如 1000 份/100 元、10 送 10 的记录在 8 月 14 日 EOD 仍是 1000/100
/// 8 月 17 日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。
@@ -765,10 +727,8 @@ namespace YLErp.Modules.SwapModule
.Select(x => x.Clone())
.ToList();
// 应用公司行为
ApplyFundCorporateActions(
ApplyCorporateActions(
openingPositions,
Array.Empty<eod_swap_position>(),
Array.Empty<swap_flow_event>(),
exDividendByCode,
settleDate);
return openingPositions;
@@ -791,10 +751,8 @@ namespace YLErp.Modules.SwapModule
/// EffectiveDate 再生成开盘基线。
/// </para>
/// </summary>
protected void ApplyFundCorporateActions(
protected void ApplyCorporateActions(
IEnumerable<eod_swap_position> positions,
IReadOnlyCollection<eod_swap_position> previousEodPositions,
IReadOnlyCollection<swap_flow_event> flowEvents,
IReadOnlyDictionary<string, ex_dividend_info> exDividendByCode,
DateTime settleDate)
{
@@ -803,8 +761,9 @@ namespace YLErp.Modules.SwapModule
return;
}
// 获取系统税率
var dividendTaxRate = GetDividendTaxRate();
// TODO: 现金分红税率接入后,仅价格调整模式需要读取税率;TRS 现金模式下不参与除权系数。
// var dividendTaxRate = GetDividendTaxRate();
var dividendTaxRate = 0m;
foreach (var position in positions)
{
if (position.PosiDirection <= 0
@@ -824,41 +783,7 @@ namespace YLErp.Modules.SwapModule
if (corporateActionClosePrice <= 0)
{
throw new InvalidOperationException(
$"Fund 标的【{position.UnderlyingCode}】登记日【{dividendInfo.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权");
}
// 当天已有 EOD 且该腿没有流水时,Copy 分支不会恢复价格字段。
// 先还原前一日基线,避免同一结算日重跑时再次除权。
var hasPositionFlow = flowEvents.Any(x => x.PositionId == position.PositionId);
var previousPosition = previousEodPositions.FirstOrDefault(
x => x.PositionId == position.PositionId);
// 没有流水 且 有前一日持仓时,恢复前一日价格字段
if (!hasPositionFlow && previousPosition != null)
{
position.PosiQuantity = previousPosition.PosiQuantity;
position.PosiGrossPrice = previousPosition.PosiGrossPrice;
position.PosiNetPrice = previousPosition.PosiNetPrice;
position.PosiNetFeePrice = previousPosition.PosiNetFeePrice;
position.PosiNetNoFeePrice = previousPosition.PosiNetNoFeePrice;
}
var denominator = 10m + dividendInfo.GiveShareAmount + dividendInfo.RationedSharesAmount;
if (denominator == 0)
{
throw new InvalidOperationException(
$"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】的除权份额参数导致除数为 0");
}
// 计算除权系数
var factors = DividendService.CalculateCorporateActionFactors(
dividendInfo,
corporateActionClosePrice,
dividendTaxRate,
adjustCashDividendPrice: false);
if (factors.PriceRatio <= 0)
{
throw new InvalidOperationException(
$"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】计算得到无效除权系数");
$"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{dividendInfo.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权");
}
// Excel 公式 口径:PriceRatio 是“登记日收盘价 / 除权参考价”,
@@ -866,37 +791,24 @@ namespace YLErp.Modules.SwapModule
// 配股已经进入 价格参考价,所以即使没有送股,配股也会调整 TRS 数量;
// 现金分红不影响 TRS Stock/Fund 期初价格,现金权益由独立分红字段处理。
var originalQuantity = position.PosiQuantity;
position.PosiQuantity = Math.Round(
originalQuantity * factors.PriceRatio,
12,
MidpointRounding.AwayFromZero);
// 计算公司行为发生后的 Q/P
var adjusted = CalculateCorporateActionValues(
position.PosiQuantity,
position.PosiGrossPrice,
position.PosiNetPrice,
position.PosiNetFeePrice,
position.PosiNetNoFeePrice,
dividendInfo,
corporateActionClosePrice,
dividendTaxRate,
GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode));
position.PosiQuantity = adjusted.Quantity;
position.TdChangedQty = position.PosiQuantity - originalQuantity;
var storagePriceRound = GetStorageDeliveryPriceRound(
position.UnderlyingInstrumentType,
position.UnderlyingCode);
position.PosiGrossPrice = Math.Round(
position.PosiGrossPrice / factors.PriceRatio,
storagePriceRound,
MidpointRounding.AwayFromZero);
position.PosiNetPrice = Math.Round(
position.PosiNetPrice / factors.PriceRatio,
ConsGlobal.PriceRound,
MidpointRounding.AwayFromZero);
if (position.PosiNetFeePrice.HasValue)
{
position.PosiNetFeePrice = Math.Round(
position.PosiNetFeePrice.Value / factors.PriceRatio,
ConsGlobal.PriceRound,
MidpointRounding.AwayFromZero);
}
if (position.PosiNetNoFeePrice.HasValue)
{
position.PosiNetNoFeePrice = Math.Round(
position.PosiNetNoFeePrice.Value / factors.PriceRatio,
ConsGlobal.PriceRound,
MidpointRounding.AwayFromZero);
}
position.PosiGrossPrice = adjusted.GrossPrice;
position.PosiNetPrice = adjusted.NetPrice;
position.PosiNetFeePrice = adjusted.NetFeePrice;
position.PosiNetNoFeePrice = adjusted.NetNoFeePrice;
var shortRatio = DirectionRatio.LongShort(position.PositionType);
var directionRatio = DirectionRatio.ReceivePay(position.PosiDirection);
@@ -998,16 +910,79 @@ namespace YLErp.Modules.SwapModule
}
var closePrice = GetFundCorporateActionClosePrice(info, position.PosiGrossPrice);
ApplyFundCorporateActionToPosition(
ApplyCorporateActionToPosition(
position,
info,
closePrice,
GetDividendTaxRate());
0m);
}
return positions;
}
/// <summary>
/// 同步公司行为后的实时浮动腿。
/// 登记日只更新下一交易日 BOD 使用的实时 Q/P,不改当日已落库的 EOD;
/// 生效日则把已调整的 EOD 复制到实时腿。每次都先从当日 EOD 恢复,保证重跑幂等。
/// </summary>
private void UpdateRealtimeCorporateActionPositions(
trade td,
IReadOnlyCollection<eod_swap_position> currentEodPositions,
IReadOnlyCollection<ex_dividend_info> registrationInfos,
IReadOnlyCollection<ex_dividend_info> effectiveInfos,
DateTime settleDate)
{
if (td == null || currentEodPositions == null || currentEodPositions.Count == 0)
{
return;
}
// 登记日收盘后即切换实时 BOD。EffectiveDate 只用于确认这条记录仍是未来生效的
// 公司行为;无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。
var pendingInfos = (registrationInfos ?? Array.Empty<ex_dividend_info>())
.Where(x => x.EffectiveDate.HasValue && x.EffectiveDate.Value.Date > settleDate.Date)
.ToList();
var appliedInfos = effectiveInfos ?? Array.Empty<ex_dividend_info>();
foreach (var eod in currentEodPositions.Where(x => x != null
&& x.PosiDirection > 0
&& IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType)
&& !string.IsNullOrWhiteSpace(x.UnderlyingCode)))
{
var realtime = DbContext.swap_position.FirstOrDefault(x => x.SwapTradeId == td.id
&& !x.Invalid
&& !x.IsInitial
&& x.PositionId == eod.PositionId);
if (realtime == null)
{
continue;
}
var pending = pendingInfos.FirstOrDefault(x => string.Equals(
x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase));
if (pending != null)
{
// 必须从登记日 EOD 基线生成下一交易日 BOD,而不是在旧实时腿上继续套系数;
// 这样 100000/100 只会变成一次 200000/50,并且不会把初始腿改掉。
// 先将实时腿恢复为登记日 EOD 的旧基线,再只对实时腿应用一次公司行为。
// EOD 仍保持除权前快照;因此 7/10 EOD=100000/100,而 7/13 BOD=200000/50。
var baseline = eod.Clone();
UpdateSwapPositionWithRealTime(baseline);
var closePrice = GetFundCorporateActionClosePrice(pending, baseline.PosiGrossPrice);
ApplyCorporateActionToPosition(realtime, pending, closePrice, 0m);
continue;
}
var applied = appliedInfos.FirstOrDefault(x => string.Equals(
x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase));
if (applied != null)
{
// 生效日 EOD 已经完成 Q/P 调整,实时腿直接同步最终快照,不再二次套系数。
UpdateSwapPositionWithRealTime(eod.Clone());
}
}
}
/// <summary>
/// 写入公司行为生命周期审计事件。
/// 登记日:保存调整前快照并标记 Applied=false
@@ -1212,72 +1187,66 @@ namespace YLErp.Modules.SwapModule
return SwapEventService.BuildCorporateActionEventReason(data);
}
/// <summary>
/// 兼容旧测试/扩展调用的直接现金分红辅助方法。
/// GiveCashAmount 按每 10 份金额计算:1000 份、每 10 份派 10,结果为 1000。
/// 当前生产 SwapPositionCompose 不再调用此方法:公司行为现金分红由同步任务
/// 写入 bond_payment_infoEffectiveDate 收盘通过 CalcBondPayment 进入 EOD
/// 以避免登记日提前入账及与债券付息链路重复。保留方法是为了不破坏已有测试替身
/// 或外部扩展类的编译契约;新增业务代码不得再直接传入 ex_dividend_info。
/// </summary>
protected void ApplyFundCashDividends(
IReadOnlyCollection<eod_swap_position> currentEodPositions,
IReadOnlyCollection<eod_swap_position> previousEodPositions,
IReadOnlyDictionary<string, ex_dividend_info> exDividendByCode,
DateTime settleDate)
/// <summary>公司行为调整后的持仓 Q/P 结果,供 EOD、实时腿和盘中平仓共用。</summary>
private readonly struct CorporateActionValues
{
if (currentEodPositions == null
|| exDividendByCode == null
|| exDividendByCode.Count == 0)
public CorporateActionValues(decimal quantity, decimal grossPrice, decimal netPrice, decimal? netFeePrice, decimal? netNoFeePrice)
{
return;
Quantity = quantity;
GrossPrice = grossPrice;
NetPrice = netPrice;
NetFeePrice = netFeePrice;
NetNoFeePrice = netNoFeePrice;
}
var dividendTaxRate = GetDividendTaxRate();
var previousList = previousEodPositions ?? Array.Empty<eod_swap_position>();
foreach (var current in currentEodPositions)
public decimal Quantity { get; }
public decimal GrossPrice { get; }
public decimal NetPrice { get; }
public decimal? NetFeePrice { get; }
public decimal? NetNoFeePrice { get; }
}
/// <summary>
/// 统一计算公司行为后的 Q/P。EOD、实时腿和盘中平仓只负责提供基线,
/// 不再各自复制数量、毛价和净价的调整公式。
/// </summary>
private static CorporateActionValues CalculateCorporateActionValues(
decimal quantity,
decimal grossPrice,
decimal netPrice,
decimal? netFeePrice,
decimal? netNoFeePrice,
ex_dividend_info dividendInfo,
decimal closePrice,
decimal dividendTaxRate,
int grossPriceRound)
{
var factors = DividendService.CalculateCorporateActionFactors(
dividendInfo,
closePrice,
dividendTaxRate,
adjustCashDividendPrice: false);
if (factors.PriceRatio <= 0)
{
if (current == null
|| current.PosiDirection == 0
|| !IsTrsCorporateActionInstrument(current.UnderlyingInstrumentType)
|| string.IsNullOrWhiteSpace(current.UnderlyingCode)
|| !exDividendByCode.TryGetValue(current.UnderlyingCode, out var dividendInfo)
|| !dividendInfo.ExDividendDate.HasValue
|| dividendInfo.ExDividendDate.Value.Date != settleDate.Date)
{
continue;
}
var previous = previousList.FirstOrDefault(
x => x != null && x.PositionId == current.PositionId);
var entitlementQuantity = previous?.PosiQuantity ?? current.PosiQuantity;
var directionRatio = DirectionRatio.ReceivePay(current.PosiDirection);
var currentDividend = entitlementQuantity > 0m
? entitlementQuantity / 10m
* dividendInfo.GiveCashAmount
* (1m - dividendTaxRate)
* directionRatio
: 0m;
// 当日浮动端分红。公司行为现金分红采用现金模式:不调期初价格,
// 只增加待实现分红,支付日仍由既有 DealDividends/付息链路结算。
current.TdPosiDividend = RoundMoney(currentDividend);
var previousDividendSum = previous?.PosiDividendSum ?? 0m;
// 浮动端平仓盈亏·分红未实现 = 前日待实现 + 当日公司行为分红
// - 当日已实现分红;本次公司行为尚未支付,因此不能写入 RealizedDividend。
current.PosiDividendSum = current.PosiQuantity > 0m
? RoundMoney(previousDividendSum + current.TdPosiDividend - current.TdCloseDividend)
: 0m;
// 现金模式不从 PosiMtmPnL 剥离分红:价格没有被除权,分红只存在于待实现字段。
current.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum(
current.PosiMtmPnL,
current.PosiDividendSum,
current.PosiFeePending));
SetFloatingRealizedPnl(current);
current.SwapPositionValue = RoundMoney(PositionValueCalc.Calc(
current.InterestProfitSum,
current.PosiProfitSum));
throw new InvalidOperationException(
$"标的【{dividendInfo?.UnderlyingCode}】计算得到无效除权系数");
}
var adjustedQuantity = Math.Round(quantity * factors.PriceRatio, 12, MidpointRounding.AwayFromZero);
var adjustedGrossPrice = Math.Round(grossPrice / factors.PriceRatio, grossPriceRound, MidpointRounding.AwayFromZero);
var adjustedNetPrice = Math.Round(netPrice / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
var adjustedNetFeePrice = netFeePrice.HasValue
? Math.Round(netFeePrice.Value / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero)
: (decimal?)null;
var adjustedNetNoFeePrice = netNoFeePrice.HasValue
? Math.Round(netNoFeePrice.Value / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero)
: (decimal?)null;
return new CorporateActionValues(
adjustedQuantity,
adjustedGrossPrice,
adjustedNetPrice,
adjustedNetFeePrice,
adjustedNetNoFeePrice);
}
/// <summary>
@@ -1288,7 +1257,7 @@ namespace YLErp.Modules.SwapModule
/// 现金模式调用公式时使用 adjustCashDividendPrice=false,现金权益只进入分红字段,
/// 不改变 Stock/Fund 的期初价格。
/// </summary>
public static bool ApplyFundCorporateActionToPosition(
public static bool ApplyCorporateActionToPosition(
swap_position position,
ex_dividend_info dividendInfo,
decimal corporateActionClosePrice,
@@ -1303,44 +1272,21 @@ namespace YLErp.Modules.SwapModule
return false;
}
var factors = DividendService.CalculateCorporateActionFactors(
var adjusted = CalculateCorporateActionValues(
position.PosiQuantity,
position.PosiGrossPrice,
position.PosiNetPrice,
position.PosiNetFeePrice,
position.PosiNetNoFeePrice,
dividendInfo,
corporateActionClosePrice,
dividendTaxRate,
adjustCashDividendPrice: false);
if (factors.PriceRatio <= 0)
{
throw new InvalidOperationException(
$"Fund 标的【{position.UnderlyingCode}】计算得到无效除权系数");
}
var originalQuantity = position.PosiQuantity;
position.PosiQuantity = Math.Round(
originalQuantity * factors.PriceRatio,
12,
MidpointRounding.AwayFromZero);
position.PosiGrossPrice = Math.Round(
position.PosiGrossPrice / factors.PriceRatio,
ConsGlobal.SwapDeliveryPriceRound,
MidpointRounding.AwayFromZero);
position.PosiNetPrice = Math.Round(
position.PosiNetPrice / factors.PriceRatio,
ConsGlobal.PriceRound,
MidpointRounding.AwayFromZero);
if (position.PosiNetFeePrice.HasValue)
{
position.PosiNetFeePrice = Math.Round(
position.PosiNetFeePrice.Value / factors.PriceRatio,
ConsGlobal.PriceRound,
MidpointRounding.AwayFromZero);
}
if (position.PosiNetNoFeePrice.HasValue)
{
position.PosiNetNoFeePrice = Math.Round(
position.PosiNetNoFeePrice.Value / factors.PriceRatio,
ConsGlobal.PriceRound,
MidpointRounding.AwayFromZero);
}
ConsGlobal.SwapDeliveryPriceRound);
position.PosiQuantity = adjusted.Quantity;
position.PosiGrossPrice = adjusted.GrossPrice;
position.PosiNetPrice = adjusted.NetPrice;
position.PosiNetFeePrice = adjusted.NetFeePrice;
position.PosiNetNoFeePrice = adjusted.NetNoFeePrice;
position.PosiNotionalValue = Math.Round(
position.PosiGrossPrice * position.PosiQuantity * position.ContractSize,
ConsGlobal.MoneyRound,
@@ -207,8 +207,8 @@ namespace YLErp.Modules.SwapModule
return events;
}
/// <summary>
/// 获取交易操作历史。返回前会过滤掉登记日创建尚未应用(Applied=false
/// 的公司行为事件,避免交易详情在真实调整前展示一条已完成历史
/// 获取交易操作历史。登记日创建尚未到 EffectiveDate 的公司行为事件也保留,
/// 由 EventData.Applied=false 表示“待生效”,保证审计日志完整可追溯
/// </summary>
/// <param name="tradeId">交易id</param>
/// <returns></returns>
@@ -218,40 +218,7 @@ namespace YLErp.Modules.SwapModule
.Where(x => x.SwapTradeId == tradeId)
.OrderByDescending(o => o.id)
.ToList();
return FilterOperationHistory(list);
}
/// <summary>
/// 过滤登记日创建且 Applied=false 的待生效公司行为事件,避免交易详情在真正
/// 调整前展示一条“已完成”历史。其他事件仍保留;无法解析的旧格式公司行为也
/// 保持可见,审计查询不能因为新 JSON 结构而静默丢失历史记录。
/// </summary>
public static List<swap_event> FilterOperationHistory(IEnumerable<swap_event> events)
{
return (events ?? Enumerable.Empty<swap_event>())
.Where(x => !IsPendingCorporateActionEvent(x))
.ToList();
}
/// <summary>
/// 判断指定事件是否为待生效的公司行为事件:EventType 为公司行为(13)
/// 且 EventData 反序列化后 Applied=false(登记日写入、尚未在真实除权日补齐调整后数据)。
/// 非公司行为类型、无法解析的旧格式或已应用的事件均返回 false,保证历史审计记录不被误删。
/// </summary>
public static bool IsPendingCorporateActionEvent(swap_event swapEvent)
{
if (swapEvent == null || swapEvent.EventType != (int)SwapEventTypeEnum.)
{
return false;
}
if (!TryDeserializeCorporateActionEventData(swapEvent, out var data))
{
// 非快照格式的历史公司行为保持可见,避免误删审计记录。
return false;
}
return !data.Applied;
return list;
}
/// <summary>
@@ -76,7 +76,12 @@ namespace YLErp.Modules.SwapModule
/// <param name="eodPayPosition"></param>
public void UpdateSwapPositionWithRealTime(eod_swap_position eodPayPosition)
{
var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId);
// 同一 PositionId 同时对应初始腿(id=PositionId)和实时腿(IsInitial=0)。
// 日终/公司行为同步只能更新实时腿;若直接 FirstOrDefault,会随机命中初始腿,
// 造成 EOD 已是 200000/50 而交易详情仍保持 100000/100,或反向污染交易初始腿。
var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId
&& !x.IsInitial
&& !x.Invalid);
if (position != null)
{
position.PosiQuantity = eodPayPosition.PosiQuantity;
@@ -96,7 +101,13 @@ namespace YLErp.Modules.SwapModule
}
else
{
// 兼容实时腿尚未生成的首日/自动合成场景:只从初始腿克隆创建实时腿,
// 不能把初始腿当作可更新对象。
position = DbContext.swap_position.FirstOrDefault(x => x.id == eodPayPosition.PositionId);
if (position == null)
{
return;
}
var posi = position.Clone();
posi.id = 0;
posi.PositionId = position.id;
@@ -1252,6 +1252,12 @@ namespace YLErp.Modules.TradeModule.DealModule
{
return true;
}
// EffectiveDate 已存在时,当前有效 EOD 是唯一执行状态来源。
// 回退会清理生效日及之后的 EOD,但不会删除 eodStatus 或不可篡改的
// 公司行为审计事件;此处不能继续落入旧的登记日 eodStatus 判断,
// 否则交易已回退仍会被错误判定为“已执行”而无法修改。
return false;
}
}