Merge remote-tracking branch 'origin/glms/feature/1.4.2' into glms/feature/1.4.2

# Conflicts:
#	UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs
This commit is contained in:
tengyufan
2026-08-17 10:00:00 +08:00
63 changed files with 1748 additions and 1179 deletions
@@ -1,29 +0,0 @@
namespace YLErp.Core.Interest;
/// <summary>
/// 计息执行上下文:把"与具体金额/利率无关"的横向参数(年化天数、精度、trace 收集器)
/// 打包成一个<b>只读值对象</b>,避免每个计息方法都重复携带这些参数。
///
/// <para><b>为何 trace 是"成员"而非散落参数</b>:利息纯函数(AccrueSimple / AccrueCompoundInArrears
/// 的核心职责是算账,trace 只是可观测性的旁路。把 trace 作为上下文的成员传入,
/// 调用点只需传一个 ctx,签名更干净;同时 ctx 是只读值对象,不破坏纯函数
/// (无共享可变状态 → 线程安全、可重入、可测)。<b>切勿</b>把 trace 设成类的实例/静态字段,
/// 那会让并发的两笔交易共用同一 trace、并使函数带隐藏状态。</para>
///
/// <para>与 AccrualState(跨日滚动本金状态)/ AccrualPolicyEOD 会计政策)正交:
/// 本上下文只描述"如何算 + 往哪记",不持有任何交易进度。</para>
/// </summary>
public readonly struct AccrualContext
{
/// <summary>年化天数(365 / 360)。</summary>
public int AnnualDays { get; }
/// <summary>舍入精度位数。默认 11(生产融资腿/保证金腿均显式传入 FundingLegPrecision=12)。</summary>
public int Precision { get; }
/// <summary>可选 trace 收集器;为 null 时不记录(纯计算场景直接传 null,与开关无关)。</summary>
public AccrualTrace? Trace { get; }
public AccrualContext(int annualDays, int precision = 11, AccrualTrace? trace = null)
=> (AnnualDays, Precision, Trace) = (annualDays, precision, trace);
}
@@ -1,71 +0,0 @@
using System;
namespace YLErp.Core.Interest;
/// <summary>
/// 利率 + 计息方式(单利 / 复利 / 连续复利)。
///
/// <para><b>通用金融原语,与互换、衍生品、任何具体业务均无耦合</b>——谁需要算利息都能用。
/// 利息计算不是互换特有的,所以它不住在 SwapModule,也不带任何 swap 词汇。</para>
///
/// <para>用法(年化时间 t,如 30天/365):</para>
/// <list type="bullet">
/// <item><description>计息因子 = <see cref="CompoundFactor(decimal)"/>;含息额 = 本金 × 因子;</description></item>
/// <item><description>利息 = 本金 × (因子 1) = <see cref="Interest(decimal, decimal)"/>。</description></item>
/// </list>
///
/// <para>与 QuantLib 模型一致:单利 / 复利 / 连续复利只是 <see cref="Compounding"/> 的一个分支,
/// 不是三套独立方法。TRS 的"重置日并本金"属于离散复利,用 <see cref="Compounding.Simple"/>
/// 按段计息、段末把利息滚入本金即可(见 SwapInterest.AccrueCompoundInArrears),无需 Pow/Expdecimal 精度无损。</para>
///
/// <para>互换特有的会计态(每日先舍入再乘天数、平仓缩放、跨日滚动本金)不属于本原语,
/// 请在各自的 accrual 层处理。</para>
/// </summary>
public enum Compounding
{
/// <summary>单利:因子 = 1 + r·t。</summary>
Simple,
/// <summary>复利(理想化闭式):因子 = (1 + r/f)^(f·t)f 为年复利频次。</summary>
Compounded,
/// <summary>连续复利:因子 = e^(r·t)。</summary>
Continuous
}
/// <summary>
/// 不可变利率值对象。构造即完整,无副作用。
/// </summary>
public readonly struct InterestRate
{
/// <summary>年化利率 r。</summary>
public decimal Rate { get; }
/// <summary>计息方式。</summary>
public Compounding Compounding { get; }
/// <summary>年复利频次(仅 <see cref="Compounding.Compounded"/> 使用,其余忽略,默认 1)。</summary>
public int Frequency { get; }
public InterestRate(decimal rate, Compounding compounding, int frequency = 1)
=> (Rate, Compounding, Frequency) = (rate, compounding, frequency);
/// <summary>
/// 计息因子(输入年化时间 t)。
/// <list type="bullet">
/// <item><description><see cref="Compounding.Simple"/>decimal 精确运算。</description></item>
/// <item><description><see cref="Compounding.Compounded"/> / <see cref="Compounding.Continuous"/>:闭式(double 计算后回 decimal),
/// 满足通用定价;若要 decimal 精度的离散重置日复利,请用 Simple 按段计息并滚动本金。</description></item>
/// </list>
/// </summary>
public decimal CompoundFactor(decimal t)
=> Compounding switch
{
Compounding.Simple => 1m + Rate * t,
Compounding.Compounded => (decimal)Math.Pow((double)(1m + Rate / Frequency), (double)(Frequency * t)),
Compounding.Continuous => (decimal)Math.Exp((double)(Rate * t)),
_ => throw new ArgumentOutOfRangeException(nameof(Compounding))
};
/// <summary>利息 = 本金 × (因子 1)。</summary>
public decimal Interest(decimal principal, decimal t)
=> principal * (CompoundFactor(t) - 1m);
}
@@ -1,291 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using YLErp.Core.Interest;
namespace YLErp.Derivatives.Interest;
// ─────────────────────────────────────────────────────────────────────────────
// 词汇表(本文件只允许出现下列用词,同一概念不得出现第二种叫法)
//
// 概念 唯一用词 与既有代码的对应
// ───────────────────────────────────────────────────────────────────
// 区间起点/终点 Start / End startDate / endDate
// 计息 Accrue CalcDailySimpleInterest / CalcDailyCompoundInterest
// 平仓 Unwind unwindPercent(既有字段 closePercent
// 已实现利息 Realized realizedInterestlegacy 字段 consumedInterest
// 待实现收益 Unrealized 预付金模式下的待实现收益余额
// 计息基数 principal principal / dynomicPrincipal
// 年化天数 annualDays tradeExtend.ExtendObj.AnnualDays
//
// 入参一律沿用既有代码的字段名,调用点两边读起来同名,不产生心智翻译成本。
// 出参改用自描述名(Accrued / AccruedToday),因为 "Td" 对新读者是黑话。
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// 计息区间边界(算头 / 算尾)。
/// 用具名值取代两个相邻 bool,物理上杜绝 calcFirst / calcLast 传反这一类历史缺陷。
/// </summary>
public readonly struct AccrualBoundary
{
/// <summary>算头:含 startDate。</summary>
public bool IncludeStart { get; }
/// <summary>算尾:含 endDate。</summary>
public bool IncludeEnd { get; }
private AccrualBoundary(bool includeStart, bool includeEnd)
=> (IncludeStart, IncludeEnd) = (includeStart, includeEnd);
/// <summary>算头算尾 [start, end]。</summary>
public static readonly AccrualBoundary Both = new(true, true);
/// <summary>算头不算尾 [start, end)。</summary>
public static readonly AccrualBoundary StartOnly = new(true, false);
/// <summary>不算头算尾 (start, end]。</summary>
public static readonly AccrualBoundary EndOnly = new(false, true);
/// <summary>不算头不算尾 (start, end)。</summary>
public static readonly AccrualBoundary None = new(false, false);
/// <summary>由既有 calcFirst / calcLast 布尔对构造,供旧调用方渐进迁移。</summary>
public static AccrualBoundary Of(bool includeStart, bool includeEnd) => new(includeStart, includeEnd);
public override string ToString()
=> $"{(IncludeStart ? "" : "")}{(IncludeEnd ? "" : "")}";
}
/// <summary>
/// 计息结果。Accrued → 记账字段 InterestAmount / InterestProfitSumAccruedToday → TdInterestAmount。
/// </summary>
public readonly struct InterestResult
{
/// <summary>区间累计应计利息。</summary>
public decimal Accrued { get; }
/// <summary>末日(当日)应计利息。</summary>
public decimal AccruedToday { get; }
public InterestResult(decimal accrued, decimal accruedToday)
=> (Accrued, AccruedToday) = (accrued, accruedToday);
public static readonly InterestResult Zero = new(0m, 0m);
public override string ToString() => $"Accrued={Accrued}, AccruedToday={AccruedToday}";
}
/// <summary>
/// 收益互换(TRS)利息腿计算——纯函数。
///
/// <para><b>层级关系</b>:计息数学(单利/复利/连续复利)是通用金融原语,已抽到
/// <see cref="InterestRate"/><c>YLErp.Core.Interest</c>,与互换无关,谁都能用)。
/// 本类只负责 TRS 特有的<b>会计态</b>:每日先舍入再乘天数的对账口径、平仓缩放、
/// 跨日滚动本金、预付金/授信模式——这些不是"利率数学",不应塞进通用原语。</para>
///
/// <para>设计约束:
/// 1. 无副作用——不读写 flowEvent、不取利率、不连库、不碰任何共享可变状态;
/// 2. 同 input → 同 output,结果仅通过返回值流出;
/// 3. 正交轴(算头算尾 / 单利复利 / 平仓 / 待实现收益)各自独立,互不耦合;
/// 4. 调用方负责「取利率 + 构造日期区间 + 落库」,本类只算账。
/// 由此,corp action 调整价格 / 数量时只需把新的 principal 与 rate 喂入,计息逻辑一行不动。</para>
///
/// <para>领域口径:本系统利息腿是单边融资腿,任一时点只有一个生效利率(见 SwapDealService 的
/// floateRate 单一入参),<b>不存在</b> IRS 那种 fixedRate floatingRate 轧差;
/// 权益腿盈亏与平仓费用属三腿汇总层,不在本类职责内。</para>
///
/// <para>TRS 的"复利"是<b>离散重置日复利</b>:按重置日切段,每段用 <see cref="InterestRate.Simple"/>
/// 计息、段末把利息滚入本金——本质就是单利按段叠加,decimal 精度无损,无需 Pow/Exp
/// (见 <see cref="AccrueCompoundInArrears"/>)。所以本类不另立复利方法,计息只有一种,区别在于"是否滚动本金"。</para>
///
/// 为何不复用 Qdp 的 IDayCount
/// a. 语义——Qdp 的 DaysInPeriod = end start 是写死的半开区间,只能表达四种算头算尾中的一种;
/// b. 精度——Qdp 返回 double 年化系数,本系统 decimal 且日息先 Round 再乘天数,
/// Round(P*r/365, 11) * n ≠ P*r*(n/365),与 Excel 对账口径不同;
/// c. 依赖方向——Qdp 用自有 Date 类型,引入会让 YLErp.Core 反向依赖定价库。
/// </summary>
public static class SwapInterest
{
/// <summary>默认舍入精度位数(历史值;生产融资腿与保证金腿均用 FundingLegPrecision=12)。</summary>
public const int Precision = 11;
/// <summary>资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。
/// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。</summary>
public const int FundingLegPrecision = 12;
/// <summary>年化天数常量(合约字段存的是 int,故不用 enum)。</summary>
public const int Act365 = 365;
public const int Act360 = 360;
/// <summary>应计天数。边界规则由日期区间表达,计息函数内不再出现 flag 分支。</summary>
public static int AccrualDays(DateTime startDate, DateTime endDate, AccrualBoundary boundary)
{
var s = boundary.IncludeStart ? startDate : startDate.AddDays(1);
var e = boundary.IncludeEnd ? endDate : endDate.AddDays(-1);
var days = (int)(e - s).TotalDays + 1; // 含两端
return days < 0 ? 0 : days;
}
/// <summary>把 TRS 年化利率收敛为通用利率原语。
/// TRS 计息按段均为单利——离散重置日复利靠"段末把利息滚入本金"实现,不引入 Compounded 闭式。</summary>
public static InterestRate ToInterestRate(decimal annualRate)
=> new(annualRate, Compounding.Simple);
/// <summary>单利:计息基数固定,每日利息相同,无逐日循环。</summary>
public static InterestResult AccrueSimple(
AccrualContext ctx,
decimal principal,
decimal rate,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary)
{
var days = AccrualDays(startDate, endDate, boundary);
var daily = Round(principal * rate / ctx.AnnualDays, ctx.Precision);
return new InterestResult(Round(daily * days, ctx.Precision), daily);
}
/// <summary>
/// 离散重置日<b>复利(compounded-in-arrears</b>:按重置日切段,段间把累计利息并入计息基数(滚动本金)。
/// 每段计息即 <see cref="ToInterestRate"/> 得到的 <see cref="InterestRate.Simple"/>(无逐日循环);
/// 重置日是唯一并本金的地方。复利与单利只有"是否滚动本金"这一个区别。
///
/// <para>此模型即 OIS / SOFR / FR007 的 <b>compounded-in-arrears</b>:每个子区间取一次定盘 rᵢ、增长因子
/// 1 + rᵢ·yfᵢ,段末把 accrued 折进下一期本金——比闭式 <see cref="InterestRate.Compounding.Compounded"/>
/// 更贴合 FR007 约定且 decimal 无损。<b>注意:它<b>不是</b> InterestRate 的 Compounded 闭式分支(TRS 下该分支为死路径)。</para>
///
/// <para>每段可有<b>独立利率</b>FR007 浮动逐段不同),由适配器按段取定盘后封装为
/// <paramref name="resetSchedule"/> 传入——取价永远在编排层,原语只吃一个数(与 QuantLib/Strata 同范)。
/// <paramref name="resetSchedule"/> 必须含一条 <c>ResetDate ≤ startDate</c> 的起始利率。</para>
///
/// <para>trace:经 <see cref="AccrualContext.Trace"/> 发射 Start / ResetBefore·ResetAfter(利率切换时) /
/// Rollover(段末并本金) / End,完整记录"重置日前后、利率切换、本金增加前后"。纯函数保持无日志依赖。</para>
/// </summary>
/// <param name="resetSchedule">重置日 → 该段生效利率(段起点 = 重置日)。</param>
public static InterestResult AccrueCompoundInArrears(
AccrualContext ctx,
decimal principal,
IReadOnlyList<(DateTime ResetDate, decimal Rate)> resetSchedule,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary)
{
var trace = ctx.Trace;
trace?.MarkStart(startDate, endDate, boundary, ctx.AnnualDays, annualized: false);
var basis = principal;
decimal accrued = 0m, accruedToday = 0m;
var segEnds = (resetSchedule ?? Array.Empty<(DateTime, decimal)>())
.Select(s => s.ResetDate)
.Where(d => d > startDate && d < endDate)
.OrderBy(d => d)
.Append(endDate)
.ToArray();
// 段起点生效利率:取"不晚于该段起点"的最近一次重置利率。
decimal RateAt(DateTime segStart)
=> (resetSchedule ?? Array.Empty<(DateTime, decimal)>())
.Where(s => s.ResetDate <= segStart)
.OrderByDescending(s => s.ResetDate)
.Select(s => s.Rate)
.FirstOrDefault();
var segStart = startDate;
var segIncludeStart = boundary.IncludeStart;
var prevRate = RateAt(startDate);
foreach (var segEnd in segEnds)
{
var segRate = RateAt(segStart);
var rateSwitched = segStart != startDate && segRate != prevRate;
if (rateSwitched) trace?.ResetBefore(segStart, prevRate, basis);
var segBoundary = AccrualBoundary.Of(segIncludeStart, segEnd == endDate && boundary.IncludeEnd);
var seg = AccrueSimple(ctx, basis, segRate, segStart, segEnd, segBoundary);
accrued += seg.Accrued;
accruedToday = seg.AccruedToday;
var newBasis = basis + seg.Accrued; // 仅在重置日并本金
// 重置日本身不动本金:RESET↑ 的本金应是"重置边界基数"(basis),与 RESET↓ 一致;
// 段末并本金后的 newBasis 由下方的 ROLLOVER 单独表达,避免重复/误导。
if (rateSwitched) trace?.ResetAfter(segStart, segRate, basis);
trace?.Rollover(segEnd, seg.Accrued, newBasis);
basis = newBasis;
prevRate = segRate;
segStart = segEnd;
segIncludeStart = false; // 后续段不算头
}
var result = new InterestResult(accrued, accruedToday);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
/// <summary>
/// 固定利率复利便捷重载(每段同一 rate),向后兼容旧调用方。
/// 内部把 resetDates 展平为"每段同率"的 schedule 后委托主方法。
/// </summary>
public static InterestResult AccrueCompoundInArrears(
AccrualContext ctx,
decimal principal,
decimal rate,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary,
IReadOnlyList<DateTime>? resetDates = null)
{
var schedule = new List<(DateTime, decimal)> { (startDate, rate) };
if (resetDates != null)
foreach (var d in resetDates)
if (d > startDate && d < endDate)
schedule.Add((d, rate));
return AccrueCompoundInArrears(ctx, principal, schedule, startDate, endDate, boundary);
}
/// <summary>
/// 平仓(Unwind)缩放——全仓唯一缩放点,物理上杜绝 unwindPercent 被重复相乘。
/// 全平即 unwindPercent = 1,不另设方法。
///
/// 已实现 / 未实现边界:传入的 <paramref name="accrued"/> 是平仓前仍「未实现(unrealized)」的
/// 累计应计利息;本方法按比例缩放后返回「平仓后剩余未实现」部分,并扣除历史累计「已实现(realized)」
/// 的 <paramref name="realizedInterest"/>。被平仓比例 unwindPercent 对应的那一份 accrued
/// 即在此刻「实现(realized)」,由调用方记入 realizedInterest。
/// </summary>
/// <param name="accrued">平仓前累计应计利息(未实现)。</param>
/// <param name="unwindPercent">
/// 平仓比例(0~1,实为 ratio 非百分数)。
/// 对应既有字段 closePercent;分母口径必须与传入 <paramref name="accrued"/> 所依据的持仓数量一致——
/// 是「本次计算依据的持仓」而非「初始建仓」,历史缺陷正来自这个歧义。
/// </param>
/// <param name="realizedInterest">已实现利息累计(legacy 字段 consumedInterest):历史各次 unwind 已确认、应从剩余未实现中扣除的部分。</param>
/// <param name="precision">舍入精度。⚠️ 默认 11(Precision),资金腿务必显式传 <see cref="FundingLegPrecision"/>=12。</param>
public static InterestResult ApplyUnwind(
InterestResult accrued,
decimal unwindPercent,
decimal realizedInterest = 0m,
int precision = Precision)
{
var remaining = 1m - unwindPercent;
return new InterestResult(
Round(accrued.Accrued * remaining - realizedInterest, precision),
Round(accrued.AccruedToday * remaining, precision));
}
/// <summary>待实现收益余额滚动(预付金 / 授信模式)。</summary>
/// <param name="openingUnrealized">上期待实现收益余额。</param>
/// <param name="todayIncome">本期新增。</param>
/// <param name="unwindDeduction">本期 unwind 应扣减(即本期实现的份额)。</param>
public static decimal AccrueUnrealized(
decimal openingUnrealized,
decimal todayIncome,
decimal unwindDeduction,
int precision = Precision)
=> Round(openingUnrealized + todayIncome - unwindDeduction, precision);
/// <summary>统一舍入:MidpointRounding.AwayFromZero。所有计息路径收口到此处,避免散落的 Math.Round 不一致。</summary>
public static decimal Round(decimal value, int precision)
=> Math.Round(value, precision, MidpointRounding.AwayFromZero);
}
@@ -0,0 +1,68 @@
using System;
using System.IO;
namespace YLErp.Modules.EodModule
{
[TestClass]
public class DividendBasketQueryTranslationTest
{
[TestMethod]
public void DividendBasketQueriesUseEfTranslatableCommodityCondition()
{
var source = ReadDividendServiceSource();
var addDividendQuery = ExtractQuery(
source,
"var basketList =",
"IEnumerable<eod_stock_price> priceList = null;");
var executeStatusQuery = ExtractQuery(
source,
"var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(",
").Select(O => O.UnderlyingCode).ToArray();");
AssertQueryUsesCommodityCondition(addDividendQuery, "AddDividendInfos");
AssertQueryUsesCommodityCondition(executeStatusQuery, "checkDividendInfoExecuteStatus");
}
private static void AssertQueryUsesCommodityCondition(string query, string methodName)
{
Assert.IsFalse(
query.Contains("IsBasket()", StringComparison.Ordinal),
$"{methodName} must not put IsBasket() in an IQueryable predicate.");
Assert.IsTrue(
query.Contains("O.CommodityCode == \"篮子标的\"", StringComparison.Ordinal),
$"{methodName} must filter baskets with the EF-translatable CommodityCode condition.");
}
private static string ExtractQuery(string source, string startMarker, string endMarker)
{
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.IsTrue(start >= 0, $"Could not find query marker: {startMarker}");
var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
Assert.IsTrue(end >= 0, $"Could not find query end marker: {endMarker}");
return source.Substring(start, end + endMarker.Length - start);
}
private static string ReadDividendServiceSource()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null)
{
var path = Path.Combine(
directory.FullName,
"YLErpDAL",
"Modules",
"TradeModule",
"DealModule",
"DividendService.cs");
if (File.Exists(path))
{
return File.ReadAllText(path);
}
directory = directory.Parent;
}
Assert.Fail("Could not locate DividendService.cs from the test output directory.");
return string.Empty;
}
}
}
@@ -123,7 +123,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "002043.SZ",
ExDividendDate = new DateTime(2020, 7, 6),
GiveCashAmount = 2.5,
GiveCashAmount = 2.5m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
@@ -135,7 +135,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "600406.SH",
ExDividendDate = new DateTime(2020, 7, 8),
GiveCashAmount = 2.9,
GiveCashAmount = 2.9m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
@@ -147,7 +147,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "600406.SH",
ExDividendDate = new DateTime(2020, 7, 8),
GiveCashAmount = 2.9,
GiveCashAmount = 2.9m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
@@ -159,7 +159,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "601021.SH",
ExDividendDate = new DateTime(2020, 7, 8),
GiveCashAmount = 2.0006,
GiveCashAmount = 2.0006m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
@@ -171,7 +171,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "300001.SZ",
ExDividendDate = new DateTime(2020, 7, 13),
GiveCashAmount = 0.2,
GiveCashAmount = 0.2m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
@@ -7,8 +7,6 @@ using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Derivatives.Interest;
using YLErp.Core.Interest;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
@@ -1,6 +1,5 @@
using Newtonsoft.Json;
using YLErp;
using YLErp.Derivatives.Interest;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
@@ -1,144 +0,0 @@
using System.Text.RegularExpressions;
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// 聚焦测试:AccrueCompoundInArrears 的「本金滚存时机」必须符合确认书规定。
/// 核心不变量:本金只允许在重置日/段末滚入利息,非重置日不得资本化。
///
/// 与原草稿的关键区别:本版<b>直接通过 AccrualTrace 断言不变量</b>。
/// 真实实现在每次段末会发出 ROLLOVER 事件并记录 newBasis(见 SwapInterest.cs:215 /
/// AccrualTrace.Rollover),因此「非重置日是否发生资本化」是可程序化验证的,
/// 无需仅靠总利息回归来保护(原草稿的自我怀疑"无法断言计息基数"已不成立)。
/// </summary>
[TestClass]
public class SwapInterest_CompoundInArrears_RolloverTimingTests
{
private const int FundingLegPrecision = 12;
private const int AnnualDays = 365;
/// <summary>
/// 场景:14天窗口,第8天(01-08)重置一次,利率恒定 3.65%(日利率 0.01%)。
/// 验证:
/// (1) 总利息 = 1400.49(第1期700 + 第2期700.49);
/// (2) ROLLOVER 仅发生在重置日(01-08)与窗口终点(01-15),非重置日(如01-03)绝不滚存;
/// (3) 重置日 ROLLOVER 的 newBasis = 原始本金 + 前7天利息 = 1,000,700
/// 证明第1段计息基数恒为原始本金、段内未提前资本化。
/// </summary>
[TestMethod]
public void InterestPrincipal_ShouldRollOnlyOnResetDays_NotOnNonResetDays()
{
var startDate = new DateTime(2026, 1, 1);
var endDate = new DateTime(2026, 1, 15);
var principal = 1_000_000m;
var rate = 0.0365m;
var resetDates = new List<DateTime> { new DateTime(2026, 1, 8) };
var trace = new AccrualTrace();
var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace);
var result = SwapInterest.AccrueCompoundInArrears(
ctx,
principal,
rate,
startDate,
endDate,
AccrualBoundary.Both,
resetDates);
Assert.AreEqual(1400.49m, Math.Round(result.Accrued, 2));
var rolloverDates = trace.Entries
.Where(e => e.Step == AccrualTraceEvent.Rollover)
.Select(e => e.Date)
.ToList();
var allowed = resetDates.Concat(new[] { endDate }).OrderBy(d => d).ToList();
CollectionAssert.AreEqual(allowed, rolloverDates.OrderBy(d => d).ToList());
Assert.IsFalse(rolloverDates.Contains(new DateTime(2026, 1, 3)),
"非重置日发生了本金滚存,违反确认书规定");
var resetRollover = trace.Entries
.First(e => e.Step == AccrualTraceEvent.Rollover && e.Date == new DateTime(2026, 1, 8));
var newBasis = ParseNewBasis(resetRollover.Line);
Assert.AreEqual(principal + 700m, newBasis,
"重置日滚入的本金应为原始本金 + 前段利息,证明段内未提前资本化");
}
/// <summary>
/// 极端场景:startDate = endDate1天),无重置日。
/// 期望利息 = 本金 × 日利率 = 1,000,000 × 0.0365/365 = 100。
/// 且唯一 ROLLOVER 必须落在窗口终点(=startDate),无任何内部重置滚存。
/// </summary>
[TestMethod]
public void SingleDay_ShouldNotRollInterest_NoResetDay()
{
var date = new DateTime(2026, 1, 1);
var principal = 1_000_000m;
var rate = 0.0365m;
var trace = new AccrualTrace();
var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace);
var result = SwapInterest.AccrueCompoundInArrears(
ctx,
principal,
rate,
date,
date,
AccrualBoundary.Both);
Assert.AreEqual(100m, Math.Round(result.Accrued, 2));
var rolloverDates = trace.Entries
.Where(e => e.Step == AccrualTraceEvent.Rollover)
.Select(e => e.Date)
.ToList();
CollectionAssert.AreEqual(new[] { date }, rolloverDates.ToArray());
}
/// <summary>
/// 段内无重置日:验证整段等同于单利,且不发生任何内部滚存。
/// 6天窗口(01-01..01-06)在7天重置周期内,Both 边界含两端 = 6 个计息日,
/// 期望利息 = 本金 × 日利率 × 6 = 600。
/// </summary>
[TestMethod]
public void WithinPeriod_NoRollover_ShouldMatchSimpleInterest()
{
var startDate = new DateTime(2026, 1, 1);
var endDate = new DateTime(2026, 1, 6);
var principal = 1_000_000m;
var rate = 0.0365m;
var trace = new AccrualTrace();
var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace);
var result = SwapInterest.AccrueCompoundInArrears(
ctx,
principal,
rate,
startDate,
endDate,
AccrualBoundary.Both);
// 计息天数必须用边界感知的 AccrualDays,不能拿 (end-start).Days(会少算1天)
var days = SwapInterest.AccrualDays(startDate, endDate, AccrualBoundary.Both); // = 6
var expected = Math.Round(principal * rate * days / AnnualDays, FundingLegPrecision, MidpointRounding.AwayFromZero);
Assert.AreEqual(expected, Math.Round(result.Accrued, 10));
var rolloverDates = trace.Entries
.Where(e => e.Step == AccrualTraceEvent.Rollover)
.Select(e => e.Date)
.ToList();
CollectionAssert.AreEqual(new[] { endDate }, rolloverDates.ToArray());
}
private static decimal ParseNewBasis(string line)
{
var m = Regex.Match(line, @"newBasis=([0-9.]+)");
Assert.IsTrue(m.Success, $"ROLLOVER 行缺少 newBasis{line}");
return decimal.Parse(m.Groups[1].Value);
}
}
}
@@ -0,0 +1,69 @@
using YLErp.Modules.EodModule;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 自动平仓路径(AuotoSwapUnwind → EnrichDividendIn, SwapDealService.cs:1668-1687)多次部分平仓是否多算的实证。
/// EnrichDividendIn 核心:GetBondPayments(td.StartDate, closeDate) × unwindQty(当次平仓量,非剩余持仓)。
/// 本测试直接驱动真实 BondPaymentService.CalcPayment(与 EnrichDividendIn 等价:GetBondPayments 按 reg_date 过滤 + CalcPayment × unwindQty),
/// 内存注入 reg_date 数据,不连库。完整 AuotoSwapUnwind 链路因 EnrichDividendIn 直接 new BondPaymentService 查库、无内存 seam 注入点,故用计算核心等价验证。
///
/// 结论验证:多次跨越登记日的部分平仓,每次 × 当次平仓量 → 总额 = 各批按登记日持有 × 平仓量分摊,
/// 不自洽多算、不重复计入重叠窗口。
/// (纠正此前"从建仓日重算导致重复计入"的推断:该推断误以为 CalcPayment 乘剩余持仓,实际乘当次 unwindQty。)
/// </summary>
[TestClass]
public class AutoUnwindMultiPartialDividendTest
{
private const string BondCode = "230004.IB";
private static readonly DateTime StartDate = new(2026, 1, 5);
private static readonly DateTime Reg1 = new(2026, 5, 15); // 每百元付息 10
private static readonly DateTime Reg2 = new(2026, 6, 15); // 每百元付息 12
private sealed class BridgeBps : BondPaymentService
{
public BridgeBps(OptUserInfo u) : base(u) { }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> new List<BondPayment>
{
new BondPayment { underlyingCode = BondCode, reg_date = Reg1, payment_date_pl = Reg1, payment_date = Reg1, payment_interest = 10m },
new BondPayment { underlyingCode = BondCode, reg_date = Reg2, payment_date_pl = Reg2, payment_date = Reg2, payment_interest = 12m },
}.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
// 等价于 EnrichDividendIn 的数值核心:GetBondPayments(StartDate, closeDate) × unwindQty
private static decimal EnrichOnce(DateTime closeDate, decimal unwindQty)
{
var svc = new BridgeBps(OptUserInfo.UnitTestUser);
return svc.CalcPayment(BondCode, StartDate, closeDate, unwindQty, 1, 1);
}
[TestMethod]
public void _自动路径总额按登记日持仓分摊_不自洽多算()
{
decimal totalFace = 10_000m; // 总面额 1 万元
decimal halfFace = totalFace / 2m; // 每次平一半
// 第一次 5/20 平一半:窗口(Start,5/20] 仅含 reg1 → 10 × 5000/100 = 500
var d1 = EnrichOnce(new DateTime(2026, 5, 20), halfFace);
// 第二次 6/20 平一半:窗口(Start,6/20] 含 reg1+reg2 → (10+12) × 5000/100 = 1100
var d2 = EnrichOnce(new DateTime(2026, 6, 20), halfFace);
var total = d1 + d2;
// 经济应得(登记日持有规则):
// 第一批5000元:5/15持有✓(10)、6/15未持有✗ → 10×5000/100 = 500
// 第二批5000元:5/15持有✓(10)、6/15持有✓(12) → 22×5000/100 = 1100
decimal expected = 10m * halfFace / 100m + (10m + 12m) * halfFace / 100m;
Assert.AreEqual(500m, d1, 0.001m, "第一次(5/20)只含 reg1 = 500");
Assert.AreEqual(1100m, d2, 0.001m, "第二次(6/20)含 reg1+reg2 = 1100");
Assert.AreEqual(expected, total, 0.001m,
"两次部分平仓总额 = 按登记日持有×平仓量分摊的应得值,重叠窗口不重复计同量(纠正:乘当次 unwindQty 而非剩余持仓)");
// 反证:若手动路径口径(第一次平仓即给全量待实现 = 两次分红×总面额)会多算
decimal manualFullIfFirst = (10m + 12m) * totalFace / 100m; // 2200
Assert.IsTrue(manualFullIfFirst > total,
"反证:手动全量落袋口径(2200) > 自动分摊口径(1600),多算方是手动路径而非自动路径");
}
}
}
@@ -145,9 +145,9 @@ namespace YLErp.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
return positions.Select(p => new swap_flow_event
@@ -125,8 +125,8 @@ namespace YLErp.Modules.SwapModule
var position = CreateCompoundPosition();
var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, closePercent,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, closePercent,
(int)SwapEventTypeEnum., false, Principal,
add: false, settment: false, newCalcLast: false);
Assert.AreEqual(1, interests.Count);
return interests[0];
@@ -356,8 +356,8 @@ namespace YLErp.Modules.SwapModule
var interests = ServiceByDate().GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
add: false, settment: false, newCalcLast: false);
Assert.AreEqual(1, interests.Count);
@@ -420,8 +420,8 @@ namespace YLErp.Modules.SwapModule
var result = service.GetInterests(td, td.trade_extend, resetDate, resetDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
remainingPrincipal, remainingPrincipal, 0m, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., true, false, 0m, remainingPrincipal,
remainingPrincipal, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., true, remainingPrincipal,
add: false, settment: false, newCalcLast: false).Single();
var remainingInterest = previousInterest * remainingPrincipal / previousPrincipal;
@@ -466,8 +466,8 @@ namespace YLErp.Modules.SwapModule
var result = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
remainingPrincipal, remainingPrincipal, 0m, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingPrincipal,
remainingPrincipal, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., false, remainingPrincipal,
add: false, settment: false, newCalcLast: false).Single();
AssertDecimal(pendingInterest, result.InterestAmount,
@@ -47,7 +47,7 @@ namespace YLErp.Modules.SwapModule
{
DealInterests(interestList, eodPositions, new List<eod_swap_position>(),
settleDate, td, new List<swap_flow_event>(), new List<swap_flow_event>(), null,
posiLongNational, 0m, 0m, grossPrice, orginPv);
posiLongNational + 0m, 0m, grossPrice, orginPv);
}
}
@@ -63,10 +63,10 @@ namespace YLErp.Modules.SwapModule
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent,
int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv,
int eventType, bool tdClose,
decimal orginPv,
bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
@@ -77,9 +77,9 @@ namespace YLErp.Modules.SwapModule
}
return (DealService ?? new SwapDealService(this)).GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
// public 包装:让测试能调用 protected 方法
@@ -99,7 +99,7 @@ namespace YLErp.Modules.SwapModule
decimal orginPv = DealInterestsScenarioTest.Principal)
{
SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval,
lastEodSwap, posiLongNotional, 0m, 1m, orginPv);
lastEodSwap, posiLongNotional + 0m, 1m, orginPv);
return PersistedPositions.LastOrDefault();
}
@@ -110,7 +110,7 @@ namespace YLErp.Modules.SwapModule
decimal closeNotional, bool autoSwap)
{
SaveAutoEodWithCloseInterestPosition(eodPayPosition, null, position, td, valueDate, interval,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, autoSwap, 1m,
posiLongNotional + posiShortNotional, flowEvents, closeNotional, autoSwap, 1m,
DealInterestsScenarioTest.Principal);
return PersistedPositions.LastOrDefault();
}
@@ -121,7 +121,7 @@ namespace YLErp.Modules.SwapModule
decimal grossPrice, decimal orginPv)
{
SaveEodInterestPositionCopy(eodPayPosition, null, valueDate, td, position, null,
false, posiLongNotional, posiShortNotional, grossPrice, orginPv);
false, posiLongNotional + posiShortNotional, grossPrice, orginPv);
return PersistedPositions.LastOrDefault();
}
@@ -134,7 +134,7 @@ namespace YLErp.Modules.SwapModule
{
DealInterests(interestList, eodPositions, new List<eod_swap_position>(),
settleDate, td, flowEvents, new List<swap_flow_event>(), null,
posiLongNational, posiShortNational, closeNational, grossPrice, orginPv);
posiLongNational + posiShortNational, closeNational, grossPrice, orginPv);
}
}
@@ -1229,8 +1229,8 @@ namespace YLErp.Modules.SwapModule
var result = new SwapDealService(service).GetInterests(
td, td.trade_extend, closeDate, closeDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, orginPv,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, orginPv,
false, settment: false, newCalcLast: false, closeList: null).Single();
AssertDecimal(remainingNotional, result.InterestPrincipal,
@@ -1268,8 +1268,8 @@ namespace YLErp.Modules.SwapModule
var firstCloseInterest = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
originalNotional, originalNotional, 0m, remainingNotional, 0.5m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
originalNotional, remainingNotional, 0.5m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
var firstCloseCash = Math.Round(firstCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
@@ -1286,14 +1286,14 @@ namespace YLErp.Modules.SwapModule
var replayAtPreviousEod = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
var replayAtFinalClose = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
var expectedFinalInterest = firstCloseEod.InterestIncomeSum
+ replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount;
@@ -1306,8 +1306,8 @@ namespace YLErp.Modules.SwapModule
var finalCloseInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { firstCloseEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
var finalCloseCash = Math.Round(finalCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
@@ -1434,8 +1434,8 @@ namespace YLErp.Modules.SwapModule
var partial = service.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
notional, notional, 0m, partialNotional, partialPercent,
(int)SwapEventTypeEnum., false, false, 0m, notional,
notional, partialNotional, partialPercent,
(int)SwapEventTypeEnum., false, notional,
settment: false).Single();
AssertDecimal(84090.95m, Math.Round(partial.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
@@ -1444,8 +1444,8 @@ namespace YLErp.Modules.SwapModule
var final = service.GetInterests(
td, td.trade_extend, maturityDate, maturityDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, remainingNotional,
settment: false, newCalcLast: true).Single();
AssertDecimal(268428.73m, Math.Round(final.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
@@ -1575,8 +1575,8 @@ namespace YLErp.Modules.SwapModule
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List<eod_swap_position> { partialEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false, newCalcLast: true).Single();
Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m,
$"5/18 复利平仓应承接 5/11 日终剩余本金的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}");
@@ -1706,8 +1706,8 @@ namespace YLErp.Modules.SwapModule
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List<eod_swap_position> { partialEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false, newCalcLast: true).Single();
Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m,
$"0005 5/18 复利应承接部分平仓后的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}");
@@ -1739,8 +1739,8 @@ namespace YLErp.Modules.SwapModule
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { intermediateEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false, newCalcLast: false).Single();
AssertDecimal(expectedFinalInterest, finalInterest.InterestAmount,
"0005 最终全平重放时,历史5/18终点必须包含当日利息后再做差额");
@@ -1829,8 +1829,8 @@ namespace YLErp.Modules.SwapModule
var result = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, remainingNotional,
settment: false).Single();
AssertDecimal(expectedInterest, result.InterestAmount,
@@ -1927,8 +1927,8 @@ namespace YLErp.Modules.SwapModule
var partialInterest = dealService.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List<eod_swap_position> { preCloseEod }, new List<swap_position> { position },
originalNotional, originalNotional, 0m, partialNotional, partialClosePercent,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
originalNotional, partialNotional, partialClosePercent,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
AssertExcelMoney(scenario.ExpectedPartialInterest, partialInterest.InterestAmount,
$"{scenario.TradeNumber} 5/11 部分平仓利息应匹配 Excel BL 列");
@@ -1976,8 +1976,8 @@ namespace YLErp.Modules.SwapModule
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { finalPreEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, remainingNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, remainingNotional,
settment: false).Single();
AssertExcelMoney(scenario.ExpectedFinalInterest, finalInterest.InterestAmount,
$"{scenario.TradeNumber} 5/19 全部平仓利息应匹配 Excel BN 列");
@@ -1,5 +1,7 @@
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.EodModule;
namespace YLErp.Modules.SwapModule
{
@@ -17,9 +19,21 @@ namespace YLErp.Modules.SwapModule
private const int SwapTradeId = 9200;
private const long PositionId = 9201;
private const decimal InitialQty = 1000m;
private const decimal DailyRatePerUnit = 0.01m; // 每单位每天 0.01,便于手算
private const decimal RegPer100 = 1.0m; // 每 100 元面值票息 1.0 → qty(1000) 时单期分红 = 1.0×1000/100 = 10
private static readonly DateTime StartDate = new(2026, 1, 5);
#region reg_date GetBondPayments
private const string BondUnderlying = "210210.IB";
private static List<BondPayment> BondPayments() => new List<BondPayment>
{
// 登记日 1/6、1/7 各一期;支付日滞后若干日(刻意与登记日不同,验证按 reg_date 而非 pay_date 计提)
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 6), payment_date_pl = new DateTime(2026, 1, 9), payment_date = new DateTime(2026, 1, 9), payment_interest = RegPer100 },
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 7), payment_date_pl = new DateTime(2026, 1, 10), payment_date = new DateTime(2026, 1, 10), payment_interest = RegPer100 },
};
#endregion
#region Stubs
/// <summary>SwapDealService stub:暴露 GetPreEodDividendSum,注入 EOD 数据(不连库)。</summary>
@@ -37,14 +51,25 @@ namespace YLErp.Modules.SwapModule
=> _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate);
}
/// <summary>SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPosition + 线性 CalcBondPayment。</summary>
/// <summary>真实 BondPaymentServicereg_date 口径)seam:仅注入内存 BondPayment 数据,票息计算走生产 GetBondPayments+CalcPayment。</summary>
private sealed class RealBondPaymentService : BondPaymentService
{
private readonly List<BondPayment> _data;
public RealBondPaymentService(List<BondPayment> data, OptUserInfo userInfo) : base(userInfo) { _data = data; }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
/// <summary>SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPositionCalcBondPayment 桥接真实 BondPaymentServicereg_date 口径,不再用线性假公式)。</summary>
private sealed class EodSvcStub : TestableSwapEodPositionService
{
public EodSvcStub() : base(nameof(DividendEodNoDoubleCountTest)) { }
private readonly List<BondPayment> _bondPayments;
public EodSvcStub(List<BondPayment> bondPayments) : base(nameof(DividendEodNoDoubleCountTest)) { _bondPayments = bondPayments; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
int days = Math.Max(0, (int)(toDate - fromDate).TotalDays);
return DailyRatePerUnit * days * qty * shortRatio * directionRatio;
// 桥接真实生产口径:GetBondPayments 按 reg_date 过滤 + CalcPayment 累加(替换原线性假公式 DailyRatePerUnit*days*qty
var svc = new RealBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser);
return svc.CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
}
protected override underlying_manager GetUnderlyingData(string underlyingCode)
=> new underlying_manager { ValueAddedTax = 0m };
@@ -122,15 +147,15 @@ namespace YLErp.Modules.SwapModule
/// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。
/// 验证:不重复(EOD TdCloseDividend 扣 DividendIn+ 不丢失(当日新计进 PosiDividendSum+ 守恒。
///
/// 序列(StartDate=1/5每日 0.01×1000=10):
/// D1=1/6 无事件 CopyPosiDividendSum = 0 + 10 = 10
/// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_eventEOD新计 10 - 实现 10 → PosiDividendSum=10
/// 序列(StartDate=1/5reg_date 1/6、1/7 各一期,每期 = qty×per100/100 = 10):
/// D1=1/6 无事件 Copy窗口(1/5,1/6] 命中 reg_date 1/6 → TdPosiDividend=10PosiDividendSum=10
/// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_eventEOD 窗口(1/6,1/7] 命中 reg_date 1/7 → 新计 10 - 实现 10 → PosiDividendSum=10
/// 守恒:全程新计(10+10) - 全程实现(10) = 末尾 PosiDividendSum(10)
/// </summary>
[TestMethod]
public void _DividendIn真实算_保存后EOD_不重复不丢失()
{
var eodSvc = new EodSvcStub();
var eodSvc = new EodSvcStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
@@ -174,15 +199,15 @@ namespace YLErp.Modules.SwapModule
/// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红
/// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。
///
/// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1(=T日前待实现,正确不含登记日当日);
/// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日)+ PosiDividendSum=0。
/// 即登记日当日分红既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。
/// 应得 = T日前待实现累计(r1.PosiDividendSum);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。
/// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1=T日前待实现,正确不含登记日当日 reg_date 1/7 的分红);
/// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日 reg_date 1/7+ PosiDividendSum=0。
/// 即登记日当日分红reg_date 1/7 的 10既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。
/// 应得 = T日前待实现累计(r1.PosiDividendSum,仅含 1/6 那期 10);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。
/// </summary>
[TestMethod]
public void _按交易场所规定不享有当日分红()
{
var eodSvc = new EodSvcStub();
var eodSvc = new EodSvcStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
@@ -209,10 +209,10 @@ namespace YLErp.Modules.SwapModule
CloseDate, CloseDate, // valueDate / unwindDate
new List<eod_swap_position>(), // eodPositions(空)
new List<swap_position> { position },
Notional, Notional, Notional, Notional, // posiNotional / long / short / closePosiNotional
Notional, Notional, // posiNotional / closePosiNotional
1m, // closePercent
(int)SwapEventTypeEnum.,
false, false, 0m, Notional, // tdClose / needPrice / grossPrice / orginPv
false, Notional, // tdClose / orginPv
false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count);
return interests[0];
@@ -0,0 +1,307 @@
using Newtonsoft.Json;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GetInterests 双显式入口语义字符化测试(Step3"特判降级"的前置钉子)。
///
/// 背景:GetIntradayUnwindInterests(盘中:平仓前剩余×实际比例)与
/// CalcEodPostCloseSettleInterestsEOD平仓后收盘:平仓后剩余×恒1)是同一经济事件
/// (部分平仓)的两套传参语义,靠 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底粘合。
/// 本测试钉死当前行为,使后续特判降级/语义重构有回归网:
/// ① 复利×mode2closePrincipal(特判产物)是 CalcDailyCompoundInterest 的重放本金——
/// 两入口 closePosiNotionalValue 均为实际平掉额 → InterestAmount 必须相等;
/// ② 单利×mode2CalcDailySimpleInterest 消费的是 posiPrincipal×closePercent——
/// 盘中(平仓前×比例) vs EOD(剩余×1) 数值口径可能不同,本测试【记录现状】(见各断言注释);
/// ③ mode9 全平(posi=0):兜底覆盖生效,结息额非零。
///
/// 数据基建复用 GetInterestsUnitTest_T0 的构建器口径(T+04/27起息,"11"算头算尾)。
/// </summary>
[TestClass]
public class GetInterestsEntrySemanticsTest
{
private const decimal Principal = 1000m;
private const decimal FixedRate = 0.01m;
private const decimal FloatRate = 0.001m;
private const int AnnualDays = 365;
private const int ResetPeriod = 3;
private static readonly DateTime TradeDate = new(2026, 4, 27);
private static readonly DateTime StartDate = new(2026, 4, 27);
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
private static readonly DateTime UnwindDate = new(2026, 4, 30);
// 平仓前剩余 1000,平掉 30%(300),收盘后剩余 700
private const decimal PreClose = 1000m;
private const decimal Closed = 300m;
private const decimal Remaining = 700m;
private const decimal ClosePercent = 0.3m;
#region Stub T0
private sealed class StubSwapDealService : SwapDealService
{
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary<DateTime, double> floatRates) : base(optUser)
{
_floatRates = floatRates;
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; }
if (_floatRates.TryGetValue(valueDate.Date, out rate)) return true;
rate = 0;
return false;
}
}
private static SwapDealService CreateService() => new StubSwapDealService(
new OptUserInfo(0, nameof(GetInterestsEntrySemanticsTest), OptUserFrom.UnitTest),
new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 27)] = (double)FloatRate,
[new DateTime(2026, 4, 28)] = (double)FloatRate,
[new DateTime(2026, 4, 29)] = (double)FloatRate,
[new DateTime(2026, 4, 30)] = (double)FloatRate,
});
#endregion
#region T0
private static trade CreateTrade()
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11", // 算头算尾
SettlementRules = 0
})
};
return new trade
{
id = 1, TradeNumber = "UT-INT-ENTRY-SEMANTICS", ClientId = 999998,
TradeType = "收益互换", TradeDate = TradeDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
trade_extend = extend
};
}
private static swap_position CreatePosition(InterestModeEnum mode, InterestTypeEnum interestType, bool floating = false)
{
var intervalModels = new List<IntervalModel>
{
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
};
return new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum., InterestMode = (int)mode,
InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)interestType,
IsAnnualized = true, interest_rest_days = ResetPeriod, interest_rule = 0,
FloatRateUnderlyingCode = floating ? "FR007" : null,
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
};
}
private static eod_swap_position CreatePreEod(decimal interestSum, decimal principal)
=> new()
{
id = 1, SwapTradeId = 1, PositionId = 1001, ValueDate = new DateTime(2026, 4, 29),
ClientId = 999998, FloatRate = FloatRate, TdInterestPrincipal = principal,
PosiNotionalValue = principal, InterestIncomeSum = interestSum, InterestProfitSum = interestSum
};
#endregion
/// <summary>
/// 复利×mode2×部分平仓30%:钉住两入口【当前】结息口径(2026-08-14 实测,字符化)。
///
/// 实测(closePrincipal 特判两边均=平掉额300,但消费路径不同):
/// 盘中 = 0.036164835616 —— CalcDailyCompoundInterest 以 closePosi(300) 全程重放 [4/27,4/30]
/// EOD = 0.059041913305 —— InitSwapDealInterest closePercent==1 分支:
/// preEod.InterestIncomeSum(0.05 全腿待实现) + amountAtEnd(0.036165) - amountAtPrevEod(0.027123)。
///
/// ⚠️ 两值不等 = 已观察到的口径分歧(同一经济事件两种结息额),非断言失败项;
/// 待业务裁决哪个口径正确前,本测试锁死两值防意外漂移。裁决后改断言为"相等"或删除错方。
/// </summary>
[TestMethod]
public void _mode2_部分平仓_双入口口径钉住现状()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum., floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum., tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Closed, 1m,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[复利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
// 钉住两入口各自的当前值(容差 1e-9 级,防任何实现漂移)
Assert.AreEqual(0.036164835616m, intraday[0].InterestAmount, 0.000000001m,
"盘中口径:closePosi(平掉额300) 全程重放利息。此值变化=盘中复利口径漂移");
Assert.AreEqual(0.059041913305m, eodPostClose[0].InterestAmount, 0.000000001m,
"EOD口径:preEod待实现(0.05) + 平掉额末段增量(0.009042)。此值变化=EOD平仓后收盘复利口径漂移");
}
/// <summary>
/// 单利×mode2×部分平仓30%:记录两入口当前口径(快照×比例 vs 重放基数差异面)。
/// 单利消费 posiPrincipal×closePercent:盘中 1000×0.3 vs EOD 700×1 —— 若两值不等,
/// 这是当前系统的已知口径差异面(非断言失败项),数值以 Console 留档,供特判降级时对照。
/// </summary>
[TestMethod]
public void _mode2_部分平仓_双入口口径留档()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum.);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum., tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Closed, 1m,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[单利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
Console.WriteLine($"[单利mode2] TdInterestAmount: 盘中={intraday[0].TdInterestAmount} / EOD={eodPostClose[0].TdInterestAmount}");
// 钉住"两入口非零"这一最低限度事实;数值差异本身是记录项,不是失败项
Assert.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0");
Assert.IsTrue(eodPostClose[0].InterestAmount != 0m, "EOD单利结息额不应为0");
}
/// <summary>
/// mode9 全平(EODposi=0):特判兜底触发 closePrincipal=closePosiNotionalValue(实际平掉额),
/// 结息额非零。若兜底被删,closePrincipal=0×1=0 → 结息额归零 → 本断言红。
/// </summary>
[TestMethod]
public void _mode9_全平_兜底覆盖生效结息额非零()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum., floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
// 全平:剩余=0,平掉=全部 1000
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, 0m, PreClose, 1m,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result.Count);
Console.WriteLine($"[复利mode9全平] InterestAmount={result[0].InterestAmount}");
Assert.IsTrue(result[0].InterestAmount != 0m,
"mode9 全平时 posi=0,兜底必须以 closePosiNotionalValue(实际平掉额) 为结息本金,结息额非零(兜底钉子)");
}
#region CalcEodPostCloseSettleInterests
/// <summary>
/// 参数捕获 stub:拦下 CalcSwapInterests 的全部实参,不触库、不真算。
/// </summary>
private sealed class CalcSwapInterestsCapture : TestableSwapEodPositionService
{
public CalcSwapInterestsCapture() : base(nameof(GetInterestsEntrySemanticsTest)) { }
public List<swap_flow_event> CapturedCloseList = null;
public bool CapturedTdClose;
public int CapturedEventType;
public decimal CapturedPosiNotional;
public decimal CapturedClosePosiNotional;
public decimal CapturedClosePercent;
public decimal CapturedOrginPv;
public bool CapturedAdd;
public bool CapturedSettment;
public bool CapturedNewCalcLast;
public int CallCount;
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent,
int eventType, bool tdClose,
decimal orginPv,
bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
CallCount++;
CapturedTdClose = tdClose; CapturedEventType = eventType;
CapturedPosiNotional = posiNotionalValue; CapturedClosePosiNotional = closePosiNotionalValue;
CapturedClosePercent = closePrecent; CapturedOrginPv = orginPv;
CapturedAdd = add; CapturedSettment = settment; CapturedNewCalcLast = newCalcLast;
CapturedCloseList = closeList;
return new List<swap_flow_event>();
}
public List<swap_flow_event> ExposedEodPostCloseSettle(InterestCalcRequest req)
=> CalcEodPostCloseSettleInterests(req);
}
/// <summary>
/// 钉死 InterestCalcRequest.EodPostCloseSettle 工厂 → CalcEodPostCloseSettleInterests →
/// CalcSwapInterests 的位置参数转发契约。这段转发是位置传参最易错位的环节
/// posiNotionalValue/closePosiNotionalValue/orginPv 三个相邻同型 decimal,编译器不查错位),
/// 任何映射改动(含将来删 needPrice/grossPrice 死参数)都必须保持本断言绿。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_工厂到接缝_参数映射钉死()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum.);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var positions = new List<swap_position> { position };
var stub = new CalcSwapInterestsCapture();
var req = InterestCalcRequest.EodPostCloseSettle(
td, td.trade_extend, UnwindDate, UnwindDate,
new List<eod_swap_position> { preEod }, positions,
remainingNotionalAfterClose: Remaining,
closedNotional: Closed,
eventType: (int)SwapEventTypeEnum., tdClose: false,
orginPv: PreClose, add: true, newCalcLast: false);
stub.ExposedEodPostCloseSettle(req);
Assert.AreEqual(1, stub.CallCount, "默认实现应恰好调用一次 CalcSwapInterests(虚接缝兼容既有测试替身)");
Assert.AreEqual(Remaining, stub.CapturedPosiNotional, "posiNotionalValue 位 = 平仓后剩余(700)——语义核心,错位即红");
Assert.AreEqual(Closed, stub.CapturedClosePosiNotional, "closePosiNotionalValue 位 = 实际平掉额(300)");
Assert.AreEqual(1m, stub.CapturedClosePercent, "closePrecent 恒 1(全额结息)");
Assert.AreEqual((int)SwapEventTypeEnum., stub.CapturedEventType);
Assert.IsFalse(stub.CapturedTdClose);
Assert.AreEqual(PreClose, stub.CapturedOrginPv, "orginPv 位 = 上一日终本金——与相邻 decimal 最易错位处");
Assert.IsTrue(stub.CapturedAdd);
Assert.IsFalse(stub.CapturedSettment, "settment=false:走盘中重放算法(EOD平仓后收盘复用重放)");
Assert.IsFalse(stub.CapturedNewCalcLast);
Assert.IsNull(stub.CapturedCloseList, "该场景不传 closeList");
}
#endregion
}
}
@@ -228,9 +228,9 @@ namespace YLErp.Modules.SwapModule
var position = CreateFloatInterestPosition(interestRule, interestType, fixedRate);
var interests = _service.GetInterests(td, td.trade_extend, valueDate, unwindDate,
eodPositions, new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
}
@@ -244,9 +244,9 @@ namespace YLErp.Modules.SwapModule
var position = CreateFloatInterestPosition(interestRule, interestType, fixedRate);
var interests = _service.GetInterests(td, td.trade_extend, valueDate, valueDate,
eodPositions, new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
Principal, Principal, 1m,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
}
@@ -263,9 +263,9 @@ namespace YLErp.Modules.SwapModule
var position = CreateFixedInterestPosition(fixedRate, interestRule);
var interests = _service.GetInterests(td, td.trade_extend, valueDate, unwindDate,
eodPositions, new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
}
@@ -279,9 +279,9 @@ namespace YLErp.Modules.SwapModule
var position = CreateFixedInterestPosition(fixedRate, interestRule);
var interests = _service.GetInterests(td, td.trade_extend, valueDate, valueDate,
eodPositions, new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
Principal, Principal, 1m,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
}
@@ -322,9 +322,9 @@ namespace YLErp.Modules.SwapModule
valueDate, unwindDate,
eodPositions,
new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -346,9 +346,9 @@ namespace YLErp.Modules.SwapModule
valueDate, valueDate,
eodPositions,
new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
Principal, Principal, 1m,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -371,9 +371,9 @@ namespace YLErp.Modules.SwapModule
valueDate, valueDate,
eodPositions,
new List<swap_position> { position },
Principal, Principal, Principal, Principal, closePercent,
Principal, Principal, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: false, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: false, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -407,9 +407,9 @@ namespace YLErp.Modules.SwapModule
valueDate, unwindDate,
eodPositions,
new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -430,9 +430,9 @@ namespace YLErp.Modules.SwapModule
valueDate, valueDate,
eodPositions,
new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
Principal, Principal, 1m,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -1716,9 +1716,9 @@ namespace YLErp.Modules.SwapModule
valueDate, unwindDate,
eodPositions,
new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -1747,9 +1747,9 @@ namespace YLErp.Modules.SwapModule
valueDate, unwindDate,
eodPositions,
new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -99,9 +99,9 @@ namespace UnitTestProject.Modules.SwapModule.Margin
{
oldList = svc.GetInterests(td, extend, valueDate, valueDate,
preEods, marginPositions,
0m, 0m, 0m, 0m, 1.0m,
(int)SwapEventTypeEnum., tdClose: false, needPrice: false,
grossPrice: 0m, orginPv: 0m,
0m, 0m, 1.0m,
(int)SwapEventTypeEnum., tdClose: false,
orginPv: 0m,
add: false, settment: true, newCalcLast: false, closeList: null);
}
catch (Exception ex)
@@ -8,7 +8,6 @@ using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.Margin;
using YLErp.Derivatives.Interest;
namespace UnitTestProject.Modules.SwapModule.Margin
{
@@ -119,8 +119,8 @@ namespace YLErp.Modules.SwapModule
var position = CreateInterestPosition();
var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
add: false, settment: false, newCalcLast: false);
return interests.Count > 0 ? interests[0].InterestAmount : 0m;
}
@@ -142,8 +142,8 @@ namespace YLErp.Modules.SwapModule
};
var interests = service.GetInterests(td, td.trade_extend, valueDate, valueDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
add: false, settment: true, newCalcLast: false);
if (interests.Count == 0) return (0m, 0m);
return (interests[0].TdInterestAmount, interests[0].InterestAmount);
@@ -313,8 +313,8 @@ namespace YLErp.Modules.SwapModule
var svc5 = new StubDealService(0m, floatRate: 0.001);
var i5 = svc5.GetInterests(td, td.trade_extend, day5, day5,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
settment: false);
decimal swap1 = i5.Count > 0 ? i5[0].InterestAmount : 0m;
@@ -322,8 +322,8 @@ namespace YLErp.Modules.SwapModule
var svc10 = new StubDealService(swap1, floatRate: 0.001);
var i10 = svc10.GetInterests(td, td.trade_extend, day10, day10,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
settment: false);
decimal swap2 = i10.Count > 0 ? i10[0].InterestAmount : 0m;
@@ -332,8 +332,8 @@ namespace YLErp.Modules.SwapModule
var svc15 = new StubDealService(totalConsumed, floatRate: 0.001);
var i15 = svc15.GetInterests(td, td.trade_extend, day15, day15,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
settment: false);
decimal finalUnwind = i15.Count > 0 ? i15[0].InterestAmount : 0m;
@@ -362,8 +362,8 @@ namespace YLErp.Modules.SwapModule
var svc = new StubDealService(0m, floatRate: 0.001);
var interests = svc.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
settment: false);
return interests.Count > 0 ? interests[0].InterestAmount : 0m;
}
@@ -103,8 +103,8 @@ namespace YLErp.Modules.SwapModule
SwapCalcTrace.Reset();
var eod = new List<eod_swap_position> { MakeEod(valueDate, PrepayRemaining, 0m) };
var fe = _svc.GetInterests(td, td.trade_extend, FullDate, FullDate, eod,
new List<swap_position> { pos }, PrepayFix, PrepayFix, PrepayFix, PrepayFix, 1m,
(int)SwapEventTypeEnum., false, false, 0, PrepayFix, false,
new List<swap_position> { pos }, PrepayFix, PrepayFix, 1m,
(int)SwapEventTypeEnum., false, PrepayFix, false,
settment: false, newCalcLast: calcLast, closeList: null)[0];
var trace = SwapCalcTrace.Dump();
Console.WriteLine(trace);
@@ -101,9 +101,9 @@ namespace YLErp.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
return positions.Select(p => new swap_flow_event
@@ -0,0 +1,270 @@
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.EodModule;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GLMS-20260105-0006 端到端补充:EOD 分红引擎的票息归属须按【债权登记日 reg_date】判定,
/// 而非支付日(pay_date)。此前 DividendEodNoDoubleCountTest.EodSvcStub 把 CalcBondPayment 覆写成
/// 线性公式(DailyRatePerUnit*days*qty)**绕开了 reg_date 口径**——即没有真正验证"引擎按登记日计提"。
///
/// 本文件把 EOD stub 的 CalcBondPayment seam 重新桥接回【真实的 BondPaymentServicereg_date 口径)】,
/// 仅用内存 BondPayment 数据(不连库),使端到端流程(CopyEodPosition/UpdateEodPosition + GetPreEodDividendSum)
/// 真正跑生产日期逻辑:
/// ① EOD 引擎在登记日计提、支付日不计提(证明 reg_date 口径);
/// ② 登记日下一日(T+1)全平:经 GetPreEodDividendSum 读到登记日当日 EOD 分红(收盘在册→享有);
/// ③ 部分平仓 T+1DividendIn 为全量(非按比例缩放),剩余 PosiDividendSum 归 0(记录当前生产行为)。
/// </summary>
[TestClass]
public class RegDateDividendEodE2ETest
{
private const string BondCode = "230004.IB";
private const int TradeId = 7004;
private const long PositionId = 70041;
private const decimal Qty = 20_000_000m;
private const decimal PaymentPer100 = 0.1808m;
private const decimal ExpectedDividend = 36_160m; // 20,000,000 × 0.1808 / 100
private static readonly DateTime StartDate = new(2026, 4, 1);
private static readonly DateTime RegDate = new(2026, 4, 3); // 债权登记日
private static readonly DateTime PayDate = new(2026, 4, 6); // 实际支付日(与登记日差 3 天)
#region reg_date
private static List<BondPayment> BondPayments()
=> new List<BondPayment>
{
new BondPayment
{
underlyingCode = BondCode,
reg_date = RegDate, // 关键:分红归属按债权登记日判定
payment_date_pl = PayDate, // 理论付息日(非归属口径)
payment_date = PayDate, // 实际付息日(非归属口径)
payment_interest = PaymentPer100
}
};
#endregion
#region BondPaymentService seam reg_date
private sealed class RegDateBondPaymentService : BondPaymentService
{
private readonly List<BondPayment> _data;
public RegDateBondPaymentService(List<BondPayment> data, OptUserInfo userInfo) : base(userInfo) { _data = data; }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
#endregion
#region EOD stubCalcBondPayment BondPaymentService
private sealed class RegDateEodStub : TestableSwapEodPositionService
{
private readonly List<BondPayment> _bondPayments;
public RegDateEodStub(List<BondPayment> bondPayments) : base(nameof(RegDateDividendEodE2ETest)) { _bondPayments = bondPayments; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
// 桥接真实生产口径:BondPaymentService.GetBondPayments 按 reg_date 过滤 + CalcPayment 累加
var svc = new RegDateBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser);
return svc.CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
}
protected override underlying_manager GetUnderlyingData(string underlyingCode)
=> new underlying_manager { ValueAddedTax = 0m };
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
{ vobp = 0m; return 1.00m; }
public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate)
=> CopyEodPosition(eod, null, td, valueDate, preSettleDate);
public eod_swap_position ExecuteUpdateEodPosition(swap_position swapPosition, eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate, List<swap_flow_event> unwindEvents)
=> UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents);
}
#endregion
#region Deal stubGetPreEodDividendSum EOD
private sealed class DealSvcStub : SwapDealService
{
private readonly List<eod_swap> _eodSwaps;
private readonly List<eod_swap_position> _eodPositions;
public DealSvcStub(List<eod_swap> eodSwaps, List<eod_swap_position> eodPositions)
: base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; }
public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
=> GetPreEodDividendSum(tradeId, positionId, dealDate);
protected override IQueryable<eod_swap> QueryPreEodSwaps(int tradeId)
=> _eodSwaps.Where(x => x.SwapTradeId == tradeId).AsQueryable();
protected override eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate)
=> _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate);
}
#endregion
#region
private static trade CreateTrade() => new trade
{
id = TradeId, TradeNumber = "UT-REGDATE-E2E-001", ClientId = 999999,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = new DateTime(2027, 4, 1), TradeStatus = "确认成交", ValidState = "Valid",
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
OriginalStockEqvNotional = (double)(Qty * 1.00m)
};
private static swap_position CreatePosition() => new swap_position
{
id = PositionId, SwapTradeId = TradeId,
PosiDirection = (int)SwapDirectionEnum., PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = BondCode, ContractSize = 1m,
PosiQuantity = Qty, PosiNotionalValue = Qty,
PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m,
PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m,
IsInitial = true, Invalid = false,
PosiTradingFee = 0, PosiTradingFeePending = 0
};
private static eod_swap_position CreateInitialEod() => new eod_swap_position
{
id = 1, SwapTradeId = TradeId, PositionId = PositionId,
ValueDate = StartDate, PosiQuantity = Qty,
PosiDirection = (int)SwapDirectionEnum., PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = BondCode, ContractSize = 1m,
PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m,
PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m,
PosiDividendSum = 0m, TdPosiDividend = 0m, TdCloseDividend = 0m,
RealizedDividend = 0m, PosiFeePending = 0m,
InterestProfitSum = 0m, Invalid = false
};
private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) => new swap_flow_event
{
SwapTradeId = TradeId, EventType = (int)SwapFlowEventTypeEnum.,
PositionId = PositionId, Quantity = qty, DividendIn = dividendIn,
MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m,
TradingAmount = qty * 1.000m,
UnwindDate = eventDate, EventDate = eventDate, PayDate = eventDate,
DataState = (int)SwapFlowDateStateEnum.
};
private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tol, string msg)
=> Assert.IsTrue(System.Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}");
#endregion
/// <summary>
/// 端到端证 reg_date 口径:EOD 引擎(CopyEodPosition)逐日计提时,
/// 仅在【债权登记日】产生分红,【支付日】不产生(即便支付日与登记日相差数日)。
/// 这是线性 stub 无法覆盖的——线性公式按"天数"算,永远无法区分登记日 vs 支付日。
/// </summary>
[TestMethod]
public void _EOD引擎按reg_date计提_非pay_date()
{
var eodSvc = new RegDateEodStub(BondPayments());
var td = CreateTrade();
var initialEod = CreateInitialEod();
// D1=4/2(登记日前一日):窗口 (4/1,4/2] 无登记日 → 0
var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, new DateTime(2026, 4, 2), StartDate);
AssertDecimalEqual(0m, r1.TdPosiDividend, 0.01m, "4/2 当日新计(无登记日)");
AssertDecimalEqual(0m, r1.PosiDividendSum, 0.01m, "4/2 累计(无登记日)");
// D2=4/3(登记日):窗口 (4/2,4/3] 命中 reg_date=4/3 → 36160
var r2 = eodSvc.ExecuteCopyEodPosition(r1, td, RegDate, StartDate);
AssertDecimalEqual(ExpectedDividend, r2.TdPosiDividend, 0.01m,
"4/3 登记日当日应计提 36160(按 reg_date 口径);若按支付日(pay_date=4/6)则此处为 0(漏计)。");
AssertDecimalEqual(ExpectedDividend, r2.PosiDividendSum, 0.01m, "4/3 累计=36160");
// D3=4/6(支付日,非登记日):窗口 (4/3,4/6] 不含任何 reg_date4/3 不>4/34/6 是支付日非登记日)→ 0
var r3 = eodSvc.ExecuteCopyEodPosition(r2, td, PayDate, StartDate);
AssertDecimalEqual(0m, r3.TdPosiDividend, 0.01m,
"4/6 支付日不应计提(分红归属按 reg_date,不是 pay_date);线性 stub 因按天数算会在此误计。");
AssertDecimalEqual(ExpectedDividend, r3.PosiDividendSum, 0.01m, "4/6 累计仍为 36160(支付日不重复计提)");
Console.WriteLine($"[reg_date 口径] 4/2={r1.PosiDividendSum}, 4/3={r2.PosiDividendSum}(登记日计提), 4/6={r3.PosiDividendSum}(支付日不计提)");
}
/// <summary>
/// 用户场景「登记日下一日(T+1)全平」:T日(登记日)收盘在册→享有T日分红;
/// T+1盘中全平,GetPreEodDividendSum(T+1) 应读到 T日 EOD(含当日分红)= 36160,而非漏读为 0。
/// 验证端到端:EOD 引擎算出 T日分红 → 快照 → 手动/互换读取正确取到。
/// </summary>
[TestMethod]
public void _经GetPreEodDividendSum读到登记日分红()
{
var eodSvc = new RegDateEodStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// T日=4/3(登记日)EOD:引擎算出分红 36160reg_date 口径)
var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate);
AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计分红=36160");
// T+1=4/4 盘中:注入 T日 EOD 快照,GetPreEodDividendSum 应读 T日(<=当日) → 36160
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
new List<eod_swap_position> { rReg });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4));
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m,
"T+1(4/4) 盘中全平应经 GetPreEodDividendSum 读到 T日(4/3)EOD 分红 36160(收盘在册→享有);" +
"若 < 严格小于 dealDate 读 T-1(4/2=0) 则漏读登记日当日。");
Console.WriteLine($"[T+1 全平] DividendIn(读T日EOD)={dividendIn}");
// T+1=4/4 EOD 全平:PosiQuantity=0 → 不计提当日 + PosiDividendSum 归 0
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate,
new List<swap_flow_event> { CloseEvent(Qty, dividendIn, new DateTime(2026, 4, 4)) });
// 实拿 = DividendIn(本次落袋) + 末尾 PosiDividendSum(剩余挂账) = 应得(T日前待实现=持有至登记日)
decimal actualGot = dividendIn + rT1.PosiDividendSum;
AssertDecimalEqual(ExpectedDividend, actualGot, 0.01m, "实拿=应得(持有至登记日享有的 36160)");
AssertDecimalEqual(0m, rT1.TdPosiDividend, 0.01m, "T+1 非登记日,EOD 不计提当日");
AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0");
Console.WriteLine($"[T+1 全平] 应得={ExpectedDividend}, 实拿={actualGot}, 末尾PosiDividendSum={rT1.PosiDividendSum}");
}
/// <summary>
/// 部分平仓 T+1:当前生产行为记录(非修复目标)。
/// T日(登记日)持有→T+1盘中部分平仓:GetPreEodDividendSum 返回的是【全量】待实现分红(非按平仓比例缩放),
/// 故 DividendIn=全量 36160T+1 EOD 部分平仓(PosiQuantity>0)后剩余 PosiDividendSum=前日-全量=0。
/// 注:此"DividendIn 不按平仓比例缩放"是当前生产行为,已与用户确认(潜在一致性议题,非本 bug 修复范围)。
/// </summary>
[TestMethod]
public void _T1_DividendIn为全量_剩余PosiDividendSum归0()
{
var eodSvc = new RegDateEodStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// T日=4/3(登记日)EOD:累计 36160
var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate);
AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计=36160");
// T+1=4/4 盘中部分平仓(50%)GetPreEodDividendSum 返回【全量】36160(不按比例缩放)
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
new List<eod_swap_position> { rReg });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4));
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, "部分平仓 T+1DividendIn 仍为全量 36160(非按 50% 缩放)");
// T+1=4/4 EOD 部分平仓(Quantity=Qty/2)PosiQuantity>0TdPosiDividend=0(非登记日)
// PosiDividendSum = 前日36160 + 0 - TdCloseDividend(全量36160) = 0
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate,
new List<swap_flow_event> { CloseEvent(Qty / 2, dividendIn, new DateTime(2026, 4, 4)) });
AssertDecimalEqual(ExpectedDividend, rT1.TdCloseDividend, 0.01m, "TdCloseDividend=全量 DividendIn(36160)");
AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m,
"部分平仓后剩余 PosiDividendSum=前日36160 - 全量实现36160 = 0(当前生产行为:DividendIn 不按比例缩放)");
Console.WriteLine($"[部分平仓 T+1] DividendIn={dividendIn}(全量), 剩余PosiDividendSum={rT1.PosiDividendSum}");
}
}
}
@@ -80,9 +80,9 @@ namespace YLErp.Modules.SwapModule
var result = service.GetInterests(
trade, trade.trade_extend, closeCase.CloseDate, closeCase.CloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
closeCase.RemainingNotional, closeCase.RemainingNotional, 0m,
closeCase.RemainingNotional,
closeCase.RemainingNotional, 1m, (int)SwapEventTypeEnum.,
false, false, 0m,
false,
closeCase.InterestType == 0 ? closeCase.RemainingNotional : closeCase.OriginalNotional,
add: false, settment: false, newCalcLast: false).Single();
@@ -1,3 +1,4 @@
using System.Linq;
using System.Reflection;
using YLErp.DBModels.Enums;
@@ -272,7 +273,16 @@ namespace YLErp.Modules.SwapModule
Console.WriteLine($" ✓ {scenario.Scenario}");
}
Assert.AreEqual(13, parameters.Length, "DealInterests应有13个参数");
// 校验参数集合(按名称,对参数增删/重排/改名均敏感,比裸数字更稳)
var expectedParamNames = new[]
{
"interestList", "eodPositions", "todyEodPositions", "settleDate",
"td", "flowEvents", "autoInterests", "lastEodSwap",
"posiTotalNotional", "closeNational", "grossPrice", "orginPv"
};
var actualParamNames = parameters.Select(p => p.Name).ToArray();
CollectionAssert.AreEquivalent(expectedParamNames, actualParamNames,
"DealInterests 参数集合应与预期一致(新增/重排/改名参数时请同步更新此列表)");
Console.WriteLine("✅ 分支覆盖分析完成");
}
}
@@ -56,17 +56,17 @@ namespace UnitTestProject.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
var svc = new StubSwapDealService(
new OptUserInfo(0, nameof(SwapInterestScenario1And2Test), OptUserFrom.UnitTest), _floatRates);
return svc.GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate,
@@ -74,7 +74,7 @@ namespace UnitTestProject.Modules.SwapModule
List<swap_flow_event> flowEvents, decimal closeNotional, eod_swap_position prevEod)
{
SaveAutoEodWithCloseInterestPosition(prevEod, null, position, td, valueDate, null,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional);
return PersistedPositions.LastOrDefault();
}
@@ -211,9 +211,9 @@ namespace UnitTestProject.Modules.SwapModule
var interests = svc.GetInterests(
td, td.trade_extend, valueDate, valueDate,
prevEod, new List<swap_position> { position },
closeNotional, closeNotional, 0m, closeNotional, 1m,
closeNotional, closeNotional, 1m,
(int)SwapEventTypeEnum.,
false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity);
false, closeNotional, false, settment: false, newCalcLast: isMaturity);
Assert.AreEqual(1, interests.Count);
return interests[0];
}
@@ -178,17 +178,17 @@ namespace UnitTestProject.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
var svc = new RealSwapDealService(
new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, FlowEvents);
var interests = svc.GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
// 捕获 base InterestPrincipal= EOD:1406 行赋给 TdInterestPrincipal 的值,反推前),供 TdInterestPrincipal 断言镜像分叉。
LastBaseInterestPrincipal = interests.Count > 0 ? interests[0].InterestPrincipal : 0m;
return interests;
@@ -226,7 +226,7 @@ namespace UnitTestProject.Modules.SwapModule
List<swap_flow_event> flowEvents, decimal closeNotional, eod_swap_position prevEod)
{
SaveAutoEodWithCloseInterestPosition(prevEod, null, position, _td, valueDate, null,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional);
return PersistedPositions.LastOrDefault();
}
@@ -394,9 +394,9 @@ namespace UnitTestProject.Modules.SwapModule
var interests = svc.GetInterests(
td, td.trade_extend, valueDate, valueDate,
prevEod, new List<swap_position> { position },
closeNotional, closeNotional, 0m, closeNotional, 1m,
closeNotional, closeNotional, 1m,
(int)SwapEventTypeEnum.,
false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity);
false, closeNotional, false, settment: false, newCalcLast: isMaturity);
Assert.AreEqual(1, interests.Count);
return interests[0];
}
@@ -79,16 +79,16 @@ namespace YLErp.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
LastInterestCalculationPositions = positions;
return base.CalcSwapInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
@@ -59,17 +59,17 @@ namespace UnitTestProject.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
var svc = new StubSwapDealService(
new OptUserInfo(0, nameof(SwapSingleTradeVerificationTest), OptUserFrom.UnitTest), _floatRates);
return svc.GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate,
@@ -77,7 +77,7 @@ namespace UnitTestProject.Modules.SwapModule
List<swap_flow_event> flowEvents, decimal closeNotional, eod_swap_position prevEod)
{
SaveAutoEodWithCloseInterestPosition(prevEod, null, position, td, valueDate, null,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional);
return PersistedPositions.LastOrDefault();
}
@@ -213,9 +213,9 @@ namespace UnitTestProject.Modules.SwapModule
var interests = svc.GetInterests(
td, td.trade_extend, valueDate, valueDate,
prevEod, new List<swap_position> { position },
closeNotional, closeNotional, 0m, closeNotional, 1m,
closeNotional, closeNotional, 1m,
(int)SwapEventTypeEnum.,
false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity);
false, closeNotional, false, settment: false, newCalcLast: isMaturity);
Assert.AreEqual(1, interests.Count);
return interests[0];
}
@@ -93,9 +93,9 @@ namespace YLErp.Modules.SwapModule
var position = MakePrepayPosition();
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, new List<swap_position> { position },
UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, closePercent,
UnderlyingNotional, UnderlyingNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null);
false, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
@@ -111,9 +111,9 @@ namespace YLErp.Modules.SwapModule
var position = MakePrepayPosition(fix, rate);
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, new List<swap_position> { position },
notional, notional, notional, notional, closePercent,
notional, notional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null);
false, notional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
@@ -279,9 +279,9 @@ namespace YLErp.Modules.SwapModule
};
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eod, new List<swap_position> { position },
fix, fix, fix, fix, closePercent,
fix, fix, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, fix, false, settment: false, newCalcLast: false, closeList: null);
false, fix, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
@@ -373,9 +373,9 @@ namespace YLErp.Modules.SwapModule
// orginPv 传 notional:非预付金腿不走 877-881 的 Fix 对齐,dynomicPrincipal = notional + notional - notional = notional
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eod, new List<swap_position> { position },
notional, notional, notional, notional * closePercent, closePercent,
notional, notional * closePercent, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null);
false, notional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "非预付金腿应生成 1 条 flow_event");
return interests[0];
}
@@ -488,9 +488,9 @@ namespace YLErp.Modules.SwapModule
};
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eodPos, new List<swap_position> { position },
baseP, baseP, baseP, baseP * closePercent, closePercent,
baseP, baseP * closePercent, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, baseP, false, settment: eodPath, newCalcLast: false, closeList: null);
false, baseP, false, settment: eodPath, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, $"mode={mode} 应生成 1 条 flow_event");
return interests[0];
}
@@ -121,9 +121,9 @@ namespace YLErp.Modules.SwapModule
var position = MakePosition(currentNotional);
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
MakeLastEod(), new List<swap_position> { position },
currentNotional, currentNotional, currentNotional, currentNotional * closePercent, closePercent,
currentNotional, currentNotional * closePercent, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, N, false, settment: false, newCalcLast: false, closeList: null);
false, N, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "标的期初全价腿应生成 1 条 flow_event");
return interests[0];
}
+22 -4
View File
@@ -5,6 +5,12 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace YLErp.DBModels
{
public static class ExDividendDataSources
{
public const string Manual = "Manual";
public const string MarketData = "MarketData";
}
[Table("ex_dividend_info")]
public class ex_dividend_info : DBModelWithOperator
{
@@ -35,29 +41,41 @@ namespace YLErp.DBModels
/// 派息金额
/// </summary>
[DisplayName("派息金额")]
public double GiveCashAmount { get; set; }
public decimal GiveCashAmount { get; set; }
/// <summary>
/// 送股手数
/// </summary>
[DisplayName("送股股数")]
public double GiveShareAmount { get; set; }
public decimal GiveShareAmount { get; set; }
/// <summary>
/// 配股手数
/// </summary>
[DisplayName("配股股数")]
public double RationedSharesAmount { get; set; }
public decimal RationedSharesAmount { get; set; }
/// <summary>
/// 配股手数
/// </summary>
[DisplayName("配股价")]
public double RationedSharesPrice { get; set; }
public decimal RationedSharesPrice { get; set; }
/// <summary>
/// 是否有效
/// </summary>
public bool ValidStatus { get; set; }
/// <summary>
/// Ownership of the record. Manual records always take precedence over imports.
/// </summary>
[DisplayName("数据来源"), Required, MaxLength(32)]
public string DataSource { get; set; } = ExDividendDataSources.Manual;
/// <summary>
/// Last update timestamp supplied by the market-data provider.
/// </summary>
[DisplayName("来源更新时间")]
public DateTime? SourceUpdatedAt { get; set; }
}
public class ex_dividend_infoReq : BaseSearchReq
@@ -1,67 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YLErp.Model.HengTaiModel
{
public class SwapUnwindReq
{
public SwapUnwindReq() {
ACCTSWAP_TERMINATE = new SwapUnwindData();
}
public SwapUnwindData ACCTSWAP_TERMINATE {get;set;}
}
public class SwapUnwindData
{
/// <summary>
/// 客户交易号
/// </summary>
public string CUSTORDID { get; set; }
/// <summary>
/// 返回的时候EXT_NO 对应推送的CUSTORDID
/// </summary>
public string EXT_NO { get; set; }
/// <summary>
/// 合约编号,推送不需要给,返回对应推送的EXT_NO
/// </summary>
public string CONTRACT_CODE { get; set; }
/// <summary>
/// 终止类型 全部终止 1 部分终止 0
/// </summary>
public string TERMINATE_TYPE { get; set; }
/// <summary>
/// 终止数量
/// </summary>
public string TERMINATE_COUNT { get; set; }
/// <summary>
/// 终止日期
/// </summary>
public string TERMINATE_DAY { get; set; }
/// <summary>
/// 支付日期
/// </summary>
public string PAY_DAY { get; set; }
/// <summary>
/// 资产端终止金额 不可为空
/// </summary>
public string ZCD_AMOUNT { get; set; }
/// <summary>
/// 固定端终止金额 不可为空
/// </summary>
public string GDD_AMOUNT { get; set; }
/// <summary>
/// 交易状态 不可为空 0新建,1审批中
/// </summary>
public string ORDSTATUS { get; set; }
/// <summary>
/// 固定端费用
/// </summary>
public string FIX_FEE { get; set; }
/// <summary>
/// 资产端费用
/// </summary>
public string ASSET_FEE { get; set;}
}
}
@@ -103,7 +103,7 @@ namespace YLErp.Modules.EodModule
var result = QueryBondPayments(underlyingCode)
.Where(x => x.reg_date > startDate && x.reg_date <= endDate)
.AsNoTracking().ToList();
Log.Debug($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " +
Log.Info($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " +
string.Join(",", result.Select(r => r.reg_date?.ToString("yyyy-MM-dd"))));
return result;
}
@@ -39,14 +39,15 @@ namespace YLErp.Modules.EodModule
predicate = PredicateBuilder.Create<T>(n => n.ValueDate == settleDate).And(predicate);
}
// 除权数据不在这里做 SQL 左连接:同一标的一天只允许一条有效除权记录,
// 但历史脏数据可能存在重复行。左连接会把一条 EOD 持仓扩成多行,进而重复
// 参与后续风险/结算计算。先取得 EOD+BOD 的唯一持仓结果,再按标的代码匹配
// 除权记录,可以把重复业务键暴露为 ToDictionary 异常,而不是静默扩行。
var query = from eod in DbContext.Set<T>().Where(predicate)
join bod in DbContext.BodTradePosition.Where(n => n.ValueDate == bodDate)
on new { eod.BookId, eod.TradeType, eod.PositionType, eod.UnderlyingCode, ExchangeOptionCode = eod.ExchangeOptionCode ?? string.Empty }
equals new { bod.BookId, bod.TradeType, bod.PositionType, bod.UnderlyingCode, ExchangeOptionCode = bod.ExchangeOptionCode ?? string.Empty } into t_bod
from bod in t_bod.DefaultIfEmpty()
join dividend in DbContext.ex_dividend_info.Where(O => O.ExDividendDate == settleDate && O.ValidStatus)
on eod.UnderlyingCode equals dividend.UnderlyingCode into t_dividend
from dividend in t_dividend.DefaultIfEmpty()
select new
{
eod,
@@ -55,24 +56,31 @@ namespace YLErp.Modules.EodModule
bod.Amount,
bod.Cost,
//bod.AveragePrice
},
dividend
}
};
var datas = query.ToArray();
var diviService = new TradeModule.DealModule.DividendService(OptUser);
// 除权查询集中复用 DividendService 的有效记录条件。字典使用不区分大小写的
// UnderlyingCode 匹配,兼容 EOD 与除权表代码大小写差异;如果同日同代码仍有
// 多条有效记录,ToDictionary 会失败,提示迁移/结算前先清理重复数据。
var dividendDict = diviService.GetExDividendQuery(settleDate)
.ToDictionary(O => O.UnderlyingCode, O => O, StringComparer.OrdinalIgnoreCase);
var eodPriceProvider = new EodPriceProvider(settleDate);
return datas.Select(data =>
{
var eod = data.eod;
var bod = data.bod;
if (data.dividend != null)
// 命中除权数据后仍沿用原有股票结算分支:只重算除权后的收盘价和数量,
// 并保留原 Pv 的正负方向。其他 TradeType 当前不进入该分支,避免扩大
// 本次查询重构的业务范围。
if (dividendDict.TryGetValue(eod.UnderlyingCode, out var dividend))
{
if (data.eod.TradeType == "股票")
{
var SettlePrice = eodPriceProvider.GetPrice(data.eod.UnderlyingCode, SettlementTypeEnum.ClosePrice);
SettlePrice = diviService.GetPrice(SettlePrice, data.dividend);
var amount = diviService.GetPositionAmount(data.eod.Amount, data.dividend);
SettlePrice = diviService.GetPrice(SettlePrice, dividend);
var amount = diviService.GetPositionAmount(data.eod.Amount, dividend);
eod.Pv = eod.Pv > 0 ? Math.Abs(amount * SettlePrice) : -Math.Abs(amount * SettlePrice);
}
}
@@ -104,14 +112,14 @@ namespace YLErp.Modules.EodModule
predicate = PredicateBuilder.Create<TPos>(n => n.ValueDate == settleDate).And(predicate);
}
// 带风险数据的重载与上面的持仓重载采用相同策略:除权记录不参与 SQL 左连接,
// 先完成 EOD、BOD、Risk 的行级关联,再在内存中按标的代码查找唯一除权记录,
// 防止除权表重复行复制风险记录。
var query = from eod in DbContext.Set<TPos>().AsNoTracking().Where(predicate)
join bod in DbContext.BodTradePosition.Where(n => n.ValueDate == bodDate)
on new { eod.BookId, eod.TradeType, eod.PositionType, eod.UnderlyingCode, ExchangeOptionCode = eod.ExchangeOptionCode ?? string.Empty }
equals new { bod.BookId, bod.TradeType, bod.PositionType, bod.UnderlyingCode, ExchangeOptionCode = bod.ExchangeOptionCode ?? string.Empty } into t_bod
from bod in t_bod.DefaultIfEmpty()
join dividend in DbContext.ex_dividend_info.AsNoTracking().Where(O => O.ExDividendDate == settleDate && O.ValidStatus)
on eod.UnderlyingCode equals dividend.UnderlyingCode into t_dividend
from dividend in t_dividend.DefaultIfEmpty()
join risk in DbContext.Set<TRisk>().AsNoTracking().Where(n => n.ValueDate == settleDate && n.TradeId > 0) on new { eod.ValueDate, eod.TradeId } equals new { risk.ValueDate, risk.TradeId } into risk_t
from risk in risk_t.DefaultIfEmpty()
select new
@@ -123,24 +131,28 @@ namespace YLErp.Modules.EodModule
bod.Cost,
//bod.AveragePrice
},
dividend,
risk
};
var datas = query.ToArray();
var diviService = new TradeModule.DealModule.DividendService(OptUser);
// 与无风险重载保持同一数据来源、日期条件和大小写无关的代码匹配规则;重复
// 有效记录会在这里显式失败,而不是让一条持仓对应多条风险结果。
var dividendDict = diviService.GetExDividendQuery(settleDate)
.ToDictionary(O => O.UnderlyingCode, O => O, StringComparer.OrdinalIgnoreCase);
var eodPriceProvider = new EodPriceProvider(settleDate);
return datas.Select(data =>
{
var pos = data.eod;
var bod = data.bod;
if (data.dividend != null)
// 风险对象的除权 Pv 重算规则与上一个重载保持一致,仅在股票交易类型下执行。
if (dividendDict.TryGetValue(pos.UnderlyingCode, out var dividend))
{
if (data.eod.TradeType == "股票")
{
var settlePrice = eodPriceProvider.GetPrice(data.eod.UnderlyingCode, SettlementTypeEnum.ClosePrice);
settlePrice = diviService.GetPrice(settlePrice, data.dividend);
var amount = diviService.GetPositionAmount(data.eod.Amount, data.dividend);
settlePrice = diviService.GetPrice(settlePrice, dividend);
var amount = diviService.GetPositionAmount(data.eod.Amount, dividend);
pos.Pv = pos.Pv > 0 ? Math.Abs(amount * settlePrice) : -Math.Abs(amount * settlePrice);
}
}
+15 -3
View File
@@ -62,6 +62,14 @@ SwapModule/
│ ├── DirectionRatio 方向因子(LongShort + ReceivePay
│ └── PositionValueCalc 持仓价值汇总(利息端 + 浮动端)
├── Accrual/ 计息(生产实现,自洽域)
│ ├── InterestMath 共用数学:Round/AccrualDays/FundingLegPrecision + AccrualBoundary/InterestResult
│ ├── SimpleInterestAccrual 单利纯函数(AccrueEod 单日 + AccruePeriod 多日)
│ ├── CompoundInterestAccrual 复利纯函数(EodBasis/AccrueEod/AccruePeriod
│ ├── AccrualPolicy 计息政策(算头算尾/单复利/重置周期/年化)
│ ├── AccrualTrace 计息 trace 收集器(SwapCalcTrace.Write 常驻落盘)
│ └── FundingLegRate all-in 利率值对象
├── SwapDealService.cs 盘中平仓/互换主逻辑
├── SwapEodPositionService.cs EOD 日终归档主逻辑
├── SwapDealIndexFixer.cs SwapDealService 专用取价器(委托 TryGetFloatRate
@@ -72,12 +80,16 @@ SwapModule/
```
Interest/
├── SwapInterest.cs 纯函数库(AccrueSimple/AccrueCompound/ApplyUnwind
├── IIndexFixer.cs 取价接口
── IndexFixerBase.cs 取价日计算工具
└── Fr007IndexFixer.cs FR007 取价生产实现(调 EodPriceQueryService
── IndexFixerBase.cs 取价日计算工具
```
> 注:① `Fr007IndexFixer.cs`(FR007 取价生产实现)在 SwapModule 下,不在本目录。
> ② 2026-08 计息类型(InterestMath/AccrualBoundary/InterestResult/AccrualTrace)已整体迁至 SwapModule/Accrual/
> Core 不再持有计息实现。原 Core 层 SwapInterest 的算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/
> AccrueUnrealized/ToInterestRate)与 AccrualContext/InterestRate 从未接线(生产走 Accrual/ 目录),作为孤儿死代码删除——
> 其舍入/rollover 口径与生产实现已分叉,若将来重建须先补对账测试,勿凭记忆复原。
## InterestModeEnum(显式赋值,DB 契约)
```
@@ -1,5 +1,3 @@
using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
@@ -11,7 +9,7 @@ namespace YLErp.Modules.SwapModule.Accrual;
/// </summary>
public sealed class AccrualPolicy
{
/// <summary>算头算尾约定(复用 SwapInterest 已有的 AccrualBoundary,物理上杜绝 calcFirst/calcLast 传反)。</summary>
/// <summary>算头算尾约定(AccrualBoundary,物理上杜绝 calcFirst/calcLast 传反)。</summary>
public AccrualBoundary Convention { get; }
/// <summary>是否复利(利滚利)。来自 DB 的 InterestTypeEnum;单利=false,复利=true。</summary>
@@ -1,49 +0,0 @@
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
/// 融资腿逐日计息的跨日状态(不可变值对象)。
/// 这是"待实现利息"在日间滚动的快照,区别于已落库的 <c>swap_flow_event</c>。
///
/// 旧字段 → 领域命名映射(DB 列不可改,仅在边界处适配;本类内部一律用下列自描述名):
/// <list type="table">
/// <item><term>TdInterestPrincipal</term><description>逐日滚动的计息本金 → <see cref="AccrualPrincipal"/></description></item>
/// <item><term>InterestIncomeSum</term><description>累计待实现利息 → <see cref="UnrealizedInterest"/></description></item>
/// <item><term>consumedInterest</term><description>历史已实现利息(legacy) → <see cref="RealizedInterest"/></description></item>
/// <item><term>ValueDate</term><description>快照截至日 → <see cref="ValueDate"/>EOD 续接起算日,Bug C / 5-11 跳过需据此判断从哪天接续)。</description></item>
/// </list>
/// </summary>
public readonly struct AccrualState
{
/// <summary>用于计算当日利息的计息本金。单利=名义本金基数;复利=本金+累计利息。</summary>
public decimal AccrualPrincipal { get; }
/// <summary>累计待实现(未平仓)利息。</summary>
public decimal UnrealizedInterest { get; }
/// <summary>历史各次平仓已确认的已实现利息,从剩余待实现中扣除。</summary>
public decimal RealizedInterest { get; }
/// <summary>快照截至日(来自 eod_swap_position.ValueDate)。编排层据此判断计息区间起点,避免 5-11 等"跳过日"误重算。</summary>
public DateTime ValueDate { get; }
public AccrualState(decimal accrualPrincipal, decimal unrealizedInterest, decimal realizedInterest, DateTime valueDate)
=> (AccrualPrincipal, UnrealizedInterest, RealizedInterest, ValueDate) = (accrualPrincipal, unrealizedInterest, realizedInterest, valueDate);
/// <summary>向后兼容:未携带快照日期时(如纯内存构造)用默认日。</summary>
public AccrualState(decimal accrualPrincipal, decimal unrealizedInterest, decimal realizedInterest)
: this(accrualPrincipal, unrealizedInterest, realizedInterest, default) { }
/// <summary>空状态(新开仓首个计息日之前)。</summary>
public static readonly AccrualState Zero = new(0m, 0m, 0m);
/// <summary>
/// 从上一日日终归档 <see cref="eod_swap_position"/> 适配(边界适配:DB 列名 → 领域名)。
/// 仅映射计息状态;名义本金基数 / 平仓比例 / 已实现利息等由调用方另行传入。
/// </summary>
public static AccrualState FromPreviousEod(eod_swap_position previousEod)
=> previousEod == null || previousEod.id == 0
? Zero
: new AccrualState(previousEod.TdInterestPrincipal, previousEod.InterestIncomeSum, 0m, previousEod.ValueDate);
}
@@ -1,14 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using YLErp.Derivatives.Interest;
namespace YLErp.Core.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
/// 计息过程追踪收集器(值对象,非日志)。
/// 计息过程追踪收集器(值对象,非日志)。2026-08 自 Core 层(YLErp.Core.Interest)迁入 DAL
/// 与 Simple/CompoundInterestAccrual、AccrualBoundary 同处一域,Core 不再持有计息类型。
///
/// <para><b>为什么是收集器而不是日志调用</b>:计息数学(SwapInterest / FundingLegAccrual)必须保持纯函数、
/// <para><b>为什么是收集器而不是日志调用</b>:计息数学(Simple/CompoundInterestAccrual)必须保持纯函数、
/// 可单测、不依赖 NLog;但按工程铁律,关键路径日志须<b>无条件常驻落盘</b>(出问题时事后翻日志定位,不能依赖开关)。
/// 折中:纯函数把"发生了什么"记录为结构化条目写入本收集器,由<b>适配器(IO 边界)</b>统一经
/// <c>SwapCalcTrace.Write</c> 常驻落盘。落盘职责归一处,计息代码零日志依赖、保持干净。</para>
@@ -1,6 +1,3 @@
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
@@ -9,7 +6,7 @@ namespace YLErp.Modules.SwapModule.Accrual;
/// </summary>
public static class CompoundInterestAccrual
{
private const int Precision = SwapInterest.FundingLegPrecision;
private const int Precision = InterestMath.FundingLegPrecision;
/// <summary>复利日终计息基数(单一真相源,纯函数与调用方共用):
/// 重置日 = notional + 累计利息×剩余比例(利息并入本金);非重置日 = priorNotional(昨日滚动基数)。
@@ -52,8 +49,8 @@ public static class CompoundInterestAccrual
var totalAccrued = priorAccrued * unwindFraction + dayInterest;
var result = new InterestResult(
SwapInterest.Round(totalAccrued, Precision),
SwapInterest.Round(tdInterest, Precision));
InterestMath.Round(totalAccrued, Precision),
InterestMath.Round(tdInterest, Precision));
trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
@@ -107,7 +104,7 @@ public static class CompoundInterestAccrual
var segIncludeStart = (si == 0) ? boundary.IncludeStart : true;
var segIncludeEnd = isLastSegment ? boundary.IncludeEnd : false;
var days = SwapInterest.AccrualDays(segmentRates[si].StartDate, segEnd,
var days = InterestMath.AccrualDays(segmentRates[si].StartDate, segEnd,
AccrualBoundary.Of(segIncludeStart, segIncludeEnd));
if (days <= 0) continue;
@@ -124,8 +121,8 @@ public static class CompoundInterestAccrual
accrued -= realizedInterest * unwindFraction;
var result = new InterestResult(
SwapInterest.Round(accrued, Precision),
SwapInterest.Round(accrued, Precision));
InterestMath.Round(accrued, Precision),
InterestMath.Round(accrued, Precision));
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
@@ -0,0 +1,104 @@
namespace YLErp.Modules.SwapModule.Accrual;
// ─────────────────────────────────────────────────────────────────────────────
// 词汇表(本文件只允许出现下列用词,同一概念不得出现第二种叫法)
//
// 概念 唯一用词 与既有代码的对应
// ───────────────────────────────────────────────────────────────────
// 区间起点/终点 Start / End startDate / endDate
// 计息 Accrue CalcDailySimpleInterest / CalcDailyCompoundInterest
// 平仓 Unwind unwindPercent(既有字段 closePercent
// 已实现利息 Realized realizedInterestlegacy 字段 consumedInterest
// 待实现收益 Unrealized 预付金模式下的待实现收益余额
// 计息基数 principal principal / dynomicPrincipal
// 年化天数 annualDays tradeExtend.ExtendObj.AnnualDays
//
// 入参一律沿用既有代码的字段名,调用点两边读起来同名,不产生心智翻译成本。
// 出参改用自描述名(Accrued / AccruedToday),因为 "Td" 对新读者是黑话。
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// 计息区间边界(算头 / 算尾)。
/// 用具名值取代两个相邻 bool,物理上杜绝 calcFirst / calcLast 传反这一类历史缺陷。
/// </summary>
public readonly struct AccrualBoundary
{
/// <summary>算头:含 startDate。</summary>
public bool IncludeStart { get; }
/// <summary>算尾:含 endDate。</summary>
public bool IncludeEnd { get; }
private AccrualBoundary(bool includeStart, bool includeEnd)
=> (IncludeStart, IncludeEnd) = (includeStart, includeEnd);
/// <summary>算头算尾 [start, end]。</summary>
public static readonly AccrualBoundary Both = new(true, true);
/// <summary>算头不算尾 [start, end)。</summary>
public static readonly AccrualBoundary StartOnly = new(true, false);
/// <summary>不算头算尾 (start, end]。</summary>
public static readonly AccrualBoundary EndOnly = new(false, true);
/// <summary>不算头不算尾 (start, end)。</summary>
public static readonly AccrualBoundary None = new(false, false);
/// <summary>由既有 calcFirst / calcLast 布尔对构造,供旧调用方渐进迁移。</summary>
public static AccrualBoundary Of(bool includeStart, bool includeEnd) => new(includeStart, includeEnd);
public override string ToString()
=> $"{(IncludeStart ? "" : "")}{(IncludeEnd ? "" : "")}";
}
/// <summary>
/// 计息结果。Accrued → 记账字段 InterestAmount / InterestProfitSumAccruedToday → TdInterestAmount。
/// </summary>
public readonly struct InterestResult
{
/// <summary>区间累计应计利息。</summary>
public decimal Accrued { get; }
/// <summary>末日(当日)应计利息。</summary>
public decimal AccruedToday { get; }
public InterestResult(decimal accrued, decimal accruedToday)
=> (Accrued, AccruedToday) = (accrued, accruedToday);
public static readonly InterestResult Zero = new(0m, 0m);
public override string ToString() => $"Accrued={Accrued}, AccruedToday={AccruedToday}";
}
/// <summary>
/// 利息腿共用数学工具:舍入、应计天数、精度常量。
///
/// <para><b>沿革</b>2026-08 自 Core 层 SwapInterest 迁入 DAL(生产消费面整体搬家)。
/// 原 SwapInterest 的算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/AccrueUnrealized
/// 与 AccrualContext/InterestRate 始终未接线(生产计息走本目录 Simple/CompoundInterestAccrual
/// 两者舍入与 rollover 口径已分叉),作为孤儿死代码删除——接线前须先补对账,勿凭记忆重建。</para>
///
/// <para>为何不复用 Qdp 的 IDayCount
/// a. 语义——Qdp 的 DaysInPeriod = end start 是写死的半开区间,只能表达四种算头算尾中的一种;
/// b. 精度——Qdp 返回 double 年化系数,本系统 decimal 对账;
/// c. 依赖方向——Qdp 用自有 Date 类型,引入会让本模块反向依赖定价库。</para>
/// </summary>
public static class InterestMath
{
/// <summary>资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。
/// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。</summary>
public const int FundingLegPrecision = 12;
/// <summary>应计天数。边界规则由日期区间表达,计息函数内不再出现 flag 分支。</summary>
public static int AccrualDays(DateTime startDate, DateTime endDate, AccrualBoundary boundary)
{
var s = boundary.IncludeStart ? startDate : startDate.AddDays(1);
var e = boundary.IncludeEnd ? endDate : endDate.AddDays(-1);
var days = (int)(e - s).TotalDays + 1; // 含两端
return days < 0 ? 0 : days;
}
/// <summary>统一舍入:MidpointRounding.AwayFromZero。所有计息路径收口到此处,避免散落的 Math.Round 不一致。</summary>
public static decimal Round(decimal value, int precision)
=> Math.Round(value, precision, MidpointRounding.AwayFromZero);
}
@@ -1,6 +1,3 @@
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
@@ -9,7 +6,7 @@ namespace YLErp.Modules.SwapModule.Accrual;
/// </summary>
public static class SimpleInterestAccrual
{
private const int Precision = SwapInterest.FundingLegPrecision;
private const int Precision = InterestMath.FundingLegPrecision;
/// <summary>
/// 单利日终计息(替换 CalcDailySimpleInterestByEod 的纯数学部分)。
@@ -38,8 +35,8 @@ public static class SimpleInterestAccrual
var totalAccrued = priorAccrued + dayInterest;
var result = new InterestResult(
SwapInterest.Round(totalAccrued, Precision),
SwapInterest.Round(tdInterest, Precision));
InterestMath.Round(totalAccrued, Precision),
InterestMath.Round(tdInterest, Precision));
trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
@@ -85,7 +82,7 @@ public static class SimpleInterestAccrual
var includeStart = effectiveStart == startDate ? boundary.IncludeStart : true;
var isLastSegment = si == segmentRates.Count - 1;
var segBoundary = AccrualBoundary.Of(includeStart, isLastSegment && boundary.IncludeEnd);
var days = SwapInterest.AccrualDays(effectiveStart, segEnd, segBoundary);
var days = InterestMath.AccrualDays(effectiveStart, segEnd, segBoundary);
if (days <= 0) { segStart = segEnd; continue; }
var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate;
@@ -98,8 +95,8 @@ public static class SimpleInterestAccrual
}
var result = new InterestResult(
SwapInterest.Round(accrued, Precision),
SwapInterest.Round(accruedUnscaled, Precision));
InterestMath.Round(accrued, Precision),
InterestMath.Round(accruedUnscaled, Precision));
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
@@ -0,0 +1,19 @@
using System;
using System.Linq;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// eod_swap_position 查询收口(Query Object)。
/// 规则"某交易某日日终的有效持仓 = SwapTradeId 匹配 + ValueDate 匹配 + 未作废(!Invalid)"集中于此,
/// 避免多处复制同一谓词导致语义漂移(漏写 !Invalid 即静默出 bug)。
/// 仅返回 IQueryable,不调用 SaveChanges,不破坏跟踪/Include/事务边界。
/// </summary>
public static class EodSwapPositionQueries
{
public static IQueryable<eod_swap_position> ActiveByTradeAndDate(
this IQueryable<eod_swap_position> query, int tradeId, DateTime valueDate)
=> query.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid);
}
}
@@ -1,4 +1,4 @@
using YLErp.DBModels;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule.FundingLegs;
@@ -7,7 +7,7 @@ namespace YLErp.Modules.SwapModule.FundingLegs;
/// 计息基数 = 标的期初含费全价(PosiGrossPrice/EntryDirtyPrice) × 数量。
/// "期初(Entry)"是关键——建仓时点的全价,非当前估值全价。
/// 主路径 CalcNotionalByMode 公式与合约名义本金规模(2)相同;
/// 差异在衡泰路径会乘 grossPrice 折算(SwapDealService.GetUnwindInterestsByHT),
/// 衡泰回执折算路径(原 SwapDealService.GetUnwindInterestsByHT 乘 grossPrice 折算)已随死链清理移除;
/// 以及 EOD 复利部分平仓后直接返回剩余本金(禁止反推,SwapEodPositionService:1458-1465)。
/// </summary>
public sealed class UnderlyingEntryFullPriceLeg : IFundingLegStrategy
@@ -0,0 +1,85 @@
namespace YLErp.Modules.SwapModule;
/// <summary>
/// GetInterests 参数对象(2026-08 参数显式化)。
///
/// 动机:原 GetInterests 20 个位置参数中,名义本金簇(posiNotionalValue/closePosiNotionalValue/closePercent
/// 在【盘中平仓】与【EOD 平仓后收盘】两类场景下语义相反(详见 GetInterests "根因位置"注释与
/// GetInterestsEntrySemanticsTest 的口径留档),位置参数无法表达该约束。
///
/// 用法:只能经两个场景工厂构造——工厂形参名即该场景语义(平仓前剩余 / 平仓后剩余 / 实际平掉额),
/// 物理上防止两套语义混传。needPrice/grossPrice(原方法死参数)与 posiLong/posiShortNotionalValue
/// (多空组合子系统删除后计息链零消费的管道死参数)均不承载。
/// </summary>
public sealed class InterestCalcRequest
{
public trade Td { get; }
public trade_extend TradeExtend { get; }
public DateTime ValueDate { get; }
public DateTime UnwindDate { get; }
public List<eod_swap_position> EodPositions { get; }
public List<swap_position> Positions { get; }
/// <summary>当日适用名义本金。语义随场景:盘中=平仓【前】剩余;EOD平仓后收盘=平仓【后】剩余;EOD增量=当前剩余。</summary>
public decimal PosiNotionalValue { get; }
/// <summary>本次实际平掉本金(两场景恒同义)。mode2 无条件覆盖 / mode9 全平兜底的输入。</summary>
public decimal ClosePosiNotionalValue { get; }
/// <summary>平仓比例。语义随场景:盘中=实际比例(B 占剩余);EOD平仓后收盘=恒1(全额结息)。</summary>
public decimal ClosePercent { get; }
public int EventType { get; }
public bool TdClose { get; }
public decimal OrginPv { get; }
public bool Add { get; }
public bool NewCalcLast { get; }
public List<swap_flow_event> CloseList { get; }
private InterestCalcRequest(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePercent,
int eventType, bool tdClose, decimal orginPv,
bool add, bool newCalcLast, List<swap_flow_event> closeList)
{
Td = td; TradeExtend = tradeExtend; ValueDate = valueDate; UnwindDate = unwindDate;
EodPositions = eodPositions; Positions = positions;
PosiNotionalValue = posiNotionalValue; ClosePosiNotionalValue = closePosiNotionalValue;
ClosePercent = closePercent; EventType = eventType; TdClose = tdClose; OrginPv = orginPv;
Add = add; NewCalcLast = newCalcLast; CloseList = closeList;
}
/// <summary>
/// 【盘中平仓/互换结息】场景(→ GetIntradayUnwindInterestssettment:false 盘中重放)。
/// </summary>
/// <param name="preCloseNotional">平仓【前】实时剩余本金(原 GetUnwindInterests.stockEqvNotional)。</param>
/// <param name="closedNotional">本次实际平掉本金(= preCloseNotional × closePercentRemaining)。</param>
/// <param name="closePercentRemaining">平仓比例,B 语义【占剩余】(前端传 A 占期初须先经 ToRemainingClosePercent 转换)。</param>
public static InterestCalcRequest IntradayUnwind(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal preCloseNotional, decimal closedNotional, decimal closePercentRemaining,
int eventType, bool tdClose, decimal orginPv,
bool add, bool newCalcLast, List<swap_flow_event> closeList)
=> new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions,
preCloseNotional, closedNotional, closePercentRemaining,
eventType, tdClose, orginPv, add, newCalcLast, closeList);
/// <summary>
/// 【EOD 当日有平仓后的收盘结息】场景(→ CalcEodPostCloseSettleInterestssettment:false 全额结息)。
/// 该场景触发 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底(见其"根因位置"注释,勿删)。
/// </summary>
/// <param name="remainingNotionalAfterClose">平仓【后】剩余本金(GetInterests.posiNotionalValue 形参位)。</param>
/// <param name="closedNotional">本次实际平掉本金。</param>
public static InterestCalcRequest EodPostCloseSettle(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal remainingNotionalAfterClose, decimal closedNotional,
int eventType, bool tdClose, decimal orginPv,
bool add, bool newCalcLast)
=> new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions,
remainingNotionalAfterClose, closedNotional, 1m, // 恒1:本次事件全额结息(非 closeNational / 期初比例)
eventType, tdClose, orginPv, add, newCalcLast, closeList: null);
}
+1 -4
View File
@@ -1,8 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text;
using YLErp.Core.Interest;
using YLErp.Helpers;
using YLErp.Modules.SwapModule.Accrual;
namespace YLErp.Modules.SwapModule
{
+28 -174
View File
@@ -1,9 +1,8 @@
using MoreLinq.Extensions;
using MoreLinq.Extensions;
using Newtonsoft.Json;
using YLErp.BLL;
using YLErp.BLL.Eod;
using YLErp.DBModels.Enums;
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
using YLErp.Helpers;
using YLErp.Modules.DataProviderModule;
@@ -50,8 +49,8 @@ namespace YLErp.Modules.SwapModule
return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve);
}
// 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 SwapInterest.FundingLegPrecision,消除重复定义。
private const int InterestCalculationPrecision = SwapInterest.FundingLegPrecision;
// 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 InterestMath.FundingLegPrecision,消除重复定义。
private const int InterestCalculationPrecision = InterestMath.FundingLegPrecision;
// 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。
private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason)
@@ -211,7 +210,7 @@ namespace YLErp.Modules.SwapModule
public UnwindData InitUnwind(int tradeId)
{
var td = DbContext.trade.Find(tradeId);
var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid);
var positions = DbContext.swap_position.ActiveByTrade(tradeId);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode);
bool commodity = ConsGlobal.InstrumentType.CalcTypeIsFutures(um.UnderlyingInstrumentType);
List<int> eventTyps = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
@@ -277,7 +276,7 @@ namespace YLErp.Modules.SwapModule
// DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径,
// 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归)
decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
Logger.Debug($"[分红-平仓预览] 方案C DividendIn=DividendPending=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
Logger.Info($"[分红-平仓预览] 方案C DividendIn=DividendPending=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
floatEvent.DividendIn = preEodDividendSum;
floatEvent.DividendPending = preEodDividendSum;
floatEvent.UnderlyingCode = position.UnderlyingCode;
@@ -356,7 +355,7 @@ namespace YLErp.Modules.SwapModule
{
var checkEventTypes = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
var td = DbContext.trade.Find(tradeId);
var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid);
var positions = DbContext.swap_position.ActiveByTrade(tradeId);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode);
List<int> eventTypes = new List<int>() { (int)SwapFlowEventTypeEnum., (int)SwapFlowEventTypeEnum. };
var maxIncomeValueDate = GetMaxIncomeValueDate(td);
@@ -412,7 +411,7 @@ namespace YLErp.Modules.SwapModule
// 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
// 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
Logger.Debug($"[分红-收益结算] DividendIn=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
Logger.Info($"[分红-收益结算] DividendIn=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
floatEvent.DividendIn = preEodDividendSum;
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
@@ -461,23 +460,18 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
var allpositions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid).ToList();
var allpositions = DbContext.swap_position.ActiveByTrade(tradeId).ToList();
var origPositions = allpositions.Where(x => x.IsInitial).ToList();
var realPostitions = allpositions.Where(x => !x.IsInitial).ToList();
// 根因修复(多次部分平仓预付金返还错误):见 ResolveInterestLegPositions 注释。
// 迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配)
// 仅对预付金腿以实时腿的剩余本金克隆覆盖,故此处不改任何日终匹配行为。
var positions = ResolveInterestLegPositions(origPositions, realPostitions);
var fpositions = origPositions.Where(x => x.PosiDirection > 0).ToList();
var longPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).ToList();
var shortPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).ToList();
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
List<int> eventTypes = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
var lastEod = DbContext.eod_swap.Where(x => x.ValueDate < unwindDate && x.SwapTradeId == tradeId).OrderByDescending(o => o.ValueDate).FirstOrDefault();
var _preSetteDate = lastEod == null ? unwindDate.AddDays(-1) : lastEod.ValueDate;
List<eod_swap_position> lastEodPositions = new SwapEodPositionService(this).GetPreEodPositions(tradeId, _preSetteDate);//上一交易数据
var posiLongNotionalValue = longPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金
var posiShortNotionalValue = shortPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金
var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); // 当前平仓前的实时剩余本金
var posiNotionalValue = stockEqvNotional * closePercent;// 本次平仓名义本金
var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); // 上一日终的浮动端本金
@@ -487,7 +481,11 @@ namespace YLErp.Modules.SwapModule
&& eventTypes.Contains(x.EventType)
&& x.DataState == (int)SwapFlowDateStateEnum.).ToList();
bool tdClose = closeList.Count > 0;
interests = GetInterests(td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, stockEqvNotional, posiLongNotionalValue, posiShortNotionalValue, posiNotionalValue, closePercent, eventType, tdClose, false, grossPrice ?? 0, orginPv, true, false,false, closeList);
// 显式入口:平仓前剩余本金 + 实际平掉额 + B语义比例,盘中重放(语义见 InterestCalcRequest.IntradayUnwind
interests = GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions,
stockEqvNotional, posiNotionalValue,
closePercent, eventType, tdClose, orginPv, add: true, newCalcLast: false, closeList));
return interests;
}
@@ -609,14 +607,22 @@ namespace YLErp.Modules.SwapModule
/// <param name="eodPositions">上一日终持仓</param>
/// <param name="positions">期初利率端</param>
/// <param name="posiNotionalValue">持仓名义本金</param>
/// <param name="posiLongNotionalValue">多头持仓名义本金</param>
/// <param name="posiShortNotionalValue">空头持仓名义本金</param>
/// <param name="closePosiNotionalValue">平仓名义本金</param>
/// <param name="closePrecent"></param>
/// <param name="eventType"></param>
/// <param name="tdClose"></param>
/// <param name="add"></param>
/// <returns></returns>
/// <summary>
/// 【盘中平仓/互换结息】显式入口——GetInterests(settment:false) 盘中语义的具名封装(2026-08 显式化重构)。
/// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。
/// </summary>
public List<swap_flow_event> GetIntradayUnwindInterests(InterestCalcRequest req)
=> GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions,
req.PosiNotionalValue, req.ClosePosiNotionalValue,
req.ClosePercent, req.EventType, req.TdClose,
req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList);
public List<swap_flow_event> GetInterests(
trade td,
trade_extend tradeExtend,
@@ -625,14 +631,10 @@ namespace YLErp.Modules.SwapModule
List<eod_swap_position> eodPositions,
List<swap_position> positions,
decimal posiNotionalValue,
decimal posiLongNotionalValue,
decimal posiShortNotionalValue,
decimal closePosiNotionalValue,
decimal closePrecent,
int eventType,
bool tdClose,
bool needPrice,
decimal grossPrice,
decimal orginPv,
bool add = false,
bool settment = true,
@@ -811,7 +813,7 @@ namespace YLErp.Modules.SwapModule
{
var preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate);
var sum = preEod == null ? 0m : preEod.PosiDividendSum;
Logger.Debug($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}");
Logger.Info($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}");
return sum;
}
@@ -826,7 +828,7 @@ namespace YLErp.Modules.SwapModule
.Where(x => x.ValueDate <= dealDate)
.OrderByDescending(o => o.ValueDate).FirstOrDefault();
var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate;
Logger.Debug($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}");
Logger.Info($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}");
return QueryPreEodPosition(tradeId, positionId, preEodDate);
}
@@ -1097,7 +1099,7 @@ namespace YLErp.Modules.SwapModule
}
return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, swap, posiPrincipal,
closePrincipal, closePercent, annualDays, eventType, preEod, false,
closePrincipal, closePercent, annualDays, eventType, preEod,
orginPv, calcFirst, calcLast, consumedInterest);
}
/// <summary>
@@ -1152,7 +1154,6 @@ namespace YLErp.Modules.SwapModule
int annualDays,
int eventType,
eod_swap_position preEodPosition,
bool needPrice,
decimal orginPv,
bool calcFirst,
bool calcLast,
@@ -1456,7 +1457,7 @@ namespace YLErp.Modules.SwapModule
SwapCalcTrace.Write(interestTrace);
// flowEvent.InterestPrincipal:当日计息基数(已按平仓比例缩放)——下游 EOD 用它播种次日 TdInterestPrincipal。
// 复用 CompoundEodBasis 单一真相源(与 AccrueCompoundEod 内部同一公式)。
// 复用 CompoundEodBasis 单一真相源(与 CompoundInterestAccrual.AccrueEod 内部同一公式,见其 EodBasis 调用)。
flowEvent.InterestPrincipal = CompoundInterestAccrual.EodBasis(
isResetDay, posiPrincipal, preEodPosition.InterestProfitSum, remainingFraction,
preEodPosition.TdInterestPrincipal) * closePercent;
@@ -1570,7 +1571,7 @@ namespace YLErp.Modules.SwapModule
{
unwindPriceFee = decimal.Parse(unwindPriceFee.ToString("F10"));
var td = DbContext.trade.Find(tradeid);
var positions = DbContext.swap_position.Where(x => x.SwapTradeId == td.id && !x.Invalid);
var positions = DbContext.swap_position.ActiveByTrade(td.id);
List<int> eventTypes = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
var dealDate = valueDate;
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
@@ -1696,153 +1697,6 @@ namespace YLErp.Modules.SwapModule
return data.ValueAddedTax ?? 0;
}
/// <summary>
/// 衡泰新增平仓事件
/// </summary>
/// <param name="td"></param>
/// <param name="valueDate"></param>
/// <param name="markClosePnl"></param>
/// <param name="unwindQty"></param>
/// <param name="allClose"></param>
public void AutoSwapUnwindFromConsumer(trade td, DateTime valueDate, DateTime payDate, decimal markClosePnl, decimal tradeinfFee, decimal interestAmount, decimal fee, decimal unwindQty, bool allClose)
{
List<int> eventTypes = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
var dealDate = valueDate;
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
td.trade_extend = tradeExtend;
var position = DbContext.swap_position.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial && !x.Invalid).FirstOrDefault();
var preDealDate = GetPreDealDate(td.id, dealDate, eventTypes);
swap_flow_event floatEvent = new swap_flow_event();
UnwindData unwindData = new UnwindData();
unwindData.CloseType = 2;
unwindData.StartDate = td.TradeDate.Value;
if (preDealDate.HasValue)
{
unwindData.StartDate = preDealDate.Value;
}
unwindData.ValueDate = dealDate;
floatEvent.EventDate = dealDate;
unwindData.UnwindDate = QdpCalendarHelper.GetNonHoliday(dealDate.AddDays(1));
floatEvent.UnwindDate = unwindData.UnwindDate;
floatEvent.PayDate = payDate;
unwindData.PayDate = floatEvent.PayDate;
floatEvent.SwapTradeId = td.id;
floatEvent.SwapTradeNo = td.TradeNumber;
unwindData.SwapTradeId = td.id;
unwindData.StructureType = td.StructureType;
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = position.PosiQuantity;
unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = Convert.ToDecimal(td.TradeAmount);
unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
unwindData.CloseMethod = allClose ? (int)CloseMethodEnum. : (int)CloseMethodEnum.;
unwindData.ClosePercent = allClose ? 1 : unwindQty / unwindData.NotionalQty;
unwindData.CloseNotionalValue = allClose ? unwindData.PosiNotionalValue : unwindQty;
unwindData.CloseQty = allClose ? unwindData.PositionQty : unwindQty;
if (position != null)
{
decimal floatRatio = position.PosiDirection == 1 ? 1m : -1m;
floatEvent.PositionId = position.id;
floatEvent.EventType = (int)SwapEventTypeEnum.;
floatEvent.EventReason = "接口合约终止交易";
floatEvent.DividendIn = 0;
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
floatEvent.BeforeCloseFee = position.PosiTradingFee + position.PosiTradingFeePending;
floatEvent.PayDirection = position.PosiDirection;
floatEvent.PosiGrossPrice = position.PosiGrossPrice;
floatEvent.PosiNetPrice = position.PosiNetPrice;
floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice;
floatEvent.TradingFeePending = position.PosiTradingFeePending * unwindData.ClosePercent;
floatEvent.TradingFee = tradeinfFee - floatEvent.TradingFeePending;
floatEvent.MarkClosePnl = markClosePnl;
floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize;
floatEvent.PositionType = position.PositionType;
floatEvent.Quantity = position.PosiQuantity;
floatEvent.PositionQty = 0;
floatEvent.ContractSize = position.ContractSize;
floatEvent.DataState = (int)SwapFlowDateStateEnum.;
floatEvent.InterestMode = position.InterestMode;
floatEvent.TradingAmount = unwindData.CloseQty;
floatEvent.ClientId = td.ClientId;
floatEvent.OptLog = "衡泰同步";
floatEvent.SetOpt(UserInfo);
}
unwindData.FlowEvents.Add(floatEvent);
var interestPositions = GetUnwindInterestsByHT(unwindData, td, interestAmount, fee);
unwindData.FlowEvents.AddRange(interestPositions);
CalcCloseAmount(unwindData);
DealUnwind(unwindData, td, "合约终止接口回执");
}
private List<swap_flow_event> GetUnwindInterestsByHT(UnwindData unwindData, trade td, decimal interestAmount, decimal fee)
{
List<swap_flow_event> interests = new List<swap_flow_event>();
var allpositions = DbContext.swap_position.Where(x => x.SwapTradeId == unwindData.SwapTradeId && !x.Invalid && x.IsInitial && x.PosiDirection > 0).ToList();
var position = allpositions.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).FirstOrDefault();
if (position == null)
{
return interests;
}
var grossPrice = allpositions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice ?? 0;
var _closePosiNotionalValue = unwindData.CloseNotionalValue;
var _posiNotionalValue = unwindData.PosiNotionalValue;
var newClosePercent = unwindData.ClosePercent;
foreach (var item in allpositions)
{
var positionClone = item.Clone();
var swapIntervalToday = position.SwapIntervalList.OrderByDescending(o => o.Date).FirstOrDefault();
if (item.InterestMode == (int)InterestModeEnum.)
{
_closePosiNotionalValue = item.InterestPrincipalFix;
_posiNotionalValue = item.InterestPrincipalFix;
newClosePercent = 1m;
}
else if (item.InterestMode == (int)InterestModeEnum.)
{
_closePosiNotionalValue = _posiNotionalValue * grossPrice * newClosePercent;
_posiNotionalValue = _posiNotionalValue * grossPrice;
}
else if (MarginModes.Contains(item.InterestMode))
{
_closePosiNotionalValue = 0;
positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection);
}
decimal rate = item.InterestRateDefault;
if (swapIntervalToday != null)//当日无适用观察日
{
rate = swapIntervalToday.Rate;
}
swap_flow_event interest = new swap_flow_event();
interest.SwapTradeId = td.id;
interest.SwapTradeNo = td.TradeNumber;
interest.EventType = (int)SwapEventTypeEnum.;
interest.EventReason = "衡泰同步平仓";
interest.EventDate = unwindData.ValueDate;
interest.PositionId = item.id;
interest.InterestDirection = positionClone.InterestDirection;
interest.InterestRate = rate;
interest.InterestPrincipal = _closePosiNotionalValue;
interest.InterestSwapInterval = item.InterestSwapInterval;
interest.InterestMode = item.InterestMode;
interest.FloatRate = item.FloatRate;
interest.DataState = (int)SwapFlowDateStateEnum.;
interest.ClientId = td.ClientId;
interest.UnwindDate = unwindData.ValueDate;
interest.PayDate = unwindData.PayDate;
if (position != null && item.id == position.id)
{
interest.InterestAmount = interestAmount;
interest.TdInterestAmount = interestAmount;
interest.InterestClosePnL = interestAmount;
interest.InterestFee = fee;
}
UpdateDbOption(interest);
interests.Add(interest);
}
return interests;
}
private void DealUnwind(UnwindData unwindData, trade td, string actionMsg = "系统操作_自动平仓")
{
int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut._平仓费, unwindData.ValueDate);
@@ -82,26 +82,40 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 计算利息腿利息明细(生产: new SwapDealService(this).GetInterests;测试: 用StubSwapDealService内存算)
/// 计算利息腿利息明细(生产: new SwapDealService(this).GetInterests;测试: 用StubSwapDealService内存算)
/// 参数与 SwapDealService.GetInterests 完全一致,保证行为不变。
/// needPrice/grossPrice 死参数已随 2026-08 收口删除,两侧同步。)
/// </summary>
protected virtual List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent,
int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv,
int eventType, bool tdClose,
decimal orginPv,
bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
/// <summary>
/// 【EOD 当日有平仓后的收盘结息】显式入口——原 SaveAutoEodWithCloseInterestPosition 直调
/// CalcSwapInterests(settment:false) 的具名封装(2026-08 显式化重构)。
/// 语义契约见 InterestCalcRequest.EodPostCloseSettle 工厂注释(平仓后剩余 + 实际平掉额 + 恒1全额结息,
/// 触发 GetInterests 内 mode2/mode9 本金修正)。计息走 CalcUnwindInterest 全区间重放。
/// 默认实现仍经 CalcSwapInterests 转发,保持既有测试替身对该虚接缝的拦截不变。
/// </summary>
protected virtual List<swap_flow_event> CalcEodPostCloseSettleInterests(InterestCalcRequest req)
=> CalcSwapInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions,
req.PosiNotionalValue,
req.ClosePosiNotionalValue, req.ClosePercent, req.EventType, req.TdClose,
req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList);
// FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复)
/// <summary>查找交易扩展(生产: DbContext.trade_extend;测试: 内存字典)</summary>
@@ -119,7 +133,7 @@ namespace YLErp.Modules.SwapModule
/// <summary>查找交易持仓(生产: DbContext.swap_position.Where;测试: 内存列表)</summary>
protected virtual List<swap_position> FindSwapPositions(int swapTradeId)
{
return DbContext.swap_position.Where(x => x.SwapTradeId == swapTradeId && !x.Invalid).ToList();
return DbContext.swap_position.ActiveByTrade(swapTradeId).ToList();
}
/// <summary>查找框架合约日终汇总(生产: DbContext.eod_swap.FirstOrDefault;测试: 内存字典)</summary>
@@ -355,7 +369,7 @@ namespace YLErp.Modules.SwapModule
var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice);
var grossPrice = curEodPosis.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice ?? 0;
//处理利息腿
DealInterests(interestList, eodPositions, todyEodPositions, settleDate, td, flowEvents, autoInterests, lastEodSwap, posiLongNotional, posiShortNotional, closePosiNotional, grossPrice, orginPv);
DealInterests(interestList, eodPositions, todyEodPositions, settleDate, td, flowEvents, autoInterests, lastEodSwap, posiLongNotional + posiShortNotional, closePosiNotional, grossPrice, orginPv);
//获取自动互换的 interval 信息,用于确定结算日期
IntervalModel autoInterval = null;
foreach (var interest in interestList)
@@ -427,8 +441,7 @@ namespace YLErp.Modules.SwapModule
List<swap_flow_event> flowEvents,
List<swap_flow_event> autoInterests,
eod_swap lastEodSwap,
decimal posiLongNational,
decimal posiShortNational,
decimal posiTotalNotional,
decimal closeNational,
decimal grossPrice,
decimal orginPv)
@@ -439,7 +452,7 @@ namespace YLErp.Modules.SwapModule
Log.Info($"[DealInterests] 参数验证 - settleDate: {settleDate:yyyy-MM-dd}, td.id: {td?.id}, td.TradeNumber: {td?.TradeNumber}");
Log.Info($"[DealInterests] 参数验证 - interestList.Count: {interestList?.Count ?? 0}, eodPositions.Count: {eodPositions?.Count ?? 0}, todyEodPositions.Count: {todyEodPositions?.Count ?? 0}");
Log.Info($"[DealInterests] 参数验证 - flowEvents.Count: {flowEvents?.Count ?? 0}, autoInterests.Count: {autoInterests?.Count ?? 0}");
Log.Info($"[DealInterests] 参数验证 - posiLongNational: {posiLongNational}, posiShortNational: {posiShortNational}, closeNational: {closeNational}, grossPrice: {grossPrice}, orginPv: {orginPv}");
Log.Info($"[DealInterests] 参数验证 - posiTotalNotional: {posiTotalNotional}, closeNational: {closeNational}, grossPrice: {grossPrice}, orginPv: {orginPv}");
// 验证关键参数
if (td == null)
@@ -487,7 +500,7 @@ namespace YLErp.Modules.SwapModule
{
if (!hasClose)//当日无平仓
{
var _autoInterests = SaveAutoEodInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, lastEodSwap, posiLongNational, posiShortNational, grossPrice, orginPv);
var _autoInterests = SaveAutoEodInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, lastEodSwap, posiTotalNotional, grossPrice, orginPv);
if (_autoInterests.Count > 0)
{
autoInterests.AddRange(_autoInterests);
@@ -495,7 +508,7 @@ namespace YLErp.Modules.SwapModule
}
else
{
var _autoInterests = SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiLongNational, posiShortNational, swapEvents, closeNational, true, grossPrice, orginPv);
var _autoInterests = SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiTotalNotional, swapEvents, closeNational, true, grossPrice, orginPv);
if (_autoInterests.Count > 0)
{
autoInterests.AddRange(_autoInterests);
@@ -508,11 +521,11 @@ namespace YLErp.Modules.SwapModule
}
else if (hasClose)
{
SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiLongNational, posiShortNational, swapEvents, closeNational, false, grossPrice, orginPv);
SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiTotalNotional, swapEvents, closeNational, false, grossPrice, orginPv);
}
else//无自动互换、互换/平仓,复制上一日终信息,并计算当日新增利息
{
SaveEodInterestPositionCopy(eodPosition, tdEodPosition, settleDate, td, interest, lastEodSwap, true, posiLongNational, posiShortNational, grossPrice, orginPv);
SaveEodInterestPositionCopy(eodPosition, tdEodPosition, settleDate, td, interest, lastEodSwap, true, posiTotalNotional, grossPrice, orginPv);
}
}
}
@@ -1079,7 +1092,7 @@ namespace YLErp.Modules.SwapModule
/// <param name="preDealDate">上一平仓/互换日期</param>
/// <param name="closeAmount">当日平仓金额</param>
/// <param name="lastEodSwap">上一日终框架合约估值</param>
protected List<swap_flow_event> SaveAutoEodInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, eod_swap lastEodSwap, decimal posiLongNotional, decimal posiShortNational, decimal grossPrice, decimal orginPv)
protected List<swap_flow_event> SaveAutoEodInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, eod_swap lastEodSwap, decimal posiTotalNotional, decimal grossPrice, decimal orginPv)
{
Log.Info($"[SaveAutoEodInterestPosition] 开始执行 - valueDate: {valueDate:yyyy-MM-dd}, td.id: {td?.id}, position.id: {position?.id}");
@@ -1118,7 +1131,7 @@ namespace YLErp.Modules.SwapModule
}
var tradeExtend = td.trade_extend.ExtendObj;
decimal posiNotionalValue = posiLongNotional + posiShortNational;
decimal posiNotionalValue = posiTotalNotional;
decimal closePercent = 1;
var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
if (eodPayPosition == null)
@@ -1145,7 +1158,7 @@ namespace YLErp.Modules.SwapModule
{
orginPv = eodPayPosition.InterestPrincipalFix;
}
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, posiNotionalValue, closePercent, (int)SwapEventTypeEnum., false, true, grossPrice, orginPv, true);
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiNotionalValue, closePercent, (int)SwapEventTypeEnum., false, orginPv, true);
decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount);
decimal tdInterestAmount = interests.Sum(x => x.TdInterestAmount);
@@ -1230,7 +1243,7 @@ namespace YLErp.Modules.SwapModule
/// <param name="closeAmount">当日平仓金额</param>
/// <param name="lastEodSwap">上一日终框架合约估值</param>
/// <param name="unwintotal">平仓主信息</param>
protected List<swap_flow_event> SaveAutoEodWithCloseInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, decimal posiLongNotional, decimal posiShortNational, List<swap_flow_event> flowEvents, decimal closeNational, bool autoSwap, decimal grossPrice, decimal orginPv)
protected List<swap_flow_event> SaveAutoEodWithCloseInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, decimal posiTotalNotional, List<swap_flow_event> flowEvents, decimal closeNational, bool autoSwap, decimal grossPrice, decimal orginPv)
{
Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
var tradeExtend = td.trade_extend.ExtendObj;
@@ -1241,8 +1254,8 @@ namespace YLErp.Modules.SwapModule
// 调用共享计息器。因此策略的 "posiNotional × closePercent" 在本例会得到 212197382.46
// 而本次实际应结的平仓本金是 closeNational=90941735.34。该语义错位由
// SwapDealService.GetInterests 的模式2无条件修正、模式9全平零值兜底分流处理,不能删除。
decimal oriPosiNotionalValue = posiLongNotional + posiShortNational + closeNational;
decimal posiNotionalValue = posiLongNotional + posiShortNational;
decimal oriPosiNotionalValue = posiTotalNotional + closeNational;
decimal posiNotionalValue = posiTotalNotional;
// ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。
var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
// 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。
@@ -1306,9 +1319,12 @@ namespace YLErp.Modules.SwapModule
List<eod_swap_position> preEodPositions = new List<eod_swap_position>();
preEodPositions.Add(eodPayPosition);
var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true;
// 此处 closePercent=1 表示 EOD 计算本次事件时走全额结息;它不是 closeNational / oriPosiNotionalValue
// 与上方“收盘后剩余本金”同时传入会触发共享计息器的模式2/9本金修正,见 GetInterests
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast);
// 显式入口:平仓后剩余本金 + 实际平掉额 + 恒1全额结息(语义见 InterestCalcRequest.EodPostCloseSettle
// 该组合触发 GetInterests 内共享计息器的模式2/9本金修正(见其"根因位置"注释,勿删)
var interests = CalcEodPostCloseSettleInterests(InterestCalcRequest.EodPostCloseSettle(
td, td.trade_extend, valueDate, valueDate, preEodPositions, positions,
posiNotionalValue, closeNational,
eventType, tdClose: false, orginPv, add: true, newCalcLast: autoSwap || calcLast));
// TdInterestAmount:计息器返回的全腿当日/累计参考值,用于拆出 EOD 的当日新增。
// interestAmountBeforeSettlement:本次事件发生前理论应结的高精度利息。
// manualSettledInterestAmountswap_flow_event 实际落库的手工结息,金额已按分处理。
@@ -1488,7 +1504,7 @@ namespace YLErp.Modules.SwapModule
/// <param name="preSettleDate">上一交易日</param>
/// <param name="valueDate">当前结算日</param>
/// <param name="td">互换交易主干</param>
protected void SaveEodInterestPositionCopy(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, DateTime valueDate, trade td, swap_position position, eod_swap lastEodSwap, bool needPrice, decimal posiLongNational, decimal posiShortNational, decimal grossPrice, decimal orginPv)
protected void SaveEodInterestPositionCopy(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, DateTime valueDate, trade td, swap_position position, eod_swap lastEodSwap, bool needPrice, decimal posiTotalNotional, decimal grossPrice, decimal orginPv)
{
Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
List<IntervalModel> intervals = position.SwapIntervalList;
@@ -1512,7 +1528,7 @@ namespace YLErp.Modules.SwapModule
eodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix;
eodPayPosition.InterestRateDefault = position.InterestRateDefault;
eodPayPosition.InterestSwapInterval = position.InterestSwapInterval;
eodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode) ? eodPayPosition.InterestPrincipalFix : posiLongNational + posiShortNational;
eodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode) ? eodPayPosition.InterestPrincipalFix : posiTotalNotional;
eodPayPosition.PosiStartDate = td.StartDate.Value;
eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value;
eodPayPosition.IsAnnualized = position.IsAnnualized;
@@ -1535,7 +1551,7 @@ namespace YLErp.Modules.SwapModule
orginPv = eodPayPosition.InterestPrincipalFix;
}
bool longShort = td.StructureType == ClientMarginTypeEnum..ToString();
decimal oriPosiNotionalValue = posiLongNational + posiShortNational;
decimal oriPosiNotionalValue = posiTotalNotional;
decimal posiNotionalValue = oriPosiNotionalValue;
if (lastEodSwap == null)
{
@@ -1560,7 +1576,7 @@ namespace YLErp.Modules.SwapModule
{
preEodPositions.Add(eodPayPosition);
}
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNational, posiShortNational, posiNotionalValue, closePercent, 0, false, needPrice, grossPrice, orginPv);
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiNotionalValue, closePercent, 0, false, orginPv);
UpdateDbOption(newEodPayPosition);
newEodPayPosition.PosiStatus = 0;
@@ -2114,7 +2130,7 @@ namespace YLErp.Modules.SwapModule
var tradeSpan = DbContext.trade_span.FirstOrDefault(x => x.TradeId == td.id && x.ValueDate == settleDate);
// eod_swap 是交易级汇总;eod_swap_position 是浮动腿、利息腿和保证金腿的明细。
// 以下先按日终明细拆腿,再按框架合约展示口径汇总。
var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList();
var eodSwapPositions = DbContext.eod_swap_position.ActiveByTradeAndDate(td.id, settleDate).ToList();
var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿
var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿
// 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模,
@@ -2176,7 +2192,7 @@ namespace YLErp.Modules.SwapModule
DbContext.eod_swap.Add(eod_Swap);
}
// 单标的调整与首次归档使用同一套框架合约汇总口径,避免重算后多空和名义本金展示不一致。
var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList();
var eodSwapPositions = DbContext.eod_swap_position.ActiveByTradeAndDate(td.id, settleDate).ToList();
var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿
var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿
eod_Swap.NotionalValue = Math.Round(Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
@@ -2227,7 +2243,7 @@ namespace YLErp.Modules.SwapModule
public SwapLongShortCloseModel GetCloseDetails(int tradeId, DateTime valueDate)
{
SwapLongShortCloseModel closeModel = new SwapLongShortCloseModel();
var eodPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid).ToList();
var eodPositions = DbContext.eod_swap_position.ActiveByTradeAndDate(tradeId, valueDate).ToList();
var flowEvents = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId && x.EventDate == valueDate && x.DataState == (int)SwapFlowDateStateEnum. && x.EventType == (int)SwapEventTypeEnum. && string.IsNullOrEmpty(x.UnderlyingCode)).ToList();
closeModel.DealPositions = eodPositions.Where(x => x.TdCloseQty != 0).ToList();
closeModel.DealInterests = flowEvents;
@@ -2468,7 +2484,7 @@ namespace YLErp.Modules.SwapModule
/// <returns></returns>
public List<eod_swap_position> GetPreEodPositions(int tradeId, DateTime valueDate)
{
return DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid).ToList();
return DbContext.eod_swap_position.ActiveByTradeAndDate(tradeId, valueDate).ToList();
}
/// <summary>
/// 获取互换交易日终持仓数据集合
@@ -0,0 +1,18 @@
using System.Linq;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// swap_position 查询收口(Query Object)。
/// 规则"有效持仓 = SwapTradeId 匹配且未作废(!Invalid)"集中于此,
/// 避免多处复制同一谓词导致语义漂移(漏写 !Invalid 即静默出 bug)。
/// 仅返回 IQueryable,不调用 SaveChanges,不破坏跟踪/Include/事务边界。
/// </summary>
public static class SwapPositionQueries
{
public static IQueryable<swap_position> ActiveByTrade(
this IQueryable<swap_position> query, int tradeId)
=> query.Where(x => x.SwapTradeId == tradeId && !x.Invalid);
}
}
@@ -1188,7 +1188,7 @@ namespace YLErp.Modules.SwapModule
tradeObj.trade_Initial_Margin = new trade_initial_margin();
}
tradeObj.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == intid);
tradeObj.swap_positions = DbContext.swap_position.Where(x => x.SwapTradeId == intid && !x.Invalid).ToList();
tradeObj.swap_positions = DbContext.swap_position.ActiveByTrade(intid).ToList();
tradeObj.swap_positions = tradeObj.swap_positions.Where(x => x.PosiQuantity > 0 || x.InterestDirection > 0).ToList();
var intervalPositions = tradeObj.swap_positions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).ToList();
var intervalPositionIds = intervalPositions.Select(s => s.id).ToList();
@@ -1517,7 +1517,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("交易不存在");
}
bool backToBegin = td.TradeDate == valueDate;
var swapPositions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid).ToList();
var swapPositions = DbContext.swap_position.ActiveByTrade(tradeId).ToList();
td.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
//展期
@@ -28,7 +28,8 @@ namespace YLErp.Modules.TradeModule.DealModule
public List<BodTradePosition> Execute(DateTime settleDate, IEnumerable<EodTradePosition> positions)
{
var result = new List<BodTradePosition>();
var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V);
var dict = GetExDividendQuery(settleDate)
.ToDictionary(K => K.UnderlyingId, V => V);
foreach (var item in positions)
{
double cost = item.Cost,
@@ -76,7 +77,8 @@ namespace YLErp.Modules.TradeModule.DealModule
useSaveTrades = new List<trade>();
useSaveUndedrlyings = new List<underlying_manager>();
var result = new List<bod_trade>();
var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V);
var dict = GetExDividendQuery(settleDate)
.ToDictionary(K => K.UnderlyingId, V => V);
var tradeIds = trades.Select(O => O.id);
var dividendRatioDict = new DbRecordChangesService<TradeChanges>(this).GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate).ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; });
foreach (var t in trades)
@@ -713,9 +715,15 @@ namespace YLErp.Modules.TradeModule.DealModule
{
return 0;
}
var ratio = overrideDividendRatio != null ? overrideDividendRatio.Value : GetRatio(info);
double? result = price / ratio;
return Math.Round(result ?? 0, 4, MidpointRounding.AwayFromZero);
var decimalRatio = overrideDividendRatio.HasValue
? (decimal)overrideDividendRatio.Value
: GetRatioDecimal(info);
if (decimalRatio == 0)
{
return 0;
}
var result = (decimal)price / decimalRatio;
return (double)Math.Round(result, 4, MidpointRounding.AwayFromZero);
}
/// <summary>
@@ -725,10 +733,17 @@ namespace YLErp.Modules.TradeModule.DealModule
/// <returns></returns>
public double GetRatio(ex_dividend_info info)
{
var dividendRate = valuedateBLL.SystemDate.DividendRate / 100;
return (double)GetRatioDecimal(info);
}
private decimal GetRatioDecimal(ex_dividend_info info)
{
var dividendRate = (decimal)valuedateBLL.SystemDate.DividendRate / 100m;
var closePrice = new EodPriceProvider(info.ExDividendDate.Value).GetPrice(info.UnderlyingCode, SettlementTypeEnum.ClosePrice);
var cDivdPrice = (closePrice * 10.0 - (info.GiveCashAmount * (1 - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / (10 + info.GiveShareAmount + info.RationedSharesAmount);
return closePrice / cDivdPrice;
var decimalClosePrice = (decimal)closePrice;
var cDivdPrice = (decimalClosePrice * 10m - (info.GiveCashAmount * (1m - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) /
(10m + info.GiveShareAmount + info.RationedSharesAmount);
return cDivdPrice == 0 ? 0 : decimalClosePrice / cDivdPrice;
}
/// <summary>
@@ -751,18 +766,20 @@ namespace YLErp.Modules.TradeModule.DealModule
/// <returns></returns>
public double GetPositionAmount(double amount, ex_dividend_info info)
{
double? result = amount * (1 + info.GiveShareAmount / 10.0);
return Math.Round(result ?? 0, 12);
var result = (decimal)amount * (1m + info.GiveShareAmount / 10m);
return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero);
}
public IQueryable<ex_dividend_info> GetExDividendQuery(DateTime valueDate)
{
return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == valueDate);
return DbContext.ex_dividend_info
.Where(O => O.ValidStatus && O.ExDividendDate == valueDate);
}
public IQueryable<ex_dividend_info> GetExDividendQuery(DateTime dateStart, DateTime dateEnd)
{
return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd);
return DbContext.ex_dividend_info
.Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd);
}
public IEnumerable<ex_dividend_info> GetExDividends(DateTime valueDate, params int[] underlyingIds)
@@ -772,7 +789,7 @@ namespace YLErp.Modules.TradeModule.DealModule
{
query = query.Where(n => underlyingIds.Contains(n.UnderlyingId));
}
return query.ToArray();
return query;
}
public IQueryable<ex_dividend_info> GetExDividendInfos(string underlyingCode)
@@ -797,17 +814,17 @@ namespace YLErp.Modules.TradeModule.DealModule
{
throw new ServiceException("请使用正确的模板上传");
}
var dict = new Dictionary<string, ex_dividend_info>();
var dividendInfos = new List<ex_dividend_info>();
for (var i = 0; i < dt.Rows.Count; i++)
{
var info = new ex_dividend_info
{
UnderlyingCode = dt.Rows[i]["股票代码"]?.ToString(),
ExDividendDate = DateTime.TryParse(getColValueFromTable(dt.Rows[i], "股权登记日"), out var date) ? date : DateTime.MinValue,
GiveCashAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0,
GiveShareAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0,
RationedSharesAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0,
RationedSharesPrice = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0,
GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0,
GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0,
RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0,
RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0,
OptId = OptUser.UserId,
OptName = OptUser.UserName,
OptDate = DateTime.Now
@@ -824,9 +841,9 @@ namespace YLErp.Modules.TradeModule.DealModule
{
throw new ServiceException($"第{i + 1}行股权登记日不正确");
}
dict[$"{info.ExDividendDate}{info.UnderlyingCode}"] = info;
dividendInfos.Add(info);
}
if (!AddDividendInfos(dict.Values, out var errMsg))
if (!AddDividendInfos(dividendInfos, out var errMsg))
{
throw new ServiceException(errMsg);
}
@@ -841,48 +858,163 @@ namespace YLErp.Modules.TradeModule.DealModule
return "";
}
private ex_dividend_info FindExDividendByBusinessKey(int underlyingId, DateTime exDividendDate, int excludedId = 0)
{
// 业务唯一键按“标的 + 自然日”定义,而不是按完整 DateTime 定义。
// 因此这里使用 [当天 00:00, 次日 00:00) 查询,兼容历史数据中可能存在的时分秒。
// excludedId 用于编辑已有记录时排除自身,避免把当前记录误判为重复记录。
return DbContext.ex_dividend_info.FirstOrDefault(O => O.UnderlyingId == underlyingId
&& O.ExDividendDate >= exDividendDate
&& O.ExDividendDate < exDividendDate.AddDays(1)
&& (excludedId <= 0 || O.id != excludedId));
}
private static void MergeNonZeroDividendValues(ex_dividend_info target, ex_dividend_info source)
{
if (target == null)
{
throw new ArgumentNullException(nameof(target));
}
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
// 同一业务键可能分别来自多行导入,或来自“数据库旧记录 + 当前导入记录”。
// 每个字段独立合并:当前值非零时覆盖旧值,当前值为零时保留旧值,
// 这样派息、送股、配股数量、配股价格可以从不同来源补齐到同一行。
// 该约定将零解释为“未提供”,因此不能通过普通导入把已有字段显式清零。
if (source.GiveCashAmount != 0m)
{
target.GiveCashAmount = source.GiveCashAmount;
}
if (source.GiveShareAmount != 0m)
{
target.GiveShareAmount = source.GiveShareAmount;
}
if (source.RationedSharesAmount != 0m)
{
target.RationedSharesAmount = source.RationedSharesAmount;
}
if (source.RationedSharesPrice != 0m)
{
target.RationedSharesPrice = source.RationedSharesPrice;
}
}
public bool AddDividendInfos(IEnumerable<ex_dividend_info> infos, out string errMsg)
{
try
{
var keys = infos.Select(O => $"{O.ExDividendDate?.ToString("yyyy-MM-dd")}{O.UnderlyingCode}");
var ids = infos.Select(O => O.id).ToHashSet();
var data = from dividendDb in DbContext.ex_dividend_info.Where(O => keys.Contains(O.ExDividendDate + O.UnderlyingCode) && O.ValidStatus)
where !ids.Contains(dividendDb.id)
select dividendDb;
if (data.Any())
var dividendInfos = infos?.ToList();
if (dividendInfos == null || dividendInfos.Count == 0)
{
var dd = data.Select(O => O.UnderlyingCode + "_" + O.ExDividendDate).ToArray();
errMsg = string.Join(",", dd) + "已存在除息信息,请修改原数据";
errMsg = "没有可保存的除权除息信息";
return false;
}
var basketList =
DataCacheProvider.GetUnderlyingDataSource()
.AsQueryable().Where(O => O.IsBasket() && O.SubData != null)
.Select(O => new { O.UnderlyingCode, O.SubData });
IEnumerable<eod_stock_price> priceList = null;
foreach (var item in infos)
var preparedInfos = new List<(ex_dividend_info Item, underlying_manager Underlying, DateTime ExDividendDate)>();
var preparedIndexes = new Dictionary<(int UnderlyingId, DateTime ExDividendDate), int>();
var recordKeys = new Dictionary<int, (int UnderlyingId, DateTime ExDividendDate)>();
foreach (var item in dividendInfos)
{
if (item == null || string.IsNullOrWhiteSpace(item.UnderlyingCode))
{
errMsg = "标的代码信息不存在";
return false;
}
var underlying = underlying_managerBLL.GetByCode(item.UnderlyingCode);
if (underlying == null)
{
errMsg = $"{item.UnderlyingCode} 标的信息不存在";
return false;
}
if (!item.ExDividendDate.HasValue)
{
errMsg = "股权登记日信息不存在";
return false;
}
// 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间
// 能命中同一个自然日业务键,也与数据库的一行模型保持一致。
var exDividendDate = item.ExDividendDate.Value.Date;
var businessKey = (underlying.id, exDividendDate);
if (item.id > 0
&& recordKeys.TryGetValue(item.id, out var existingRecordKey)
&& existingRecordKey != businessKey)
{
errMsg = "同一除权信息不能重复保存";
return false;
}
item.UnderlyingId = underlying.id;
item.GiveCashAmount = item.GiveCashAmount.FormatValue(6);
item.RationedSharesAmount = item.RationedSharesAmount.FormatValue(6);
item.RationedSharesPrice = item.RationedSharesPrice.FormatValue(6);
item.GiveShareAmount = item.GiveShareAmount.FormatValue(6);
item.ValidStatus = true;
item.OptId = OptUser.UserId;
item.OptName = OptUser.UserName;
item.OptDate = DateTime.Now;
var dividend = item.id > 0 ? DbContext.ex_dividend_info.Where(O => O.id == item.id).FirstOrDefault() : null;
item.ExDividendDate = exDividendDate;
item.GiveCashAmount = OtcFormatHelper.FormatValue(item.GiveCashAmount, 6);
item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6);
item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6);
item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6);
// 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录
// 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。
if (preparedIndexes.TryGetValue(businessKey, out var preparedIndex))
{
var preparedItem = preparedInfos[preparedIndex].Item;
// 同一业务键下允许重复的是同一条记录(两个新对象都为 id=0,
// 或两个对象的 id 相同);不同 id 代表不同存量记录,不能静默合并。
if ((preparedItem.id == 0) != (item.id == 0)
|| preparedItem.id > 0 && item.id > 0 && preparedItem.id != item.id)
{
errMsg = $"{item.UnderlyingCode} {exDividendDate:yyyy-MM-dd}除权信息不能合并不同记录";
return false;
}
MergeNonZeroDividendValues(preparedItem, item);
if (item.id > 0)
{
recordKeys[item.id] = businessKey;
}
continue;
}
if (item.id > 0)
{
recordKeys[item.id] = businessKey;
}
preparedIndexes.Add(businessKey, preparedInfos.Count);
preparedInfos.Add((item, underlying, exDividendDate));
}
var basketList =
DataCacheProvider.GetUnderlyingDataSource()
.AsQueryable().Where(O => O.CommodityCode == "篮子标的" && O.SubData != null)
.Select(O => new { O.UnderlyingCode, O.SubData });
IEnumerable<eod_stock_price> priceList = null;
foreach (var prepared in preparedInfos)
{
var item = prepared.Item;
var underlying = prepared.Underlying;
var itemDate = prepared.ExDividendDate;
// id>0 表示前端正在编辑指定的存量记录;id=0 时先按自然日业务键
// 查找数据库旧记录,使“新增导入”也能与已有记录合并,而不是重复插入。
var dividend = item.id > 0
? DbContext.ex_dividend_info.FirstOrDefault(O => O.id == item.id)
: FindExDividendByBusinessKey(underlying.id, itemDate);
if (dividend == null)
{ DbContext.ex_dividend_info.Add(item); }
{
if (item.id > 0)
{
errMsg = "未找到要修改的除权除息信息";
return false;
}
item.DataSource = ExDividendDataSources.Manual;
item.SourceUpdatedAt = null;
item.ValidStatus = true;
item.OptId = OptUser.UserId;
item.OptName = OptUser.UserName;
item.OptDate = DateTime.Now;
DbContext.ex_dividend_info.Add(item);
}
else
{
if (checkDividendInfoExecuteStatus(dividend))
@@ -890,28 +1022,37 @@ namespace YLErp.Modules.TradeModule.DealModule
errMsg = $"{dividend.UnderlyingCode} {dividend.ExDividendDate?.ToString("yyyy-MM-dd")}除权信息保存失败,该信息已被执行,不允许修改!";
return false;
}
var conflictingDividend = FindExDividendByBusinessKey(underlying.id, itemDate, dividend.id);
if (conflictingDividend != null)
{
errMsg = $"{item.UnderlyingCode} {itemDate:yyyy-MM-dd}除权信息已存在,不能修改为该业务键";
return false;
}
var sourceUpdatedAt = dividend.SourceUpdatedAt;
dividend.UnderlyingCode = item.UnderlyingCode;
dividend.UnderlyingId = item.UnderlyingId;
dividend.ExDividendDate = item.ExDividendDate;
dividend.GiveCashAmount = item.GiveCashAmount;
dividend.RationedSharesAmount = item.RationedSharesAmount;
dividend.RationedSharesPrice = item.RationedSharesPrice;
dividend.GiveShareAmount = item.GiveShareAmount;
dividend.ValidStatus = item.ValidStatus;
dividend.OptId = item.OptId;
dividend.OptName = item.OptName;
dividend.OptDate = item.OptDate;
// 数据库已有记录也必须走与批次内重复行相同的合并规则:导入字段非零
// 才覆盖旧值,导入字段为零则保留数据库存量值,避免一次不完整导入
// 把旧的派息/送股/配股信息误清零。
MergeNonZeroDividendValues(dividend, item);
dividend.ValidStatus = true;
dividend.DataSource = ExDividendDataSources.Manual;
dividend.SourceUpdatedAt = sourceUpdatedAt;
dividend.OptId = OptUser.UserId;
dividend.OptName = OptUser.UserName;
dividend.OptDate = DateTime.Now;
}
if (!basketList.Any())
{
continue;
}
var codes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode);
if (!codes.Any())
var basketCodes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode);
if (!basketCodes.Any())
{
continue;
}
var removePriceList = DbContext.eod_stock_price.Where(O => codes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate);
var removePriceList = DbContext.eod_stock_price.Where(O => basketCodes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate);
if (!removePriceList.Any())
{
continue;
@@ -954,7 +1095,7 @@ namespace YLErp.Modules.TradeModule.DealModule
return true;
}
//查询篮子标的对应交易是否执行过收盘操作;
var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.IsBasket() && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray();
var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.CommodityCode == "篮子标的" && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray();
tradeQuery = from t in DbContext.trade.Where(O => umList.Contains(O.UnderlyingCode) && O.TradeDate <= info.ExDividendDate && O.ExerciseDate >= info.ExDividendDate && O.DividendDate >= O.TradeDate)
join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus))
on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate }
@@ -34,6 +34,7 @@ using YLErp.Office.Converters;
using YLErp.Plugins.TradeDocGenerator;
using YLErp.Plugins.TradeDocGenerator.Abstracts;
using YLErp.QdpModule;
using YLErp.Modules.SwapModule;
namespace YLErp.Modules.TradeModule.DocGenerateModule
{
@@ -2864,7 +2865,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule
}
public List<eod_swap_position> GetEodPositions(int tradeId, DateTime valueDate)
{
return DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid && x.ValueDate == valueDate).AsNoTracking().ToList();
return DbContext.eod_swap_position.ActiveByTradeAndDate(tradeId, valueDate).AsNoTracking().ToList();
}
public List<SwapFlowDeal> GetSwapFlowDeals(int tradeId)
@@ -98,6 +98,10 @@ namespace YLErp.Web.Controllers
else
{
r.ValidStatus = false;
r.DataSource = ExDividendDataSources.Manual;
r.OptId = CurUser.UserId;
r.OptName = CurUser.UserName;
r.OptDate = DateTime.Now;
yldb.SaveChanges();
return JsonSuccess("删除成功");
}
+1 -1
View File
@@ -85,7 +85,7 @@
<script src="~/Statics/libs/sortable/Sortable.min.js"></script>
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/app/tradeHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="@HtmlUtil.BasicDataJs("品种", "标的Live", "客户")"></script>
<script src="@HtmlUtil.BasicDataJs("品种", "客户")"></script>
<script>
const pageObj = @Json.Serialize(pageObj);
const pageData = @Json.Serialize(pageData);
+1 -1
View File
@@ -77,7 +77,7 @@
<script src="~/Statics/libs/sortable/Sortable.min.js"></script>
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/app/tradeHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="@HtmlUtil.BasicDataJs("品种", "标的Live", "客户")"></script>
<script src="@HtmlUtil.BasicDataJs("品种", "客户")"></script>
<script src="~/Scripts/app/trade/settag.js?v=@HtmlUtil.JsVersion" type="text/javascript"></script>
<script type="text/javascript">
const HasEditTagPermission = @Json.Serialize(CurUser.系统管理.标签编辑权限);
@@ -109,9 +109,52 @@ const vueTradeType = function () {
};
};
//标的选择组件
//标的选择组件(EQD-7049:改为服务端搜索,不再依赖全量 ylotc.underlyings,避免十几万标的整段下载卡死)
const vueUnderlying = function () {
const _suggestionTpl = _.template($('#underlyingSuggestionTpl').html());
// 标的缓存:按 品种|关键词 隔离;乱序响应由 token 丢弃(helper 收在函数内,避免全局绑定冲突)
const _cache = {};
const _tokens = {};
function _fetch(varietyId, query, cb) {
var key = (varietyId || 0) + '|' + (query || '');
var token = (_tokens[key] = (_tokens[key] || 0) + 1);
var postData = {
FilterCode: (query || '').toUpperCase(),
VarietyId: varietyId || 0,
MaxShowLength: 20,
BlackLimit: 1,
UseForTrading: true,
IncludeMatured: true,
CheckLaunch: true
};
main.post('/frontdata/AjaxGetUnderlyingSelect', postData).done(function (res) {
if (_tokens[key] !== token) return; // 丢弃过期响应
var arr = (res && (res.obj || res.data)) || [];
var norm = arr.map(function (x) {
return {
Code: x.Code,
Name: x.Name,
InstrumentType: x.InstrumentType,
VarietyId: x.VarietyId,
Disallow: !!x.Disallow,
IsCombined: !!x.IsSynthetic || !!x.IsBasket,
BlackWhiteState: x.BlackWhiteState || 0,
PinYin: x.PinYin || ''
};
});
cb && cb(norm);
});
}
function _filter(list, query, varietyId) {
if (!query) return (list || []).slice(0, 20);
query = query.toUpperCase();
return (list || []).filter(function (x) {
if (varietyId && x.VarietyId !== varietyId) return false;
if (x.IsCombined) return false; // 与原逻辑一致:搜索时排除组合标的
return (x.Code && x.Code.toUpperCase().indexOf(query) !== -1)
|| (x.PinYin && x.PinYin.toUpperCase().indexOf(query) !== -1);
}).slice(0, 20);
}
return {
props: ['underlying'],
data() {
@@ -120,27 +163,24 @@ const vueUnderlying = function () {
mounted() {
var self = this;
this.jqInput = $(this.$el).children(0);
// EQD-7049:预拉默认20条(当前品种),避免下拉空白
_fetch(self.underlying.VarietyId, '', function (list) {
_cache[self.underlying.VarietyId || 0] = list;
try { $(self.jqInput).autocomplete('search', ''); } catch (e) {}
});
this.autoctrl = FastVue.autocomplete(this.jqInput, {
valueField: 'Code',
lookup(query, callback) {
var arr = [];
if (!query) {
var varietyId = self.underlying.VarietyId;
ylotc.underlyings.forEach(x => {
(!varietyId || x.VarietyId === varietyId) && arr.push(x);
});
} else {
query = query.toUpperCase();
ylotc.underlyings.forEach(x => {
if (x.Code.toUpperCase().indexOf(query) !== -1 || x.PinYin && x.PinYin.indexOf(query) !== -1 && !x.IsCombined) {
arr.push(x);
}
var varietyId = self.underlying.VarietyId;
var cached = _cache[varietyId || 0] || [];
var immediate = _filter(cached, query, varietyId);
if (query) {
// 有输入时异步向服务端搜索并刷新缓存(乱序响应由 token 丢弃)
_fetch(varietyId, query, function (list) {
_cache[varietyId || 0] = list;
});
}
if (arr.length < 30) {
arr = _.sortBy(arr, x => x.Code);
}
return arr;
return immediate;
},
onSelect(data) {
if (self.underlying !== data) {
@@ -1430,6 +1470,12 @@ const vueTrade = function () {
//更新标的
updateUnderlying(reqData, fromSelect) {
let self = this;
// EQD-7049:新建空白页未选标的/品种/类型时,跳过必然失败的后端默认标的查询,避免报“标的信息缺失”
var hasQueryKey = !!(reqData.UnderlyingCode || reqData.InstrumentType || reqData.VarietyId > 0);
if (!hasQueryKey) {
!fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying());
return;
}
var instTypeChanged = !!reqData.InstrumentType;
!fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying());
main.post("/pricing/AjaxGetUnderlying", reqData).done(function (resp) {
@@ -108,9 +108,52 @@ const vueTradeType = function () {
};
};
//标的选择组件
//标的选择组件(EQD-7049:改为服务端搜索,不再依赖全量 ylotc.underlyings,避免十几万标的整段下载卡死)
const vueUnderlying = function () {
const _suggestionTpl = _.template($('#underlyingSuggestionTpl').html());
// 标的缓存:按 品种|关键词 隔离;乱序响应由 token 丢弃(helper 收在函数内,避免全局绑定冲突)
const _cache = {};
const _tokens = {};
function _fetch(varietyId, query, cb) {
var key = (varietyId || 0) + '|' + (query || '');
var token = (_tokens[key] = (_tokens[key] || 0) + 1);
var postData = {
FilterCode: (query || '').toUpperCase(),
VarietyId: varietyId || 0,
MaxShowLength: 20,
BlackLimit: 1,
UseForTrading: true,
IncludeMatured: true,
CheckLaunch: true
};
main.post('/frontdata/AjaxGetUnderlyingSelect', postData).done(function (res) {
if (_tokens[key] !== token) return; // 丢弃过期响应
var arr = (res && (res.obj || res.data)) || [];
var norm = arr.map(function (x) {
return {
Code: x.Code,
Name: x.Name,
InstrumentType: x.InstrumentType,
VarietyId: x.VarietyId,
Disallow: !!x.Disallow,
IsCombined: !!x.IsSynthetic || !!x.IsBasket,
BlackWhiteState: x.BlackWhiteState || 0,
PinYin: x.PinYin || ''
};
});
cb && cb(norm);
});
}
function _filter(list, query, varietyId) {
if (!query) return (list || []).slice(0, 20);
query = query.toUpperCase();
return (list || []).filter(function (x) {
if (varietyId && x.VarietyId !== varietyId) return false;
if (x.IsCombined) return false; // 与原逻辑一致:搜索时排除组合标的
return (x.Code && x.Code.toUpperCase().indexOf(query) !== -1)
|| (x.PinYin && x.PinYin.toUpperCase().indexOf(query) !== -1);
}).slice(0, 20);
}
return {
props: ['underlying'],
data() {
@@ -119,27 +162,24 @@ const vueUnderlying = function () {
mounted() {
var self = this;
this.jqInput = $(this.$el).children(0);
// EQD-7049:预拉默认20条(当前品种),避免下拉空白
_fetch(self.underlying.VarietyId, '', function (list) {
_cache[self.underlying.VarietyId || 0] = list;
try { $(self.jqInput).autocomplete('search', ''); } catch (e) {}
});
this.autoctrl = FastVue.autocomplete(this.jqInput, {
valueField: 'Code',
lookup(query, callback) {
var arr = [];
if (!query) {
var varietyId = self.underlying.VarietyId;
ylotc.underlyings.forEach(x => {
(!varietyId || x.VarietyId === varietyId) && arr.push(x);
});
} else {
query = query.toUpperCase();
ylotc.underlyings.forEach(x => {
if (x.Code.toUpperCase().indexOf(query) !== -1 || x.PinYin && x.PinYin.indexOf(query) !== -1 && !x.IsCombined) {
arr.push(x);
}
var varietyId = self.underlying.VarietyId;
var cached = _cache[varietyId || 0] || [];
var immediate = _filter(cached, query, varietyId);
if (query) {
// 有输入时异步向服务端搜索并刷新缓存(乱序响应由 token 丢弃)
_fetch(varietyId, query, function (list) {
_cache[varietyId || 0] = list;
});
}
if (arr.length < 30) {
arr = _.sortBy(arr, x => x.Code);
}
return arr;
return immediate;
},
onSelect(data) {
if (self.underlying !== data) {
@@ -1044,6 +1084,12 @@ const vueTrade = function () {
//更新标的
updateUnderlying(reqData, fromSelect) {
let self = this;
// EQD-7049:新建空白页未选标的/品种/类型时,跳过必然失败的后端默认标的查询,避免报“标的信息缺失”
var hasQueryKey = !!(reqData.UnderlyingCode || reqData.InstrumentType || reqData.VarietyId > 0);
if (!hasQueryKey) {
!fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying());
return;
}
var instTypeChanged = !!reqData.InstrumentType;
!fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying());
main.post("/pricing/AjaxGetUnderlying", reqData).done(function (resp) {
@@ -28,10 +28,13 @@ function saveInfo(dataId, rowId) {
g_grid.jqGrid('saveRow', rowId,
{
successfunc: function (response) {
var msg = response.responseJSON.msg;
var result = response.responseJSON || {};
var msg = result.msg || "保存失败";
main.message(msg);
$("#systemTip").text(new Date().toLocaleString() + " " + msg);
if (!result.success) return false;
g_grid.trigger('reloadGrid');
return true;
},
"url": "/ex_dividend_info/SaveDividend",
"extraparam": data,
@@ -150,4 +153,4 @@ $(function () {
onPaging: onJqgridPaging
};
g_grid = jQuery('#listGrid').jqGrid(obj);
});
});
@@ -164,7 +164,7 @@ $(function () {
//设置除权
function SetDividEnd(cellValue, options, rowObject) {
if (rowObject.UnderlyingInstrumentType !== "Stock" || rowObject.CommodityCode === "篮子标的" || !g_dividend) return "";
if (["Stock", "Fund"].indexOf(rowObject.UnderlyingInstrumentType) < 0 || rowObject.CommodityCode === "篮子标的" || !g_dividend) return "";
var imageHtml = "<input type=\"button\" value=\"除权\" class=\"wentiEdit btn-info\" onclick=\"openDividEnd('" + rowObject.EncryptId + "');\" />";
return imageHtml;
}
+1 -1
View File
@@ -99,7 +99,7 @@ curretEod.PosiQuantity = qty < 0 ? 0 : Math.Abs(qty);
| **数量递推** | `SwapEodPositionService.cs:1897`qty 递推)、`:1723`/`:1905`(无事件日结转)、`:1631`(首次归档) | 需新增「公司行为数量」第三来源项 | `SwapEodPositionService.cs` |
| **`TdChangedQty`** | 定义 `EodSwapPosition.cs:300`DisplayName "当日公司行为数量");唯一赋值 `SwapEodPositionService.cs:1650`(恒=0) | 挂进 :1897 递推式(与 `TdCloseQty`:1942 对称),否则与 `PosiQuantity` 永久不自洽 | `SwapEodPositionService.cs` |
| **成本均价** | `SwapEodPositionService.cs:1926-1936`(加权重算,TRS 无 `CostPrice` 字段,等价字段 `PosiGrossPrice`/`PosiNetPrice`) | 送股无成交金额(分子+0、分母增)→ 走 `:1912 else if` 分支价不摊薄,污染盯市;配股有现金需加 `RationedSharesAmount×Price` | `SwapEodPositionService.cs:1912-1937` |
| **计息基准** | `SwapDealService.cs:893 CalcNotionalByMode`(五种模式分流)、`:1372` `dynomicPrincipal``:2384 InterestPrincipalFix` 仅平仓递减 | `posiLong/Short``PosiNotionalValue` 可自动跟随;**`InterestPrincipalFix` 是独立存量,与数量解耦**,配股缴款需新写入点 | `SwapDealService.cs` |
| **计息基准** | mode 分流已重构为 `FundingLegs/FundingLegStrategyFactory`(原 `SwapDealService CalcNotionalByMode` 已删;`dynomicPrincipal` 亦随重构消失)`InterestPrincipalFix` 仅平仓递减 | `posiLong/Short``PosiNotionalValue` 可自动跟随;**`InterestPrincipalFix` 是独立存量,与数量解耦**,配股缴款需新写入点 | `SwapDealService.cs` + `FundingLegs/` |
| **盯市盈亏** | `SwapEodPositionService.cs:1657/1730/1815/2019`4 份同构副本 `PosiMtmPnL`)、`:1713 GetSwapValuationPrice`(取除权后价) | 数量突变日若 `PosiQuantity`/`PosiGrossPrice` 未同步除权 → 虚假巨亏;**4 处副本必须一致改** | `SwapEodPositionService.cs` |
| **数据源** | `DividendService.cs:752 GetPositionAmount`(现成 `amount*(1+GiveShareAmount/10)` 送股调整)、`:730 GetRatio`(现成除权价公式) | SwapModule 未复用,需建调用边 | 新增 SwapModule→DividendService 调用 |
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="yilian-nexus" value="http://git.yiliantech.com/nexus/repository/nuget-group/index.json" protocolVersion="3" />
</packageSources>
</configuration>
@@ -0,0 +1,103 @@
# 多租户死代码清理执行计划(国联民生独家分支 · 校正版)
> 分支:`glms/feature/1.4.2`
> 编制:2026-08-15
> 状态:**计划已查明边界,多处需"部署配置确认"后方可执行删除**。本文件是执行清单,不是批准书。
## 0. 最关键的两条纠偏(决定本计划能否落地)
1. **本分支的"公司"不是编译期硬编码,而是运行期配置。**
`BizLogicSingleton` / `MarginCalculation` 的派发依据是 `PS.Config.Company`,其值来自 **DB `appconfig` 表的 `IErpConfig.ErpElement.Company`**,不是 git 分支。所以"国联独家分支"只表示**当前 GLMS 部署把 Company 配成国联**;理论上同一份代码换 Company 配置即可服务别家。
→ 删除任何"非国联"代码,等于**永久剥夺该部署切换/回退到其它家的能力**。这是业务逻辑决策,不是纯技术清理。
2. **真实死活取决于独立开关,不止 Company。**
- **衡泰对接**:不受 Company 门控,由 `HedgingSource`(对冲数据源,衡泰=1)与"接收衡泰修改回执"两个独立开关控制。衡泰模型层 26 文件是否死,取决于 GLMS 部署是否开启这两个开关——**不能一律当死代码删**。
- **保证金 per-company 计算器**:是否死,取决于 GLMS 把 Company 配成哪家 + 是否走 `UniversalMarginCalculation`(见阶段 1 待确认项)。
> ⚠️ 因此本计划所有删除动作前,必须先确认两件事:
> (a) GLMS 生产 `appconfig``Company` 实际值;
> (b) GLMS 生产是否开启衡泰 `HedgingSource` / 接收回执开关、是否配置浙商等 DataSource。
> 这两点只能问部署负责人或查生产配置,**grep 代码无法判定**。
## 1. 死代码总览(按"确定性"分级)
| 区域 | 规模 | 确定性 | 死因 |
|---|---|---|---|
| 衡泰模型层 `HengTaiModel/` | 26 文件(实测 1712 行) | **中**——取决于衡泰开关 | `HedgingSource`/回执开关未开则死 |
| 衡泰 DataCompare DTO | 4 文件 | 中 | 同上 |
| 6 处 `using HengTaiModel` 未用 | 6 文件各 1 行 | **高** | 确认未引用任何 HengTai 类型 |
| 保证金 per-company 计算器 | 35 生产文件 ≈17,600 行 + 测试副本 ≈7,000 行 | **中**——取决于 Company 配置 + 是否走 Universal | switch 不含国联→defaultUniversal 生产无调用 |
| 其他家 BizLogic | 34 文件(含 BizLogicZheShang 等) | 中 | Company 非国联则不可达 |
| 浙商空插件 `Plugins/YLErp.Plugins.ZheShang/` | 目录仅 obj/ | **高**(目录已空) | 无源码/无 csproj/无 sln 引用 |
| 前端"衡泰"JS 引用 | 2 处(etradingRule/etradeAccountList、tradeConfirmBook | 高 | 衡泰相关 |
| 前端 `Is{券商}` 分支 | 少量(**待核实**,见阶段 3) | 中 | 公司/开关门控 |
| 浙商监管报表 `ExtendReport/ZheShang/` | 2 文件 197 行 | **低**——按 DataSource 派发 | 国联是否配置该 DataSource 未知 |
> 与"上帝类"的关系:实测 `SwapDealService`/`SwapEodPositionService`**0 个 `Is{券商}` 分支**;全仓 66 个券商分支点泄漏在 `RealtimePnlCalc`/`QuotaMonitorService` 等 EOD/风控文件。→ 删死代码**几乎不缩减上帝类的领域密度**,只降"仓库表面积/误接风险"。上帝类治理见阶段 5(领域 seam 抽取),与本案正交。
## 2. 删除前通用检查清单(每文件必做)
1. `grep -rn "<TypeName>" --include=*.cs` 全仓(含 `Tools/``UnitTestProject/``YLErpUnitTest/``Plugins/`),确认除自身定义 + 派发 switch/工厂外无第二引用。
2. 若在 `.csproj``<Compile Include=...>` 显式引用(非 SDK 通配),删文件同步移除该行。
3. 若被其它**待删死文件**引用,可一并删并在 commit 说明。
4. 删除后必须编译验证(**本机无 dotnet SDK**,交 CI 或你本地 `dotnet build`)。编译器比 grep 可靠。
## 3. 分阶段执行计划
### 阶段 0 — 清 6 处衡泰死 import【零风险,可立即做】
仅删 `using YLErp.Model.HengTaiModel;` 行(已确认未引用任何类型):
- `YLErpWeb/Controllers/riskController.cs:5`
- `YLErpWeb/App/KafkaTask/ClientReskCheckKafkaTask.cs:5`
- `YLErpDAL/Modules/SwapModule/TRSHedgingOrderService.cs:16`
- `YLErpDAL/Modules/SwapModule/SwapConsumerService.cs:12`
- `YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs:37`
- `YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs:26`
### 阶段 1 — 保证金 per-company 计算器【最高体量,但需先确认】
**待确认(执行前必答):**
- GLMS `appconfig.Company` 实际值是否为 `国联`
- `MarginCalculation.cs``default` 分支对国联是否真的走 `DefaultMarginCalculation`?还是团队意图走 `UniversalMarginCalculation`(当前生产无调用,疑似未接线)?
- 若国联本应走 `Universal`,则 `UniversalMarginCalculation` 是**活代码、须保留并接线**,而非死代码。
**确认后,若国联走 Default**
- **保留**`DefaultMarginCalculation.cs`(19) `MarginCalculation.cs`(803,调度器) `MarginCalculationBase.cs`(374) `MarginCalcHelper.cs`(662,partial base) `MarginCalcRequests.cs`(182) `MarginCalcException.cs`(13)
- **删除**(生产 `YLErpDAL/BLL/MarginCalculation/`):
GTJAMarginCalculation(2718) ChangJiangMarginCalculation(1369) GFSMMarginCalculation(905) GuoXinJinYangMarginCalculation(800) HuaAnMarginCalculation(749) DongZhengRunHeMarginCalculation(697) FDMarginCalculation(674) BHRSMarginCalculation(550) XiangCaiMarginCalculation(545) RDMarginCalculation(541) GDGZMarginCalculation(513) GLDHMarginCalculation(513,`#if DEBUG` 才可达) DongWuMarginCalculation(507)+`DongWu/`子目录(115+258+95+42) HongYuanMarginCalculation(500) GuoTouMarginCalculation(501) XingZhengMarginCalculation(494) HaiTongMarginCalculation(479) XMXYMarginCalculation(422) ZhaoZhengMarginCalculation(389) ZheQiMarginCalculation(409) HongYeMarginCalculation(327) GQMarginCalculation(333) ZhongLiangMarginCalculation(338) BXMarginCalculation(320) MaoChuanMarginCalculation(318) ZhongJinMarginCalculation(294) SYWGMarginCalculation(254) XingYeMarginCalculation(226) ALQHMarginCalculation(191) GuoHaiMarginCalculation(82) SQMarginCalculation(20) ZhongCaiMarginCalculation(20) **UniversalMarginCalculation(139,待确认)**
- **测试副本同步删**`YLErpUnitTest/Modules/MarginModule/MarginCalculation/` 下所有 per-company 副本 + `UnitTestProject/Modules/CalcModules/MarginCalculationTest.cs``ZhaoZheng`/`GuoTou` 用例;并移除 `YLErpUnitTest.csproj` 对应 `<Compile Include>` 行。
- 体量:生产 ≈17,600 行 + 测试 ≈7,000 行。**这是唯一同时降死代码 + 类蔓延的动作。**
### 阶段 2 — 其他家 BizLogic【需 Company 确认】
- **保留**`BizLogicDefault.cs`(基类) `BizLogicSingleton.cs`(派发器) `BizLogicException.cs` `IBizLogic.cs` `BizLogicGuoLian.cs`(活)
- **删除 34 个公司类**`BizLogicALQH BHRS BX DX DZ FD GDGZ GFSM GT Gldh Gtja GXJY GuangQi GuoMao HaiTong HongYe HongYuan HuaAn HuaXi RD ShanXiGuShou SQ Sywg WCZD XiangYu XingYe XingZheng ZJ ZhaoZheng ZL ZheQi ZhongJiShiHua MaoChuan ZheShang`
- 收口:`BizLogicSingleton.cs` switch 精简为仅 `国联`(+default→GuoLian),消除多租户 dispatch 异味。
### 阶段 3 — 前端 + 浙商插件/报表【中风险,需核实】
- **前端衡泰(高确定)**`wwwroot/Scripts/app/etradingRule/etradeAccountList.js` + `tradeConfirmBook.js` 中"衡泰"引用删除;`Views/EtradeAccount/Index.cshtml`「衡泰簿记账户」整块删(确认无其它活内容)。
- **前端 `Is{券商}` 分支(待核实)**:计划初稿列的 `eodExecV2.cshtml:14/67/134``TradeMarketReport_Collateral.cshtml:413` 等,**需先 grep 确认是 `CompanyEnum` 还是 `Is浙商`/`Is国元固收` 等开关**,再决定删法。前端实际券商分支极少。
- **浙商空插件(高确定)**`Plugins/YLErp.Plugins.ZheShang/` 整目录删(已空,无 sln/csproj 引用;删前 grep 确认)。
- **浙商报表(低确定)**`ExtendReport/ZheShang/`**DataSource** 派发,国联是否配置该 DataSource 未知 → **先查生产菜单/配置确认不可达再删**。同类 `DongWu/GeLin/ZheQi` 报表同理。
### 阶段 4 — 衡泰模型层【中风险,需开关确认】
- 仅当确认 GLMS **未开启** `HedgingSource=衡泰` 与"接收衡泰修改回执"开关时,才删 `HengTaiModel/`(26 文件) + `DataCompare/Dto/`(4 文件)。否则保留。
- 删除前先确认 `DataCompare` 模块整体是否还有非衡泰调用方。
### 阶段 5(独立轨道,不在本计划执行)— 上帝类 seam 抽取
`SwapDealService`/`SwapEodPositionService` 瘦身**不靠删文件**,按 `互换模块独立化最终方案.md` + `互换模块可测性改造Seam实践指南.md` 做领域 seam 抽取;并优先把泄漏在 `RealtimePnlCalc`/`QuotaMonitorService` 的 66 个 `Is{券商}` 分支收回 `BizLogic` 子类。与阶段 04 解耦、可并行。
## 4. 风险与回滚
- **编译验证硬门槛**:本机无 SDK,每阶段交 CI 编译通过;错误几乎都来自漏删引用或误删共享基础设施(`MarginCalculationBase`/`MarginCalcHelper`/`MarginCalcRequests`/`MarginCalcException` 务必保留)。
- **业务决策风险**:删非国联代码 = 永久丧失该部署切换/回退能力。必须业务/架构负责人拍板,不可技术单方面决定。
- **报表 DataSource 误判**:阶段 3C 须运行时确认。
- 每阶段独立 commit`git rm`),便于单阶段回退。
## 5. 预估收益(删除全部确认项后)
| 阶段 | 删除行数(估) | 风险 | 类型 |
|---|---|---|---|
| 0 衡泰死 import | 6 行 | 零 | 纯死代码 |
| 1 保证金 | ≈17,600 生产 +7,000 测试 | 低(确认后) | 死代码 + 类蔓延 |
| 2 BizLogic | 34 文件 ≈3,000+ 行 | 低(确认后) | 多租户残留 |
| 3 前端/插件/报表 | 数百行 + 空目录 | 中 | 公司/开关门控 |
| 4 衡泰模型 | 26+4 文件 ≈1,700 行 | 中(开关确认后) | 供应商集成 |
| **合计** | **≈ 29,000+ 行** | — | — |
> 结论给用户:清死代码是 hygiene 末道工序,能显著降表面积与误接风险,但**不是**上帝类的最佳方向;上帝类最佳方向仍是领域 seam 抽取 + 收回泄漏的券商分支。删前须先确认 GLMS 部署的 Company 值与衡泰/浙商开关。