feat(bond): 支持债券与股票基金公司行为现金流计算的差异化处理 - init2

- 修改 CalcPayment 方法添加 useBondPriceScale 参数区分债券和股票/基金的金额计算口径
- 债券利息按每100元面值票息通过BondPriceConverter转为入库金额,股票基金分红直接计算
- 在BondPaymentService中添加详细的参数说明文档注释
- 更新SwapDealService中分红计算逻辑,根据标的类型自动选择合适的金额转换方式
- 新增CorporateActionEventLifecycleTest单元测试验证公司行为事件生命周期管理
- 添加SplitCorporateActionTddTest测试验证拆合股功能
- 优化FundCorporateActionRollbackAndUnwindTest扩展到股票类型测试
- 更新前端OperationHistory页面表格列宽和显示格式支持更长的说明信息
This commit is contained in:
张名锐
2026-08-19 17:48:34 +08:00
parent ddf233678d
commit aa3548e77f
19 changed files with 1342 additions and 104 deletions
@@ -20,6 +20,7 @@ namespace YLErp.DBModels
=9,
=10,
=11,
=12
=12,
=13
}
}
@@ -82,6 +82,37 @@ namespace YLErp.DBModels
[NotMapped]
public UnwindData unwindData { get; set; }
}
/// <summary>
/// 公司行为事件快照。登记日先写入待生效快照,真实除权日补齐调整后数据;
/// 已应用快照只允许追加回退事件,不覆盖原记录。
/// ExDividendDate 是登记日,EffectiveDate 是 Q/P 真实切换日;GiveShareAmount
/// 表示每 10 份增减数量,Split 表示独立拆/合股倍数(null 按 1)。Before/After
/// 分别保存调整前后名义本金、价格、数量和待实现分红,CashFlowChange 保存现金变化。
/// </summary>
public class CorporateActionEventData
{
public int ExDividendInfoId { get; set; }
public long PositionId { get; set; }
public string UnderlyingCode { get; set; }
public DateTime? ExDividendDate { get; set; }
public DateTime? EffectiveDate { get; set; }
public decimal GiveCashAmount { get; set; }
public decimal GiveShareAmount { get; set; }
public decimal? Split { get; set; }
public decimal RationedSharesAmount { get; set; }
public decimal RationedSharesPrice { get; set; }
public decimal BeforeNotional { get; set; }
public decimal BeforePrice { get; set; }
public decimal BeforeQuantity { get; set; }
public decimal AfterNotional { get; set; }
public decimal AfterPrice { get; set; }
public decimal AfterQuantity { get; set; }
public decimal BeforePendingDividend { get; set; }
public decimal AfterPendingDividend { get; set; }
public decimal CashFlowChange { get; set; }
public bool Applied { get; set; }
}
/// <summary>
/// 展期信息
/// </summary>
@@ -69,7 +69,10 @@ namespace YLErp.Modules.SwapModule
public List<(DateTime valueDate, int eventType, string reason, UnwindData data)> SwapEvents { get; } = new();
/// <summary>捕获落库的互换流水明细</summary>
public List<swap_flow_event> PersistedFlowEvents { get; } = new();
public List<swap_flow_event> PersistedFlowEvents => DbContext.swap_flow_event.Local.ToList();
/// <summary>捕获资金流水的金额、操作类型和发生日</summary>
public List<(double amount, string action, DateTime valueDate)> ClientCashCallDetails { get; } = new();
public AutoSwapEodService(
List<trade> trades, List<swap_position> positions,
@@ -112,6 +115,13 @@ namespace YLErp.Modules.SwapModule
protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List<int> eventTypes) { }
public override void ClearSwapPositions(trade td, DateTime valueDate, List<int> eventTypes, bool delAfter) { }
public override int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate)
{
ClientCashCalls.Add((amount, action));
ClientCashCallDetails.Add((amount, action, valueDate));
return ClientCashCalls.Count;
}
protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType,
string data, int clientCashId, bool save, string reason)
{
@@ -509,6 +519,9 @@ namespace YLErp.Modules.SwapModule
$"分红支付日({actualPayDate:yyyy-MM-dd})不应早于结算日({PayDate:yyyy-MM-dd})");
Assert.IsFalse(QdpModule.QdpCalendarHelper.IsHoliday(actualPayDate),
$"分红支付日({actualPayDate:yyyy-MM-dd})必须落在非假日");
Assert.AreEqual(1, svc.ClientCashCallDetails.Count, "应生成 1 条分红资金流水");
Assert.AreEqual(actualPayDate, svc.ClientCashCallDetails[0].valueDate,
"资金发生日应使用分红支付日");
}
// ================================================================
@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.DBModels;
using YLErp.DBModels.Consts;
namespace YLErp.Modules.SwapModule
{
[TestClass]
public class CorporateActionEventLifecycleTest
{
// 8/14 登记日只创建 Applied=false 的待生效事件;8/17 真实生效日补齐
// 同一事件的调整前后快照并标记 Applied=true。
private static readonly DateTime RecordDate = new DateTime(2026, 8, 14);
private static readonly DateTime EffectiveDate = new DateTime(2026, 8, 17);
[TestMethod]
public void RegistrationSnapshot_IsPending_AndKeepsBeforeFields()
{
var info = CreateAction(77, ConsGlobal.InstrumentType.Stock);
var before = CreateEodPosition(9, info.UnderlyingCode, 1000m, 100m);
var snapshot = SwapEodPositionService.BuildCorporateActionEventData(
info,
before,
null,
applied: false);
Assert.AreEqual(77, snapshot.ExDividendInfoId);
Assert.AreEqual(9L, snapshot.PositionId);
Assert.AreEqual(1000m, snapshot.BeforeQuantity);
Assert.AreEqual(100m, snapshot.BeforePrice);
Assert.AreEqual(100000m, snapshot.BeforeNotional);
Assert.AreEqual(0m, snapshot.AfterQuantity);
Assert.IsFalse(snapshot.Applied);
var reason = SwapEventService.BuildCorporateActionEventReason(snapshot);
StringAssert.Contains(reason, "BeforeQuantity=1000");
StringAssert.Contains(reason, "AfterQuantity=0");
}
[TestMethod]
public void EffectiveSnapshot_ContainsAfterFields_AndSupportsStockAndFund()
{
var info = CreateAction(78, ConsGlobal.InstrumentType.Fund);
var before = CreateEodPosition(10, info.UnderlyingCode, 1000m, 100m);
var after = CreateEodPosition(10, info.UnderlyingCode, 2000m, 50m);
var snapshot = SwapEodPositionService.BuildCorporateActionEventData(
info,
before,
after,
applied: true);
Assert.AreEqual(1000m, snapshot.BeforeQuantity);
Assert.AreEqual(100m, snapshot.BeforePrice);
Assert.AreEqual(2000m, snapshot.AfterQuantity);
Assert.AreEqual(50m, snapshot.AfterPrice);
Assert.AreEqual(100000m, snapshot.AfterNotional);
Assert.IsTrue(snapshot.Applied);
Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Stock));
Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Fund));
Assert.IsFalse(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.TBonds));
}
[TestMethod]
public void Rerun_DoesNotCreateDuplicateCorporateActionEvent()
{
var info = CreateAction(79, ConsGlobal.InstrumentType.Stock);
var snapshot = SwapEodPositionService.BuildCorporateActionEventData(
info,
CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m),
null,
applied: false);
var existing = new swap_event
{
SwapTradeId = 100,
EventType = (int)SwapEventTypeEnum.,
EventData = JsonConvert.SerializeObject(snapshot),
Invalid = false
};
Assert.IsFalse(SwapEodPositionService.ShouldCreateCorporateActionEvent(
new[] { existing },
info,
11L));
}
[TestMethod]
public void LegacyEventWithoutExDividendInfoId_DoesNotBlockCurrentEvent()
{
var info = CreateAction(79, ConsGlobal.InstrumentType.Stock);
var legacySnapshot = SwapEodPositionService.BuildCorporateActionEventData(
info,
CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m),
null,
applied: false);
legacySnapshot.ExDividendInfoId = 0;
var legacyEvent = new swap_event
{
SwapTradeId = 100,
EventType = (int)SwapEventTypeEnum.,
EventData = JsonConvert.SerializeObject(legacySnapshot),
Invalid = false
};
Assert.IsTrue(SwapEodPositionService.ShouldCreateCorporateActionEvent(
new[] { legacyEvent },
info,
11L));
}
[TestMethod]
public void OperationHistory_FiltersPendingCorporateActionOnly()
{
var info = CreateAction(80, ConsGlobal.InstrumentType.Stock);
var pendingData = SwapEodPositionService.BuildCorporateActionEventData(
info,
CreateEodPosition(12, info.UnderlyingCode, 1000m, 100m),
null,
applied: false);
var appliedData = SwapEodPositionService.BuildCorporateActionEventData(
info,
CreateEodPosition(13, info.UnderlyingCode, 1000m, 100m),
CreateEodPosition(13, info.UnderlyingCode, 2000m, 50m),
applied: true);
var events = new List<swap_event>
{
new swap_event { id = 1, EventType = (int)SwapEventTypeEnum., EventData = JsonConvert.SerializeObject(pendingData) },
new swap_event { id = 2, EventType = (int)SwapEventTypeEnum., EventData = JsonConvert.SerializeObject(appliedData) },
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);
}
[TestMethod]
public void EffectiveCorporateAction_AdjustsStockQuantityAndPrice()
{
var position = new swap_position
{
PositionId = 14,
PosiDirection = 1,
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock,
UnderlyingCode = "STOCK.TEST",
PosiQuantity = 1000m,
PosiGrossPrice = 100m,
PosiNetPrice = 100m,
ContractSize = 1m
};
var info = CreateAction(81, ConsGlobal.InstrumentType.Stock);
info.GiveShareAmount = 10m;
var applied = SwapEodPositionService.ApplyFundCorporateActionToPosition(
position,
info,
100m,
0m);
Assert.IsTrue(applied);
Assert.AreEqual(2000m, position.PosiQuantity);
Assert.AreEqual(50m, position.PosiGrossPrice);
Assert.AreEqual(100000m, position.PosiNotionalValue);
}
[TestMethod]
public void Lifecycle_RegistrationIsIdempotent_ThenEffectiveUpdatesSameEvent()
{
var info = CreateAction(82, ConsGlobal.InstrumentType.Stock);
var before = CreateEodPosition(15, info.UnderlyingCode, 1000m, 100m);
var after = CreateEodPosition(15, info.UnderlyingCode, 2000m, 50m);
var service = new EventRecordingService();
var trade = new trade { id = 100 };
service.Record(
trade,
new[] { before },
Array.Empty<eod_swap_position>(),
new[] { info },
Array.Empty<ex_dividend_info>(),
RecordDate);
service.Record(
trade,
new[] { before },
Array.Empty<eod_swap_position>(),
new[] { info },
Array.Empty<ex_dividend_info>(),
RecordDate);
Assert.AreEqual(1, service.Events.Count);
Assert.AreEqual(1000m, before.PosiQuantity, "登记日不能改持仓数量");
Assert.AreEqual(100m, before.PosiGrossPrice, "登记日不能改持仓价格");
var pending = JsonConvert.DeserializeObject<CorporateActionEventData>(service.Events[0].EventData);
Assert.IsFalse(pending.Applied);
Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date);
service.Record(
trade,
new[] { after },
new[] { before },
Array.Empty<ex_dividend_info>(),
new[] { info },
EffectiveDate);
Assert.AreEqual(1, service.Events.Count, "生效日应更新原事件而非新增事件");
Assert.AreEqual(1, service.UpdateCount);
var applied = JsonConvert.DeserializeObject<CorporateActionEventData>(service.Events[0].EventData);
Assert.IsTrue(applied.Applied);
Assert.AreEqual(1000m, applied.BeforeQuantity);
Assert.AreEqual(2000m, applied.AfterQuantity);
Assert.AreEqual(50m, applied.AfterPrice);
Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date);
}
private sealed class EventRecordingService : TestableSwapEodPositionService
{
public List<swap_event> Events { get; } = new List<swap_event>();
public int UpdateCount { get; private set; }
public EventRecordingService()
: base(nameof(CorporateActionEventLifecycleTest))
{
}
protected override List<swap_event> FindCorporateActionEvents(int swapTradeId)
{
return Events;
}
protected override swap_event AddSwapEvent(
DateTime tradeDate,
int swapTradeId,
int eventType,
string data,
int clientCashId,
bool save,
string reason)
{
return new swap_event { id = Events.Count + 1 };
}
protected override void UpdateCorporateActionEventRecord(swap_event swapEvent)
{
UpdateCount++;
}
public void Record(
trade trade,
IReadOnlyCollection<eod_swap_position> current,
IReadOnlyCollection<eod_swap_position> previous,
IReadOnlyCollection<ex_dividend_info> registration,
IReadOnlyCollection<ex_dividend_info> effective,
DateTime settleDate)
{
RecordCorporateActionEvents(
trade,
current,
previous,
registration,
effective,
settleDate);
}
}
private static ex_dividend_info CreateAction(int id, string instrumentType)
{
return new ex_dividend_info
{
id = id,
UnderlyingCode = instrumentType == ConsGlobal.InstrumentType.Fund ? "FUND.TEST" : "STOCK.TEST",
ExDividendDate = RecordDate,
EffectiveDate = EffectiveDate,
GiveShareAmount = 0m,
GiveCashAmount = 0m,
ValidStatus = true
};
}
private static eod_swap_position CreateEodPosition(long positionId, string code, decimal quantity, decimal price)
{
return new eod_swap_position
{
PositionId = positionId,
UnderlyingCode = code,
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock,
PosiQuantity = quantity,
PosiGrossPrice = price,
PosiNotionalValue = quantity * price,
PosiNetPrice = price,
ContractSize = 1m,
PosiDirection = 1,
PositionType = 1
};
}
}
}
@@ -6,6 +6,8 @@ namespace YLErp.Modules.SwapModule
[TestClass]
public class FundCorporateActionRollbackAndUnwindTest
{
// 生产恢复范围已从原 Fund-only 扩展到 TRS Fund/Stock;本组继续使用 Fund 夹具,
// 验证共享的登记日/EffectiveDate 边界和回退、平仓基线。
private static readonly DateTime ExDate = new(2026, 8, 17);
[TestMethod]
@@ -51,15 +53,15 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
public void FCA_UW_002_非Fund和最新Eod后已有完成流水时保持实时持仓()
public void FCA_UW_002_股票与最新Eod后已有完成流水时保持实时持仓()
{
var nonFund = CreateRealtimeFundPosition();
nonFund.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock;
var eod = CreateEod(ExDate, 2000m, 50m);
Assert.IsFalse(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod));
Assert.AreEqual(1000m, nonFund.PosiQuantity);
Assert.AreEqual(100m, nonFund.PosiGrossPrice);
Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod));
Assert.AreEqual(2000m, nonFund.PosiQuantity);
Assert.AreEqual(50m, nonFund.PosiGrossPrice);
var td = SwapDealTestFactory.CreateTrade();
var realtime = CreateRealtimeFundPosition();
@@ -0,0 +1,197 @@
using System.Reflection;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.TradeModule.DealModule;
namespace YLErp.Modules.SwapModule
{
[TestClass]
public class SplitCorporateActionTddTest
{
[TestMethod]
public void SplitTenScalesQuantityAndPriceByTen()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(split: 10m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(1000m, position.PosiQuantity);
Assert.AreEqual(10m, position.PosiGrossPrice);
}
[TestMethod]
public void SplitPointOneScalesQuantityAndPriceByPointOne()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(split: 0.1m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(10m, position.PosiQuantity);
Assert.AreEqual(1000m, position.PosiGrossPrice);
}
[TestMethod]
public void GiveShareTenWithNullSplitUsesCompatibleFactorTwo()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(giveShare: 10m, split: null);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(200m, position.PosiQuantity);
Assert.AreEqual(50m, position.PosiGrossPrice);
}
[TestMethod]
public void GiveShareFiveAndSplitTwoHaveCombinedFactorThree()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(giveShare: 5m, split: 2m);
// (1 + 5 / 10) * 2 = 3100 份/100 元变为 300 份/约 33.333333333 元。
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(300m, position.PosiQuantity);
Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 33.333333333m) < 0.000000001m);
}
[TestMethod]
public void CashAmountDoesNotChangeTrsFundInitialPriceFactor()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(cash: 10m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
position, info, 100m, 0m));
Assert.AreEqual(100m, position.PosiQuantity);
Assert.AreEqual(100m, position.PosiGrossPrice);
}
[TestMethod]
public void RationedSharesUseExcelPriceRatioForTrsQuantity()
{
var position = CreateFundPosition();
var info = CreateCorporateAction(
rationedSharesAmount: 1m,
rationedSharesPrice: 50m);
Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition(
position, info, 100m, 0m));
// Excel L-NL=(100*10+1*50)/(10+1)=95.4545...M=100/L
// 因此数量和价格分别按 Q'=Q*M、P'=P/M 调整。
Assert.IsTrue(Math.Abs(position.PosiQuantity - 104.761904761905m) < 0.000000000001m);
Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 95.454545455m) < 0.000000001m);
}
[TestMethod]
public void ZeroSplitIsRejected()
{
var info = CreateCorporateAction(split: 0m);
Assert.ThrowsException<ArgumentOutOfRangeException>(() =>
SwapEodPositionService.ApplyFundCorporateActionToPosition(
CreateFundPosition(), info, 100m, 0m));
}
[TestMethod]
public void NegativeSplitIsRejected()
{
var info = CreateCorporateAction(split: -1m);
Assert.ThrowsException<ArgumentOutOfRangeException>(() =>
SwapEodPositionService.ApplyFundCorporateActionToPosition(
CreateFundPosition(), info, 100m, 0m));
}
[TestMethod]
public void MissingSplitDoesNotClearExistingSplitDuringMerge()
{
var target = CreateCorporateAction(split: 10m);
var source = CreateCorporateAction(split: null);
InvokeMerge(target, source);
Assert.AreEqual(10m, GetSplit(target));
}
[TestMethod]
public void ExplicitSplitOneOverridesExistingSplitDuringMerge()
{
var target = CreateCorporateAction(split: 10m);
var source = CreateCorporateAction(split: 1m);
InvokeMerge(target, source);
Assert.AreEqual(1m, GetSplit(target));
}
private static ex_dividend_info CreateCorporateAction(
decimal cash = 0m,
decimal giveShare = 0m,
decimal? split = null,
decimal rationedSharesAmount = 0m,
decimal rationedSharesPrice = 0m)
{
var info = new ex_dividend_info
{
UnderlyingCode = "FUND.TEST",
ExDividendDate = new DateTime(2026, 8, 14),
EffectiveDate = new DateTime(2026, 8, 17),
GiveCashAmount = cash,
GiveShareAmount = giveShare,
RationedSharesAmount = rationedSharesAmount,
RationedSharesPrice = rationedSharesPrice,
ValidStatus = true
};
SetSplit(info, split);
return info;
}
private static swap_position CreateFundPosition()
{
return new swap_position
{
PosiDirection = 1,
UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund,
UnderlyingCode = "FUND.TEST",
PosiQuantity = 100m,
PosiGrossPrice = 100m,
PosiNetPrice = 100m,
PosiNetFeePrice = 100m,
PosiNetNoFeePrice = 100m,
ContractSize = 1m
};
}
private static void SetSplit(ex_dividend_info info, decimal? value)
{
var property = typeof(ex_dividend_info).GetProperty("Split");
Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现");
property.SetValue(info, value);
}
private static decimal? GetSplit(ex_dividend_info info)
{
var property = typeof(ex_dividend_info).GetProperty("Split");
Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现");
return (decimal?)property.GetValue(info);
}
private static void InvokeMerge(ex_dividend_info target, ex_dividend_info source)
{
var method = typeof(DividendService).GetMethod(
"MergeNonZeroDividendValues",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.IsNotNull(method, "公司行为存量合并方法不存在");
method.Invoke(null, new object[] { target, source });
}
}
}
@@ -332,7 +332,7 @@ namespace YLErp.Modules.SwapModule
}
[TestMethod]
public void SPC_FUND_002_现金分红_收盘时记入已实现分红()
public void SPC_FUND_002_现金分红_登记日不直接入账()
{
var td = CreateTrade();
var position = CreateFloatPosition(1, 1000m);
@@ -351,16 +351,18 @@ namespace YLErp.Modules.SwapModule
var actual = service.CreatedEodPositions.Single(x => x.PositionId == 1);
// 现金分红改由同步任务写入 bond_payment_info,并以 EffectiveDate 进入债券付息
// 链路;登记日 EOD 不直接读取 ex_dividend_info,因此此处不应提前产生现金。
Assert.AreEqual(1000m, actual.PosiQuantity);
Assert.AreEqual(0m, actual.TdChangedQty);
Assert.AreEqual(99m, actual.PosiGrossPrice);
Assert.AreEqual(1000m, actual.TdPosiDividend);
Assert.AreEqual(100m, actual.PosiGrossPrice);
Assert.AreEqual(0m, actual.TdPosiDividend);
Assert.AreEqual(0m, actual.PosiDividendSum);
Assert.AreEqual(99000m, actual.PosiNotionalValue);
Assert.AreEqual(100000m, actual.PosiNotionalValue);
Assert.AreEqual(0m, actual.PosiMtmPnL);
Assert.AreEqual(0m, actual.PosiProfitSum);
Assert.AreEqual(1000m, actual.RealizedDividend);
Assert.AreEqual(1000m, actual.RealizedPnl);
Assert.AreEqual(0m, actual.RealizedDividend);
Assert.AreEqual(0m, actual.RealizedPnl);
}
[TestMethod]
@@ -45,6 +45,9 @@ namespace YLErp.Modules.SwapModule
/// <summary>SwapPositionCompose 使用的公司行为内存数据;默认空,避免测试访问数据库。</summary>
public List<ex_dividend_info> ExDividendInfos { get; } = new();
/// <summary>捕获公司行为生命周期事件,避免事件测试访问真实 swap_event 表。</summary>
public List<swap_event> CorporateActionEvents { get; } = new();
/// <summary>自增 id 模拟器(新增 eod 时分配 id</summary>
private int _nextId = 1;
@@ -90,6 +93,32 @@ namespace YLErp.Modules.SwapModule
.ToList();
}
protected override List<ex_dividend_info> FindCorporateActionInfos(DateTime settleDate)
{
return ExDividendInfos
.Where(x => x.ValidStatus
&& (x.ExDividendDate?.Date == settleDate.Date
|| x.EffectiveDate?.Date == settleDate.Date))
.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
.Where(x => x.SwapTradeId == swapTradeId && !x.Invalid
&& x.EventType == (int)SwapEventTypeEnum.)
.ToList();
}
protected override decimal GetFundCorporateActionClosePrice(
ex_dividend_info dividendInfo,
decimal fallbackPrice)
+7
View File
@@ -56,6 +56,13 @@ namespace YLErp.DBModels
[DisplayName("送股股数")]
public decimal GiveShareAmount { get; set; }
/// <summary>
/// 拆/合股倍数。为空时按 1 兼容历史记录;与 GiveShareAmount 的“每 10 份送股数量”语义不同。
/// </summary>
[DisplayName("拆/合股倍数")]
public decimal? Split { get; set; }
/// <summary>
/// 配股手数
/// </summary>
@@ -119,24 +119,49 @@ namespace YLErp.Modules.EodModule
/// <param name="longRatio">多空方向</param>
/// <param name="payDirection">收支方向</param>
/// <returns></returns>
public decimal CalcPayment(string underlyingCode, DateTime startDate, DateTime endDate, decimal qty, decimal longRatio, decimal payDirection)
public decimal CalcPayment(
string underlyingCode,
DateTime startDate,
DateTime endDate,
decimal qty,
decimal longRatio,
decimal payDirection,
bool useBondPriceScale = true)
{
var payments = GetBondPayments(underlyingCode, startDate, endDate);
return CalcPayment(payments, qty, longRatio, payDirection);
return CalcPayment(payments, qty, longRatio, payDirection, useBondPriceScale);
}
/// <summary>
/// 计算某债券期间付息
/// 计算某标的期间现金流。债券与 Stock/Fund 公司行为共用 bond_payment_info
/// 但通过 useBondPriceScale 明确区分两种入库金额单位。
/// </summary>
/// <param name="payments">期间付息集合</param>
/// <param name="qty">持仓数量</param>
/// <param name="longRatio">多空方向</param>
/// <param name="payDirection">收支方向</param>
/// <param name="useBondPriceScale">
/// 是否按债券报价的百分比口径换算。债券的 payment_interest 是每 100 元面值的票息,
/// 需要继续通过 BondPriceConverter 转成入库金额;Fund/Stock 的公司行为现金分红
/// 在 bond_payment_info 中按每 10 份存储,payment_interest * qty 已经是实际现金,
/// 不能再做一次 /100。默认 true 是为了保持所有历史债券调用方的原有口径。
/// </param>
/// <returns></returns>
public decimal CalcPayment(List<BondPayment> payments, decimal qty, decimal longRatio, decimal payDirection)
public decimal CalcPayment(
List<BondPayment> payments,
decimal qty,
decimal longRatio,
decimal payDirection,
bool useBondPriceScale = true)
{
var interest = payments.Sum(s => s.payment_interest ?? 0);
// interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额(与入库价格 bondPriceMultiple 同口径)
return BondPriceConverter.ToStorage(interest * qty) * longRatio * payDirection;
var paymentAmount = interest * qty;
// 债券:interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额。
// Fund/Stock 公司行为:interest 已由【同步任务】写成 GiveCashAmount/10
// ×qty 就是“每 10 份派现额 × 持仓份额”,必须保留原金额,不能套债券的 /100。
var actualAmount = useBondPriceScale
? BondPriceConverter.ToStorage(paymentAmount)
: paymentAmount;
return actualAmount * longRatio * payDirection;
}
}
+13 -3
View File
@@ -2005,10 +2005,20 @@ namespace YLErp.Modules.SwapModule
int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType);
int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection);
// + 付息日>上日日终且小于等于平仓日期的分红数据
var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode);
decimal tax = um.ValueAddedTax ?? 0;
// 债券付息按每百元票息存储,继续走 BondPriceConverterStock/Fund 的公司行为
// 现金分红按每 10 份金额存储,实际现金就是 payment_interest * qty,不能 /100。
// 标的资料缺失时保持旧债券口径,避免未知标的的历史平仓金额被放大。
var useBondPriceScale = um == null
|| !SwapEodPositionService.IsCorporateActionInstrument(um.UnderlyingInstrumentType);
// + 付息日>上日日终且小于等于平仓日期的分红数据
var dividendIn = servie.CalcPayment(
payments,
unwindQty,
shortRatio,
directionRatio,
useBondPriceScale);
decimal tax = um?.ValueAddedTax ?? 0;
dividendIn = DividendCalc.AfterTaxRaw(dividendIn, tax);
flowEvent.DividendIn = Math.Round(dividendIn, 2, MidpointRounding.AwayFromZero);
@@ -91,8 +91,8 @@ namespace YLErp.Modules.SwapModule
if (realtimePosition == null
|| eodPosition == null
|| realtimePosition.PosiDirection <= 0
|| realtimePosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund
|| eodPosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund)
|| !IsTrsCorporateActionInstrument(realtimePosition.UnderlyingInstrumentType)
|| !IsTrsCorporateActionInstrument(eodPosition.UnderlyingInstrumentType))
{
return false;
}
@@ -370,10 +370,27 @@ namespace YLErp.Modules.SwapModule
return DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode);
}
/// <summary>计算债券付息(生产: BondPaymentService;测试: 返回固定值)</summary>
/// <summary>
/// 计算期间现金流(生产: BondPaymentService;测试: 返回固定值)。
/// BondPaymentService 的默认仍是债券百分比价格口径;TRS Stock/Fund 的公司行为
/// 分红行按每 10 份金额入库,因此必须显式关闭 BondPriceConverter 的 /100 换算。
/// 标的资料缺失时沿用债券口径,避免把未知历史数据放大 100 倍。
/// </summary>
protected virtual decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
return new BondPaymentService(UserInfo).CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
var underlying = GetUnderlyingData(underlyingCode);
// 本期现金分红只覆盖 TRS Stock/Fund。其他非债券(期货、期权等)虽然也不属于
// 债券,但尚未接入本现金分红表,继续使用默认债券换算,避免扩大改造范围。
var useBondPriceScale = underlying == null
|| !IsCorporateActionInstrument(underlying.UnderlyingInstrumentType);
return new BondPaymentService(UserInfo).CalcPayment(
underlyingCode,
fromDate,
toDate,
qty,
shortRatio,
directionRatio,
useBondPriceScale);
}
// ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)----
@@ -444,6 +461,48 @@ namespace YLErp.Modules.SwapModule
.ToList();
}
/// <summary>
/// 查询登记日或真实生效日命中的公司行为。保留 FindExDividendInfos 这个
/// 可替换入口,测试和历史调用方可以继续注入内存数据。
/// </summary>
protected virtual List<ex_dividend_info> FindCorporateActionInfos(DateTime settleDate)
{
return DbContext.ex_dividend_info
.Where(x => x.ValidStatus
&& ((x.ExDividendDate.HasValue && x.ExDividendDate.Value == settleDate.Date)
|| (x.EffectiveDate.HasValue && x.EffectiveDate.Value == settleDate.Date)))
.ToList();
}
/// <summary>查询交易已有公司行为事件,用于登记日/生效日幂等匹配。</summary>
protected virtual List<swap_event> FindCorporateActionEvents(int swapTradeId)
{
return DbContext.swap_event
.Where(x => x.SwapTradeId == swapTradeId
&& x.EventType == (int)SwapEventTypeEnum.
&& !x.Invalid)
.ToList();
}
/// <summary>更新已存在的公司行为事件;默认只标记实体,统一由收盘事务保存。</summary>
protected virtual void UpdateCorporateActionEventRecord(swap_event swapEvent)
{
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 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日
@@ -474,6 +533,17 @@ namespace YLErp.Modules.SwapModule
return new DividendService(this).GetDividendTaxRateDecimal();
}
public static bool IsCorporateActionInstrument(string instrumentType)
{
// TRS 公司行为本期只覆盖 Stock/Fund。TBonds 等类型继续走原债券付息链路,
// 这里不能用“非空标的类型”放宽,否则会把期权、期货等未验证品种一并启用。
return string.Equals(instrumentType, ConsGlobal.InstrumentType.Fund, StringComparison.OrdinalIgnoreCase)
|| string.Equals(instrumentType, ConsGlobal.InstrumentType.Stock, StringComparison.OrdinalIgnoreCase);
}
private static bool IsTrsCorporateActionInstrument(string instrumentType)
=> IsCorporateActionInstrument(instrumentType);
#endregion
/// <summary>
@@ -522,7 +592,19 @@ namespace YLErp.Modules.SwapModule
var completedFlowEvents = FindCompletedFlowEvents(tradeIds);
// 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘,
// 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。
var exDividendInfos = FindExDividendInfos(settleDate);
var corporateActionInfos = FindCorporateActionInfos(settleDate) ?? new List<ex_dividend_info>();
var exDividendInfos = corporateActionInfos
.Where(x => x != null
&& x.ValidStatus
&& x.EffectiveDate.HasValue
&& x.EffectiveDate.Value.Date == settleDate.Date)
.ToList();
var registrationInfos = corporateActionInfos
.Where(x => x != null
&& x.ValidStatus
&& x.ExDividendDate.HasValue
&& x.ExDividendDate.Value.Date == settleDate.Date)
.ToList();
var duplicateDividend = exDividendInfos
.GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(x => x.Count() > 1);
@@ -530,6 +612,16 @@ namespace YLErp.Modules.SwapModule
{
throw new InvalidOperationException($"标的【{duplicateDividend.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效除权记录");
}
// 公司行为去重 - 拦截
var duplicateRegistration = registrationInfos
.GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(x => x.Count() > 1);
if (duplicateRegistration != null)
{
// 登记日现金权益不能依赖数据库返回顺序取 First;同一标的同一登记日
// 有多条有效记录时,系统无法证明应采用哪一条派现金额,必须中止收盘。
throw new InvalidOperationException($"标的【{duplicateRegistration.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效登记日记录");
}
var exDividendByCode = exDividendInfos.ToDictionary(
x => x.UnderlyingCode,
x => x,
@@ -573,17 +665,32 @@ namespace YLErp.Modules.SwapModule
var flowEvents = FindFlowEvents(td.id, settleDate);
var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期
List<swap_flow_event> autoInterests = new List<swap_flow_event>();//自动互换利息腿信息
// 处理浮动腿前先准备当日开盘基线:登记日 8 月 14 日 EOD 仍保存
// 1000 份/100 元,8 月 17 日收盘时先把上一 EOD 的基线转换为
// 处理浮动腿前先准备当日开盘基线:登记日 EOD 仍保存
// 1000 份/100 元,除权日收盘时先把上一 EOD 的基线转换为
// 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。
// 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到
// 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。
// 重置基线
var openingEodPositions = PrepareFundOpeningEodPositions(
eodPositions,
exDividendByCode,
settleDate);
// 构建公司行为前eod持仓
var corporateActionBeforePositions = BuildCorporateActionBeforePositions(
eodPositions,
posiList);
// 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线,应用生效日公司行为。
// 有上一份 EOD 时沿用 PrepareFundOpeningEodPositions,避免重复套系数。
var floatPositionsForCompose = eodPositions.Count == 0
? PrepareInitialCorporateActionPositions(posiList, exDividendByCode, settleDate)
: posiList;
// 处理浮动腿归档
var curEodPosis = DealFloatPositions(
posiList,
floatPositionsForCompose,
realPosiList,
openingEodPositions,
todyEodPositions,
@@ -591,14 +698,17 @@ namespace YLErp.Modules.SwapModule
td,
preSettleDate,
flowEvents);
// 公司行为 - 分红
// Fund 现金分红在 EffectiveDate 当日收盘即完成结算:
// TdPosiDividend 展示当日金额,RealizedDividend 累计已实现金额,
// 不把同一笔金额留在 PosiDividendSum 待实现字段中
ApplyFundCashDividends(
// 现金分红不在登记日直接读取 ex_dividend_info 累加。
// 同步任务会把 GiveCashAmount/10 写入 bond_payment_infoCopy/Update EOD 在
// EffectiveDate 通过 CalcBondPayment 命中该行并生成 TdPosiDividend。
// 这样登记日快照不提前变化,也不会与债券付息/平仓链路重复计算。
RecordCorporateActionEvents(
td,
curEodPosis,
eodPositions,
exDividendByCode,
corporateActionBeforePositions,
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);
@@ -665,11 +775,11 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 对 Fund 浮动腿应用一条已按 EffectiveDate 筛选的公司行为。
/// 对 TRS Stock/Fund 浮动腿应用一条已按 EffectiveDate 筛选的份额/价格公司行为。
/// 此方法用于直接测试/兼容已有调用方;正式收盘链路通过
/// PrepareFundOpeningEodPositions 在处理当日流水前执行同一动作。
/// 该步骤只改 EOD 持仓,不生成现金分红流水;现金分红通过期初价下调进入浮动端损益,
/// 若同时再写 TdPosiDividend 会重复计入。
/// 该步骤只改 EOD 持仓的份额/价格基线,不生成现金分红流水;现金模式下现金分红
/// 不下调期初价格,而是由同步任务写入 bond_payment_info,后续付息链路单独计入。
/// <para>
/// 幂等例子:原持仓 1000 份、期初价 100,每 10 份送 10 份。首次收盘得到 2000 份/50;
/// 同日重跑时,若该腿没有新流水,先从前一日 EOD 恢复 1000/100,再计算为 2000/50
@@ -698,7 +808,7 @@ namespace YLErp.Modules.SwapModule
foreach (var position in positions)
{
if (position.PosiDirection <= 0
|| position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund
|| !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType)
|| string.IsNullOrWhiteSpace(position.UnderlyingCode)
|| !exDividendByCode.TryGetValue(position.UnderlyingCode, out var dividendInfo)
|| !dividendInfo.EffectiveDate.HasValue
@@ -739,22 +849,25 @@ namespace YLErp.Modules.SwapModule
$"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】的除权份额参数导致除数为 0");
}
// 计算除权系数
var factors = DividendService.CalculateCorporateActionFactors(
dividendInfo,
corporateActionClosePrice,
dividendTaxRate);
if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0)
dividendTaxRate,
adjustCashDividendPrice: false);
if (factors.PriceRatio <= 0)
{
throw new InvalidOperationException(
$"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】计算得到无效除权系数");
}
// PriceRatio 是“除权前收盘价 / 除权参考价”,所以期初价格要除以它;ShareFactor
// 只来自送股/拆合股。10 送 10 时 1000 份/100 变为 2000 份/50,名义本金仍为 100000
// 每 10 份派现 10 时数量不变、价格基准降为 99,名义本金变为 99000,后续平一半只能扣 49500。
// Excel 公式 口径:PriceRatio 是“登记日收盘价 / 除权参考价”,
// 因此期初价格和持仓数量都使用同一个系数:P' = P / MQ' = Q * M。
// 配股已经进入 价格参考价,所以即使没有送股,配股也会调整 TRS 数量;
// 现金分红不影响 TRS Stock/Fund 期初价格,现金权益由独立分红字段处理。
var originalQuantity = position.PosiQuantity;
position.PosiQuantity = Math.Round(
originalQuantity * factors.ShareFactor,
originalQuantity * factors.PriceRatio,
12,
MidpointRounding.AwayFromZero);
position.TdChangedQty = position.PosiQuantity - originalQuantity;
@@ -815,10 +928,297 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 将 Fund 当日现金分红记入 EOD 已实现分红。
/// 构造审计事件的调整前快照。优先克隆上一 EOD,保证后续调整不会污染历史实体;
/// 交易首日没有 EOD 时才从初始持仓复制,并把累计分红/已实现字段初始化为 0。
/// </summary>
private static List<eod_swap_position> BuildCorporateActionBeforePositions(
IReadOnlyCollection<eod_swap_position> previousPositions,
IReadOnlyCollection<swap_position> initialPositions)
{
if (previousPositions != null && previousPositions.Count > 0)
{
return previousPositions
.Where(x => x != null)
.Select(x => x.Clone())
.ToList();
}
return (initialPositions ?? Array.Empty<swap_position>())
.Where(x => x != null)
.Select(x => new eod_swap_position
{
PositionId = x.PositionId,
UnderlyingCode = x.UnderlyingCode,
UnderlyingInstrumentType = x.UnderlyingInstrumentType,
PosiDirection = x.PosiDirection,
PositionType = x.PositionType,
ContractSize = x.ContractSize,
CountRatio = x.CountRatio,
PosiQuantity = x.PosiQuantity,
PosiGrossPrice = x.PosiGrossPrice,
PosiNetPrice = x.PosiNetPrice,
PosiNetFeePrice = x.PosiNetFeePrice,
PosiNetNoFeePrice = x.PosiNetNoFeePrice,
PosiNotionalValue = x.PosiNotionalValue,
PosiTradingFee = x.PosiTradingFee,
PosiFeePending = x.PosiTradingFeePending,
PosiDividendSum = 0m,
RealizedDividend = 0m,
PosiStatus = x.PosiQuantity == 0m ? 1 : 0
})
.ToList();
}
/// <summary>
/// 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线。
/// 不直接修改初始持仓实体,避免重收盘或后续流程再次读取时重复套用系数。
/// </summary>
private List<swap_position> PrepareInitialCorporateActionPositions(
IReadOnlyCollection<swap_position> initialPositions,
IReadOnlyDictionary<string, ex_dividend_info> exDividendByCode,
DateTime settleDate)
{
var positions = (initialPositions ?? Array.Empty<swap_position>())
.Where(x => x != null)
.Select(x => x.Clone())
.ToList();
if (positions.Count == 0 || exDividendByCode == null || exDividendByCode.Count == 0)
{
return positions;
}
foreach (var position in positions)
{
if (position.PosiDirection <= 0
|| !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType)
|| string.IsNullOrWhiteSpace(position.UnderlyingCode)
|| !exDividendByCode.TryGetValue(position.UnderlyingCode, out var info))
{
continue;
}
var closePrice = GetFundCorporateActionClosePrice(info, position.PosiGrossPrice);
ApplyFundCorporateActionToPosition(
position,
info,
closePrice,
GetDividendTaxRate());
}
return positions;
}
/// <summary>
/// 写入公司行为生命周期审计事件。
/// 登记日:保存调整前快照并标记 Applied=false
/// 真实除权日:使用上一 EOD 与当前 EOD 补齐调整后快照并标记 Applied=true。
/// 事件数据只追加/补齐,不删除已生效记录,
/// 便于交易回退后通过 BackId 关联新的回退记录。
/// </summary>
protected virtual void RecordCorporateActionEvents(
trade td,
IReadOnlyCollection<eod_swap_position> currentPositions,
IReadOnlyCollection<eod_swap_position> previousPositions,
IReadOnlyCollection<ex_dividend_info> registrationInfos,
IReadOnlyCollection<ex_dividend_info> effectiveInfos,
DateTime settleDate)
{
if (td == null || currentPositions == null)
{
return;
}
var infos = (registrationInfos ?? Array.Empty<ex_dividend_info>())
.Concat(effectiveInfos ?? Array.Empty<ex_dividend_info>())
.Where(x => x != null && x.ValidStatus && !string.IsNullOrWhiteSpace(x.UnderlyingCode))
.GroupBy(x => new
{
x.id,
x.UnderlyingCode,
ExDividendDate = x.ExDividendDate?.Date,
EffectiveDate = x.EffectiveDate?.Date
})
.Select(x => x.First())
.ToList();
if (infos.Count == 0)
{
return;
}
var existingEvents = FindCorporateActionEvents(td.id);
foreach (var current in currentPositions.Where(x => x != null && x.PosiDirection > 0
&& IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType)))
{
var info = infos.FirstOrDefault(x => string.Equals(
x.UnderlyingCode,
current.UnderlyingCode,
StringComparison.OrdinalIgnoreCase));
if (info == null)
{
continue;
}
// 公司行为事件只使用“公司行为记录主键 + PositionId”作为幂等键。
var matchingEvents = existingEvents
.Select(x => new { Event = x, Data = DeserializeCorporateActionEventData(x.EventData) })
.Where(x => x.Data != null
&& info.id > 0
&& x.Data.ExDividendInfoId == info.id
&& x.Data.PositionId == current.PositionId)
.ToList();
var eventData = matchingEvents.FirstOrDefault(x => !x.Data.Applied)
?? matchingEvents.FirstOrDefault();
var previous = previousPositions?.FirstOrDefault(x => x != null && x.PositionId == current.PositionId);
// 登记日 false 除权日 true
var isEffective = info.EffectiveDate.HasValue
&& info.EffectiveDate.Value.Date <= settleDate.Date
&& effectiveInfos != null
&& effectiveInfos.Any(x => x.id == info.id);
// 如果没有匹配到事件或事件未生效,则创建新事件。
if (eventData == null || (!isEffective && eventData.Data.Applied))
{
// 创建新事件
var pending = BuildCorporateActionEventData(
info,
previous ?? current,
isEffective ? current : null,
applied: isEffective);
// 生命周期事件的发生日固定为登记日,EffectiveDate 只表示 Q/P 基线切换日。
// 这样回退后重收盘仍能按原登记日排序和追溯,不会把同一事件拆成两条历史。
var eventDate = info.ExDividendDate?.Date
?? info.EffectiveDate?.Date
?? settleDate.Date;
var created = AddSwapEvent(
eventDate,
td.id,
(int)SwapEventTypeEnum.,
JsonConvert.SerializeObject(pending),
0,
false,
BuildCorporateActionReason(pending));
if (created == null)
{
created = new swap_event();
}
// 测试接缝和历史实现可能返回只带 id 的实体;统一补齐字段,
// 确保同一收盘事务内的生效步骤能找到刚创建的事件。
created.EventType = (int)SwapEventTypeEnum.;
created.SwapTradeId = td.id;
created.ValueDate = eventDate;
created.EventData = JsonConvert.SerializeObject(pending);
created.EventReason = BuildCorporateActionReason(pending);
existingEvents.Add(created);
continue;
}
// 如果不是生效日或事件已生效,则跳过。
if (!isEffective || eventData.Data.Applied)
{
continue;
}
// 生效日只补齐同一事件的 Before/After 快照,不重新套系数:Before* 来自
// 调整前 EODAfter* 来自生效日当前 EOD,current 已由开盘基线处理完成。
eventData.Data.BeforeNotional = previous?.PosiNotionalValue ?? eventData.Data.BeforeNotional;
eventData.Data.BeforePrice = previous?.PosiGrossPrice ?? eventData.Data.BeforePrice;
eventData.Data.BeforeQuantity = previous?.PosiQuantity ?? eventData.Data.BeforeQuantity;
eventData.Data.BeforePendingDividend = previous?.PosiDividendSum ?? eventData.Data.BeforePendingDividend;
eventData.Data.AfterNotional = current.PosiNotionalValue;
eventData.Data.AfterPrice = current.PosiGrossPrice;
eventData.Data.AfterQuantity = current.PosiQuantity;
eventData.Data.AfterPendingDividend = current.PosiDividendSum;
eventData.Data.CashFlowChange = current.RealizedDividend - (previous?.RealizedDividend ?? current.RealizedDividend);
eventData.Data.Applied = true;
eventData.Event.EventData = JsonConvert.SerializeObject(eventData.Data);
eventData.Event.EventReason = BuildCorporateActionReason(eventData.Data);
UpdateCorporateActionEventRecord(eventData.Event);
}
}
public static CorporateActionEventData BuildCorporateActionEventData(
ex_dividend_info info,
eod_swap_position previous,
eod_swap_position current,
bool applied)
{
return new CorporateActionEventData
{
ExDividendInfoId = info.id,
PositionId = (current ?? previous).PositionId,
UnderlyingCode = (current ?? previous).UnderlyingCode,
ExDividendDate = info.ExDividendDate,
EffectiveDate = info.EffectiveDate,
GiveCashAmount = info.GiveCashAmount,
GiveShareAmount = info.GiveShareAmount,
Split = info.Split,
RationedSharesAmount = info.RationedSharesAmount,
RationedSharesPrice = info.RationedSharesPrice,
BeforeNotional = previous?.PosiNotionalValue ?? 0m,
BeforePrice = previous?.PosiGrossPrice ?? 0m,
BeforeQuantity = previous?.PosiQuantity ?? 0m,
AfterNotional = applied ? current?.PosiNotionalValue ?? 0m : 0m,
AfterPrice = applied ? current?.PosiGrossPrice ?? 0m : 0m,
AfterQuantity = applied ? current?.PosiQuantity ?? 0m : 0m,
BeforePendingDividend = previous?.PosiDividendSum ?? 0m,
AfterPendingDividend = applied ? current?.PosiDividendSum ?? 0m : 0m,
CashFlowChange = applied ? (current?.RealizedDividend ?? 0m) - (previous?.RealizedDividend ?? 0m) : 0m,
Applied = applied,
};
}
public static bool ShouldCreateCorporateActionEvent(
IEnumerable<swap_event> events,
ex_dividend_info info,
long positionId)
{
if (info == null)
{
return false;
}
// 幂等键与收盘事件匹配保持一致,只认 ExDividendInfoId + PositionId。
// 无法反序列化或缺少 ExDividendInfoId 的存量事件均不参与匹配。
return !(events ?? Enumerable.Empty<swap_event>()).Any(x =>
{
if (!SwapEventService.TryDeserializeCorporateActionEventData(x, out var data))
{
return false;
}
return info.id > 0
&& data.ExDividendInfoId == info.id
&& data.PositionId == positionId;
});
}
private static CorporateActionEventData DeserializeCorporateActionEventData(string eventData)
{
if (string.IsNullOrWhiteSpace(eventData))
{
return null;
}
try
{
return JsonConvert.DeserializeObject<CorporateActionEventData>(eventData);
}
catch (JsonException)
{
return null;
}
}
private static string BuildCorporateActionReason(CorporateActionEventData data)
{
return SwapEventService.BuildCorporateActionEventReason(data);
}
/// <summary>
/// 兼容旧测试/扩展调用的直接现金分红辅助方法。
/// GiveCashAmount 按每 10 份金额计算:1000 份、每 10 份派 10,结果为 1000。
/// 现金分红在生效日 EOD 即执行,因此 PosiDividendSum 不增加本次金额,
/// 同时从除权价格变化产生的 PosiMtmPnL 中剥离,避免收益重复计算。
/// 当前生产 SwapPositionCompose 不再调用此方法:公司行为现金分红由同步任务
/// 写入 bond_payment_infoEffectiveDate 收盘通过 CalcBondPayment 进入 EOD
/// 以避免登记日提前入账及与债券付息链路重复。保留方法是为了不破坏已有测试替身
/// 或外部扩展类的编译契约;新增业务代码不得再直接传入 ex_dividend_info。
/// </summary>
protected void ApplyFundCashDividends(
IReadOnlyCollection<eod_swap_position> currentEodPositions,
@@ -827,7 +1227,6 @@ namespace YLErp.Modules.SwapModule
DateTime settleDate)
{
if (currentEodPositions == null
|| previousEodPositions == null
|| exDividendByCode == null
|| exDividendByCode.Count == 0)
{
@@ -835,20 +1234,21 @@ namespace YLErp.Modules.SwapModule
}
var dividendTaxRate = GetDividendTaxRate();
var previousList = previousEodPositions ?? Array.Empty<eod_swap_position>();
foreach (var current in currentEodPositions)
{
if (current == null
|| current.PosiDirection == 0
|| current.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund
|| !IsTrsCorporateActionInstrument(current.UnderlyingInstrumentType)
|| string.IsNullOrWhiteSpace(current.UnderlyingCode)
|| !exDividendByCode.TryGetValue(current.UnderlyingCode, out var dividendInfo)
|| !dividendInfo.EffectiveDate.HasValue
|| dividendInfo.EffectiveDate.Value.Date != settleDate.Date)
|| !dividendInfo.ExDividendDate.HasValue
|| dividendInfo.ExDividendDate.Value.Date != settleDate.Date)
{
continue;
}
var previous = previousEodPositions.FirstOrDefault(
var previous = previousList.FirstOrDefault(
x => x != null && x.PositionId == current.PositionId);
var entitlementQuantity = previous?.PosiQuantity ?? current.PosiQuantity;
var directionRatio = DirectionRatio.ReceivePay(current.PosiDirection);
@@ -859,18 +1259,16 @@ namespace YLErp.Modules.SwapModule
* directionRatio
: 0m;
// 当日浮动端分红
// 当日浮动端分红。公司行为现金分红采用现金模式:不调期初价格,
// 只增加待实现分红,支付日仍由既有 DealDividends/付息链路结算。
current.TdPosiDividend = RoundMoney(currentDividend);
var previousDividendSum = previous?.PosiDividendSum ?? 0m;
// 浮动端平仓盈亏·分红未实现 = 未实现分红总和 - 当日浮动端平仓盈亏·分红
// 浮动端平仓盈亏·分红未实现 = 前日待实现 + 当日公司行为分红
// - 当日已实现分红;本次公司行为尚未支付,因此不能写入 RealizedDividend。
current.PosiDividendSum = current.PosiQuantity > 0m
? RoundMoney(previousDividendSum - current.TdCloseDividend)
? RoundMoney(previousDividendSum + current.TdPosiDividend - current.TdCloseDividend)
: 0m;
// 浮动端平仓盈亏·盯市未实现 = 盯市未实现 - 当日浮动端分红
// current.PosiMtmPnL = RoundMoney(current.PosiMtmPnL - current.TdPosiDividend);
// 浮动端已实现·分红 = 已实现分红 + 当日浮动端分红
current.RealizedDividend = RoundMoney(current.RealizedDividend + current.TdPosiDividend);
// 浮动端已实现·盈亏 = 盈亏 + 当日浮动端分红
// 现金模式不从 PosiMtmPnL 剥离分红:价格没有被除权,分红只存在于待实现字段。
current.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum(
current.PosiMtmPnL,
current.PosiDividendSum,
@@ -883,10 +1281,12 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 将一条真实生效日公司行为应用到盘中实时 Fund 浮动腿。
/// 将一条真实生效日公司行为应用到盘中实时 TRS Stock/Fund 浮动腿。
/// 盘中先复制严格早于 valueDate 的 EOD,再调用此方法;因此重复调用时每次都会
/// 从同一份除权前 EOD 重新恢复,不会把 1000/100 重复变成 4000/25。
/// 例:8 月 14 日 EOD 为 1000/1008 月 17 日生效的 10 送 10 会得到 2000/50。
/// 现金模式调用公式时使用 adjustCashDividendPrice=false,现金权益只进入分红字段,
/// 不改变 Stock/Fund 的期初价格。
/// </summary>
public static bool ApplyFundCorporateActionToPosition(
swap_position position,
@@ -897,7 +1297,7 @@ namespace YLErp.Modules.SwapModule
if (position == null
|| dividendInfo == null
|| position.PosiDirection <= 0
|| position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund
|| !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType)
|| corporateActionClosePrice <= 0)
{
return false;
@@ -906,8 +1306,9 @@ namespace YLErp.Modules.SwapModule
var factors = DividendService.CalculateCorporateActionFactors(
dividendInfo,
corporateActionClosePrice,
dividendTaxRate);
if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0)
dividendTaxRate,
adjustCashDividendPrice: false);
if (factors.PriceRatio <= 0)
{
throw new InvalidOperationException(
$"Fund 标的【{position.UnderlyingCode}】计算得到无效除权系数");
@@ -915,7 +1316,7 @@ namespace YLErp.Modules.SwapModule
var originalQuantity = position.PosiQuantity;
position.PosiQuantity = Math.Round(
originalQuantity * factors.ShareFactor,
originalQuantity * factors.PriceRatio,
12,
MidpointRounding.AwayFromZero);
position.PosiGrossPrice = Math.Round(
@@ -1203,9 +1604,10 @@ namespace YLErp.Modules.SwapModule
var hasDividend = curEodPositions.Any(x => x.PosiDividendSum != 0);
if (!hasDividend) return;
// ApplyFundCorporateActions 已经把 Fund 的现金分红写入除权后的期初价格/名义本金;
// 这里处理的是持仓期间累计的付息/分红结算流水。两者同时把同一现金再写入
// PosiDividendSum 会重复实现,故公司行为步骤不会在此处直接填充该字段。
// 公司行为现金分红与债券付息共用既有待实现/支付链路:公司行为步骤只把金额
// 累加到 PosiDividendSum,这里仍按交易约定的 DividendPayDate 生成支付流水。
// 公司行为不会调整 Stock/Fund 的期初价格;因此不能再把现金分红从 PosiMtmPnL
// 中剥离或当作已实现收益提前写入。
var dividendPayDateOffset = tradeExtend?.ExtendObj?.DividendPayDate ?? 1;
if (dividendPayDateOffset <= 0) return;
+110 -3
View File
@@ -1,6 +1,7 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
@@ -206,14 +207,120 @@ namespace YLErp.Modules.SwapModule
return events;
}
/// <summary>
/// 获取交易操作历史
/// 获取交易操作历史。返回前会过滤掉登记日创建且尚未应用(Applied=false
/// 的公司行为事件,避免交易详情在真实调整前展示一条已完成历史。
/// </summary>
/// <param name="tradeId">交易id</param>
/// <returns></returns>
public List<swap_event> GetOpreationHistorys(int tradeId)
{
List<swap_event> list = DbContext.swap_event.Where(x => x.SwapTradeId == tradeId).OrderByDescending(o => o.id).ToList();
return list;
List<swap_event> list = DbContext.swap_event
.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;
}
/// <summary>
/// 将 swap_event.EventData 安全反序列化为公司行为快照。事件为空、EventData
/// 为空白或 JSON 格式不匹配时返回 false 并将 data 置 null,调用方据此保留旧格式记录。
/// </summary>
public static bool TryDeserializeCorporateActionEventData(
swap_event swapEvent,
out CorporateActionEventData data)
{
data = null;
if (swapEvent == null || string.IsNullOrWhiteSpace(swapEvent.EventData))
{
return false;
}
try
{
data = JsonConvert.DeserializeObject<CorporateActionEventData>(swapEvent.EventData);
return data != null;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
/// 公司行为说明使用稳定的键值格式,完整保留调整前后名义本金、价格、数量、
/// 待实现分红和现金流变化,操作历史无需重新计算即可核对。
/// </summary>
public static string BuildCorporateActionEventReason(CorporateActionEventData data)
{
if (data == null)
{
return "公司行为快照为空";
}
// 使用 InvariantCulture 固定小数与日期格式,说明文本不随服务器区域设置变化。
string D(decimal value) => value.ToString(CultureInfo.InvariantCulture);
string Date(DateTime? value) => value.HasValue
? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
: "";
return string.Join("; ", new[]
{
$"公司行为[{data.UnderlyingCode}]",
$"ExDividendDate={Date(data.ExDividendDate)}",
$"EffectiveDate={Date(data.EffectiveDate)}",
$"ExDividendInfoId={data.ExDividendInfoId}",
$"PositionId={data.PositionId}",
$"GiveCashAmount={D(data.GiveCashAmount)}",
$"GiveShareAmount={D(data.GiveShareAmount)}",
$"Split={(data.Split.HasValue ? D(data.Split.Value) : "")}",
$"RationedSharesAmount={D(data.RationedSharesAmount)}",
$"RationedSharesPrice={D(data.RationedSharesPrice)}",
"调整前",
$"BeforeNotional={D(data.BeforeNotional)}",
$"BeforePrice={D(data.BeforePrice)}",
$"BeforeQuantity={D(data.BeforeQuantity)}",
$"BeforePendingDividend={D(data.BeforePendingDividend)}",
"调整后",
$"AfterNotional={D(data.AfterNotional)}",
$"AfterPrice={D(data.AfterPrice)}",
$"AfterQuantity={D(data.AfterQuantity)}",
$"AfterPendingDividend={D(data.AfterPendingDividend)}",
$"CashFlowChange={D(data.CashFlowChange)}",
$"Applied={data.Applied}"
});
}
public void DeleteEvent(int tradeId)
@@ -1547,8 +1547,25 @@ namespace YLErp.Modules.SwapModule
{
swapEventService.DeleteExtensionTime(swapEvent.id);
}
var corporateActionEvents = DbContext.swap_event
.Where(x => !x.Invalid
&& x.SwapTradeId == tradeId
&& x.EventType == (int)SwapEventTypeEnum.
&& x.ValueDate >= valueDate)
.OrderByDescending(x => x.id)
.ToList();
InvalidTradeOptionDatasByDate(tradeId, valueDate, backToBegin);
swapEventService.AddSwapEventDate(valueDate, tradeId, (int)SwapEventTypeEnum.退, string.Empty, 0, false, $"交易回退至{valueDate:yyyy年MM月dd日}");
var rollbackEvent = swapEventService.AddSwapEventDate(
valueDate,
tradeId,
(int)SwapEventTypeEnum.退,
string.Empty,
0,
false,
$"交易回退至{valueDate:yyyy年MM月dd日}");
// 公司行为原事件保持有效作为不可篡改审计;回退事件通过 BackId 指向本次
// 回退影响的最新公司行为事件,后续重收盘会追加新的公司行为事件。
rollbackEvent.BackId = corporateActionEvents.FirstOrDefault()?.id ?? 0;
DbContext.SaveChanges();
if (del)
{
@@ -1762,6 +1779,12 @@ namespace YLErp.Modules.SwapModule
var firstConfirm = false;
swapEvents.ForEach(x =>
{
// 公司行为事件是不可篡改审计日志。回退只追加回退事件,不把原始公司
// 行为事件置无效;否则无法追溯交易曾经经历过的调整。
if (x.EventType == (int)SwapEventTypeEnum.)
{
return;
}
if (backToBegin && !firstConfirm && x.EventType == (int)SwapEventTypeEnum.)
{
firstConfirm = true;
@@ -748,44 +748,68 @@ namespace YLErp.Modules.TradeModule.DealModule
internal readonly struct CorporateActionFactors
{
public CorporateActionFactors(decimal priceRatio, decimal shareFactor)
public CorporateActionFactors(decimal priceRatio)
{
PriceRatio = priceRatio;
ShareFactor = shareFactor;
}
public decimal PriceRatio { get; }
public decimal ShareFactor { get; }
}
/// <summary>
/// 统一计算公司行为的价格系数和数量系数。价格系数沿用原股票除权公式;
/// 数量仅受送股/拆合股影响,配股仍只进入价格公式,保持现有业务口径不变。
/// 按 Excel 公式计算公司行为的除权系数。
/// GiveShareAmount 只表示每 10 份的送股数量,Split 表示独立的拆/合股倍数;
/// Split 为空按 1 兼容历史记录。TRS Stock/Fund 使用 PriceRatio 同时调整期初价格
/// 和持仓数量,不再维护独立的旧数量系数。
/// <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 的价格系数下调。
/// 现金分红不参与 TRS Stock/Fund 的期初价格公司行为系数;现金权益由既有分红流水单独处理。
/// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。
/// </para>
/// </summary>
internal static CorporateActionFactors CalculateCorporateActionFactors(
ex_dividend_info info,
decimal closePrice,
decimal dividendRate)
decimal dividendRate,
bool adjustCashDividendPrice = true)
{
// (收盘价 * 10) - 现金分红 * (1 - 税率) + (配股数量 * 配股价格)
// -------------------------------------------------------
// (10 + 送股数量 + 配股数量)
var exDividendPrice = (closePrice * 10m - info.GiveCashAmount * (1m - dividendRate)
// 价格调整模式除权参考价 =
// 收盘价 * 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);
/ (10m + info.GiveShareAmount + info.RationedSharesAmount))
/ splitFactor;
// 除权系数 = 股权登记日收盘价 / 除权除息参考价
var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice;
var shareFactor = 1m + info.GiveShareAmount / 10m;
return new CorporateActionFactors(priceRatio, shareFactor);
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>
@@ -798,10 +822,9 @@ namespace YLErp.Modules.TradeModule.DealModule
}
/**
*
* 10*closePrice / 10+GiveShareAmount
* = ×
* = ÷
* GiveShareAmount 10 Split / 1
* = × (1 + GiveShareAmount / 10) × Split
* = ÷
*/
private decimal GetRatioDecimal(ex_dividend_info info)
{
@@ -833,9 +856,12 @@ 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);
// 这是旧场内/兼容链路的数量接口;TRS Stock/Fund 不走这里,而是在
// SwapEodPositionService 中按 Excel公式 使用 PriceRatio。旧链路数量只按
// 送股和独立拆合股调整,现金分红和配股不增加持仓数量。
var result = (decimal)amount
* (1m + info.GiveShareAmount / 10m)
* GetSplitFactor(info);
return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero);
}
@@ -900,6 +926,7 @@ namespace YLErp.Modules.TradeModule.DealModule
: (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,
@@ -922,6 +949,10 @@ namespace YLErp.Modules.TradeModule.DealModule
{
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}行真实除权日不应早于股权登记日");
@@ -985,6 +1016,11 @@ 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 是日期语义,导入/接口可能带时分秒;统一只保留自然日。
@@ -1026,6 +1062,11 @@ namespace YLErp.Modules.TradeModule.DealModule
errMsg = "股权登记日信息不存在";
return false;
}
if (item.Split.HasValue && item.Split.Value <= 0m)
{
errMsg = "拆/合股倍数必须大于0";
return false;
}
// 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间
// 能命中同一个自然日业务键,也与数据库的一行模型保持一致。
@@ -1049,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);
}
// 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录
// 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。
@@ -1177,6 +1224,37 @@ 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;
}
}
}
var eodStatus = DbContext.eodStatus.Where(O => O.ValueDate == info.ExDividendDate && O.OptDate > info.OptDate).Any();
if (eodStatus)
{
@@ -52,6 +52,10 @@ namespace YLErp.Web.Controllers
{
throw new FormatException("标的代码、股权登记日或真实除权日信息不存在!");
}
if (info.Split.HasValue && info.Split.Value <= 0m)
{
throw new FormatException("拆/合股倍数必须大于0!");
}
if (QdpCalendarHelper.IsHoliday(info.ExDividendDate.Value))
{
throw new FormatException("股权登记日不应为非交易日!");
@@ -16,14 +16,14 @@
<col span="1" width="150" />
<col span="1" width="120" />
<col span="1" />
<col span="1" width="300" />
<col span="1" width="420" />
</colgroup>
<thead>
<tr>
<th>操作时间</th>
<th>操作人</th>
<th class="text-left" style="width:10%">操作内容</th>
<th class="text-left" style="width:50%">说明</th>
<th class="text-left" style="width:50%">说明(含公司行为前后要素)</th>
</tr>
</thead>
<tbody>
@@ -31,7 +31,7 @@
<td>{{dateFormat(item.OptTime,'YYYY-MM-DD HH:mm:ss')}}</td>
<td>{{item.OptName}}</td>
<td>{{item.EventTypeName}}</td>
<td>{{item.EventReason}}</td>
<td style="white-space:pre-line">{{item.EventReason}}</td>
</tr>
</tbody>
</table>
@@ -21,6 +21,7 @@ function saveInfo(dataId, rowId) {
EffectiveDate: $("#" + rowId + "_EffectiveDate").val(),
GiveCashAmount: $("#" + rowId + "_GiveCashAmount").val(),
GiveShareAmount: $("#" + rowId + "_GiveShareAmount").val(),
Split: $("#" + rowId + "_Split").val(),
ConversionShareAmount: $("#" + rowId + "_ConversionShareAmount").val(),
RationedSharesAmount: $("#" + rowId + "_RationedSharesAmount").val(),
RationedSharesPrice: $("#" + rowId + "_RationedSharesPrice").val()
@@ -80,6 +81,7 @@ function gridComplete(obj) {
EffectiveDate: null,
GiveCashAmount: 0.0,
GiveShareAmount: 0,
Split: 1,
RationedSharesAmount: 0,
RationedSharesPrice: 0,
OptName: null,
@@ -115,6 +117,8 @@ var colModelGrid = [{
name: 'GiveCashAmount', label: '派息金额(10股)', index: 'GiveCashAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true },
}, {
name: 'GiveShareAmount', label: '送股股数(10股)', index: 'GiveShareAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true },
}, {
name: 'Split', label: '拆/合股倍数', index: 'Split', width: 100, formatter: { number: { decimalPlaces: 6, defaultValue: '1' } }, editable: true, editrules: { number: true },
}, {
name: 'RationedSharesAmount', label: '配股股数(10股)', index: 'RationedSharesAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true },
}, {