feat(bond): 支持债券与股票基金公司行为现金流计算的差异化处理 - init2
- 修改 CalcPayment 方法添加 useBondPriceScale 参数区分债券和股票/基金的金额计算口径 - 债券利息按每100元面值票息通过BondPriceConverter转为入库金额,股票基金分红直接计算 - 在BondPaymentService中添加详细的参数说明文档注释 - 更新SwapDealService中分红计算逻辑,根据标的类型自动选择合适的金额转换方式 - 新增CorporateActionEventLifecycleTest单元测试验证公司行为事件生命周期管理 - 添加SplitCorporateActionTddTest测试验证拆合股功能 - 优化FundCorporateActionRollbackAndUnwindTest扩展到股票类型测试 - 更新前端OperationHistory页面表格列宽和显示格式支持更长的说明信息
This commit is contained in:
@@ -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 = 3:100 份/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-N:L=(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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
// 债券付息按每百元票息存储,继续走 BondPriceConverter;Stock/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_info,Copy/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 / M,Q' = 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* 来自
|
||||
// 调整前 EOD,After* 来自生效日当前 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_info,EffectiveDate 收盘通过 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/100,8 月 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;
|
||||
|
||||
@@ -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=2,ShareFactor=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)
|
||||
{
|
||||
|
||||
Binary file not shown.
@@ -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 },
|
||||
}, {
|
||||
|
||||
Reference in New Issue
Block a user