Merge remote-tracking branch 'origin/glms/feature/1.4.2' into glms/feature/1.4.2-margin
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
MSTest 并行化配置(opt-in:仅在 `dotnet test --settings .runsettings` 时生效)。
|
||||
Workers=0 自动取 CPU 核数;Scope=TestClass 比 Method 安全(同类内静态单例不互踩)。
|
||||
注意:本仓库测试共享静态状态(LogFactory / DataCacheManager / YLServiceLocator 单例,见 UnitTestProject/Program.cs M1)。
|
||||
启用前务必先跑一次串行基线,确认无交叉污染后再作为默认。
|
||||
-->
|
||||
<RunSettings>
|
||||
<MSTest>
|
||||
<Parallelize>
|
||||
<Workers>0</Workers>
|
||||
<Scope>TestClass</Scope>
|
||||
</Parallelize>
|
||||
</MSTest>
|
||||
</RunSettings>
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace YLErp.DBModels
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户黑名单审批及操作日志。
|
||||
/// </summary>
|
||||
[Table("client_blacklog")]
|
||||
public class ClientBlackLog
|
||||
{
|
||||
public long id { get; set; }
|
||||
|
||||
public int ClientBlackId { get; set; }
|
||||
|
||||
public string Changes { get; set; }
|
||||
|
||||
public string OptType { get; set; }
|
||||
|
||||
public string DataType { get; set; }
|
||||
|
||||
public int OptId { get; set; }
|
||||
|
||||
public string OptName { get; set; }
|
||||
|
||||
public DateTime OptDate { get; set; }
|
||||
}
|
||||
|
||||
[NotMapped]
|
||||
public class ClientBlackLogDto : ClientBlackLog
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,13 @@ namespace YLErp.Model
|
||||
[Table("client_black")]
|
||||
public class client_black : DBModelWithOperator, IDataEntity, IDataTraceV2, IClonable<client_black>
|
||||
{
|
||||
public const string 未提交 = "未提交";
|
||||
public const string 新增审批中 = "新增审批中";
|
||||
public const string 新增已拒绝 = "新增已拒绝";
|
||||
public const string 已加入 = "已加入";
|
||||
public const string 删除审批中 = "删除审批中";
|
||||
public const string 删除已拒绝 = "删除已拒绝";
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称
|
||||
/// </summary>
|
||||
@@ -25,6 +32,26 @@ namespace YLErp.Model
|
||||
[DataChange]
|
||||
public string Remarks { get; set; }
|
||||
|
||||
[DisplayName("提交审批时间")]
|
||||
public DateTime? ApprovalOptDate { get; set; }
|
||||
|
||||
[DisplayName("提交审批人")]
|
||||
public string ApprovalOptName { get; set; }
|
||||
|
||||
public int ApprovalProcess { get; set; }
|
||||
|
||||
[DisplayName("状态")]
|
||||
public string State { get; set; } = "";
|
||||
|
||||
[DisplayName("创建人")]
|
||||
public int? creator_id { get; set; }
|
||||
|
||||
[DisplayName("创建人")]
|
||||
public string creator_name { get; set; }
|
||||
|
||||
[DisplayName("创建时间")]
|
||||
public DateTime? creator_time { get; set; }
|
||||
|
||||
public client_black Clone()
|
||||
{
|
||||
return (client_black)MemberwiseClone();
|
||||
|
||||
@@ -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(跨日滚动本金状态)/ AccrualPolicy(EOD 会计政策)正交:
|
||||
/// 本上下文只描述"如何算 + 往哪记",不持有任何交易进度。</para>
|
||||
/// </summary>
|
||||
public readonly struct AccrualContext
|
||||
{
|
||||
/// <summary>年化天数(365 / 360)。</summary>
|
||||
public int AnnualDays { get; }
|
||||
|
||||
/// <summary>舍入精度位数。默认 11(仅未接线的 MarginAccount.AccrueInterest 走此默认;生产融资腿/保证金腿均显式用 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/Exp,decimal 精度无损。</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 realizedInterest(legacy 字段 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 / InterestProfitSum;AccruedToday → 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,121 @@
|
||||
using YLErp.Model;
|
||||
|
||||
namespace YLErp.Modules.ClientModule.Tests
|
||||
{
|
||||
[TestClass]
|
||||
public class ClientBlackApprovalPolicyTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow(client_black.未提交, false)]
|
||||
[DataRow(client_black.新增审批中, false)]
|
||||
[DataRow(client_black.新增已拒绝, false)]
|
||||
[DataRow(client_black.已加入, true)]
|
||||
[DataRow(client_black.删除审批中, true)]
|
||||
[DataRow(client_black.删除已拒绝, true)]
|
||||
public void IsEffective_OnlyAppliedOrPendingRemovalStatesAreEffective(string state, bool expected)
|
||||
{
|
||||
Assert.AreEqual(expected, ClientBlackApprovalPolicy.IsEffective(state));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(client_black.未提交, true)]
|
||||
[DataRow(client_black.新增已拒绝, true)]
|
||||
[DataRow(client_black.新增审批中, false)]
|
||||
[DataRow(client_black.已加入, false)]
|
||||
[DataRow(client_black.删除审批中, false)]
|
||||
[DataRow(client_black.删除已拒绝, false)]
|
||||
public void CanSubmitAddition_OnlyDraftOrRejectedAdditionCanSubmit(string state, bool expected)
|
||||
{
|
||||
Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanSubmitAddition(state));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(client_black.已加入, true)]
|
||||
[DataRow(client_black.删除已拒绝, true)]
|
||||
[DataRow(client_black.未提交, false)]
|
||||
[DataRow(client_black.新增审批中, false)]
|
||||
[DataRow(client_black.新增已拒绝, false)]
|
||||
[DataRow(client_black.删除审批中, false)]
|
||||
public void CanRequestRemoval_OnlyEffectiveNonPendingRemovalStatesCanRequest(string state, bool expected)
|
||||
{
|
||||
Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanRequestRemoval(state));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(client_black.新增审批中, 1, true)]
|
||||
[DataRow(client_black.删除审批中, 1, true)]
|
||||
[DataRow(client_black.新增审批中, 2, false)]
|
||||
[DataRow(client_black.删除审批中, 2, false)]
|
||||
[DataRow(client_black.未提交, 0, false)]
|
||||
public void CanWithdraw_OnlyFirstApprovalNodeCanWithdraw(string state, int approvalProcess, bool expected)
|
||||
{
|
||||
Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanWithdraw(state, approvalProcess));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(client_black.新增审批中, client_black.新增已拒绝)]
|
||||
[DataRow(client_black.删除审批中, client_black.删除已拒绝)]
|
||||
public void RejectedState_DistinguishesAdditionAndRemoval(string state, string expected)
|
||||
{
|
||||
Assert.AreEqual(expected, ClientBlackApprovalPolicy.GetRejectedState(state));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(client_black.新增审批中, client_black.未提交, 0)]
|
||||
[DataRow(client_black.删除审批中, client_black.已加入, -2)]
|
||||
public void WithdrawState_RestoresStateBeforeSubmission(string state, string expectedState, int expectedProcess)
|
||||
{
|
||||
var result = ClientBlackApprovalPolicy.GetWithdrawResult(state);
|
||||
|
||||
Assert.AreEqual(expectedState, result.State);
|
||||
Assert.AreEqual(expectedProcess, result.ApprovalProcess);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(client_black.新增审批中, client_black.已加入, false)]
|
||||
[DataRow(client_black.删除审批中, null, true)]
|
||||
public void GetFinalResult_AdditionAppliesAndRemovalDeletes(string state, string expectedState, bool expectedDelete)
|
||||
{
|
||||
var result = ClientBlackApprovalPolicy.GetFinalResult(state);
|
||||
|
||||
Assert.AreEqual(expectedState, result.State);
|
||||
Assert.AreEqual(expectedDelete, result.ShouldDelete);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(false, client_black.已加入, -2, true)]
|
||||
[DataRow(true, client_black.未提交, 0, false)]
|
||||
public void GetAdditionResult_OnlyEffectiveWithoutApprovalProcess(bool hasApprovalProcess, string expectedState, int expectedProcess, bool expectedEffective)
|
||||
{
|
||||
var result = ClientBlackApprovalPolicy.GetAdditionResult(hasApprovalProcess);
|
||||
|
||||
Assert.AreEqual(expectedState, result.State);
|
||||
Assert.AreEqual(expectedProcess, result.ApprovalProcess);
|
||||
Assert.AreEqual(expectedEffective, result.IsEffective);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(false, client_black.已加入, -2, true)]
|
||||
[DataRow(true, client_black.删除审批中, 1, false)]
|
||||
public void GetRemovalResult_OnlyDeletesImmediatelyWithoutApprovalProcess(bool hasApprovalProcess, string expectedState, int expectedProcess, bool expectedDelete)
|
||||
{
|
||||
var result = ClientBlackApprovalPolicy.GetRemovalResult(hasApprovalProcess);
|
||||
|
||||
Assert.AreEqual(expectedState, result.State);
|
||||
Assert.AreEqual(expectedProcess, result.ApprovalProcess);
|
||||
Assert.AreEqual(expectedDelete, result.ShouldDelete);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(client_black.未提交, true)]
|
||||
[DataRow(client_black.新增已拒绝, true)]
|
||||
[DataRow(client_black.新增审批中, false)]
|
||||
[DataRow(client_black.删除审批中, false)]
|
||||
[DataRow(client_black.已加入, true)]
|
||||
[DataRow(client_black.删除已拒绝, true)]
|
||||
public void CanReplaceRemarks_ApprovalPendingRowsCannotBeOverwritten(string state, bool expected)
|
||||
{
|
||||
Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanReplaceRemarks(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace UnitTestProject.Modules.SwapModule.Accrual
|
||||
{
|
||||
/// <summary>
|
||||
/// 契约参考实现(确认书公式,TEST-MATRIX §8a)——全矩阵统一 oracle 供给。
|
||||
///
|
||||
/// 【独立性约束·勿破坏】本类只实现确认书公式原文,禁止引用任何生产计息引擎类
|
||||
/// (YLErp.Modules.SwapModule.Accrual.* / SwapDealService),否则 oracle 与被测对象同源,
|
||||
/// 失去"独立参考"资格(oracle 分级第一级,见 TEST-MATRIX §7.4)。
|
||||
///
|
||||
/// 确认书公式(国联民生收益互换确认书-现券/ETF 四份一致):
|
||||
/// 参考利率(绝对) = ∏[i=1..k] ( 1 + (FR007i + 利差) × di / 365 ) − 1
|
||||
/// 结息额(平仓部分) = 实际平掉额 × 参考利率(绝对)
|
||||
/// - k = 计息期包含的重置期个数;完整重置期 di = 重置频率(生产 7 天),末段不足按实际日历日
|
||||
/// - 重置期自计息期首日按重置频率依次推算;首个重置期始于计息期首日;末段收口到计息期最后一日
|
||||
/// - 利率确定日 = 每个重置期首日(重置日)的上一个营业日,取该日 FR007
|
||||
/// - 计息期 = 自起始日(含)至到期日(不含)——即算头不算尾 "10"(生产主力条款)
|
||||
/// - 计息基准 A/365
|
||||
///
|
||||
/// 营业日准则:本参考实现按周末近似(周六/周日非营业日);法定节假日历由调用方通过
|
||||
/// 取价委托自行吸收(如按确定日提供同一利率)。测试与生产参数对齐(§8):重置 7 天 / 365。
|
||||
/// </summary>
|
||||
public static class ContractReferenceCalc
|
||||
{
|
||||
/// <summary>
|
||||
/// 参考利率(绝对) = ∏(1 + (FR007i+利差)×di/annualDays) − 1。
|
||||
/// </summary>
|
||||
/// <param name="startDate">计息期首日(含)</param>
|
||||
/// <param name="endDate">计息期末日("10"不含/"11"含,由 calcLast 决定)</param>
|
||||
/// <param name="resetDays">重置频率天数(生产 7)</param>
|
||||
/// <param name="spread">利差(InterestRateDefault,如 +0.25% = 0.0025)</param>
|
||||
/// <param name="fixing">取价委托:入参=利率确定日(重置日上一营业日),返回该日 FR007</param>
|
||||
/// <param name="calcFirst">算头(生产 "10"/"11" 为 true)</param>
|
||||
/// <param name="calcLast">算尾(生产 "10" 为 false)</param>
|
||||
/// <param name="annualDays">计息基准(生产 365)</param>
|
||||
public static decimal ReferenceRateAbsolute(
|
||||
DateTime startDate, DateTime endDate,
|
||||
int resetDays, decimal spread,
|
||||
Func<DateTime, decimal> fixing,
|
||||
bool calcFirst = true, bool calcLast = false,
|
||||
int annualDays = 365)
|
||||
{
|
||||
var totalDays = (endDate - startDate).Days + (calcFirst ? 0 : -1) + (calcLast ? 1 : 0);
|
||||
if (totalDays <= 0) return 0m;
|
||||
|
||||
decimal factor = 1m;
|
||||
var resetDate = startDate; // 首个重置期始于计息期首日
|
||||
var remaining = totalDays;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var di = Math.Min(resetDays, remaining); // 完整期 di=resetDays,末段按实际日历日
|
||||
var fixingDate = PreviousBusinessDay(resetDate);
|
||||
var allIn = fixing(fixingDate) + spread;
|
||||
factor *= 1m + allIn * di / annualDays;
|
||||
remaining -= di;
|
||||
resetDate = resetDate.AddDays(di);
|
||||
}
|
||||
return factor - 1m;
|
||||
}
|
||||
|
||||
/// <summary>结息额(平仓部分)= 实际平掉额 × 参考利率(绝对)。</summary>
|
||||
public static decimal ClosedInterest(decimal closedNotional, decimal referenceRate)
|
||||
=> closedNotional * referenceRate;
|
||||
|
||||
/// <summary>利率确定日 = 重置日的上一营业日(周末近似)。</summary>
|
||||
public static DateTime PreviousBusinessDay(DateTime date)
|
||||
{
|
||||
do { date = date.AddDays(-1); }
|
||||
while (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday);
|
||||
return date;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Newtonsoft.Json;
|
||||
using YLErp;
|
||||
using YLErp.DBModels.Enums;
|
||||
using YLErp.Modules.SwapModule;
|
||||
|
||||
namespace UnitTestProject.Modules.SwapModule.Accrual
|
||||
{
|
||||
/// <summary>
|
||||
/// 契约参考实现 oracle 落地(TEST-MATRIX §7 第 5 步)——两段式:
|
||||
///
|
||||
/// ① oracle 自验证:手算锚点直接钉 ContractReferenceCalc(独立于生产引擎,公式正确性
|
||||
/// 由裁决文档 §1.1/§1.2 已核过的手算值保证——真实规模 5000 万/2.05%/90 天 与玩具 4 天)。
|
||||
/// ② 引擎对照:主力族(mode9 标的期初全价 / mode2 合约名义本金规模 × FR007 × 复利 × "10")
|
||||
/// 盘中 T+0 部分平仓 30%,GetInterests 重放结果 必须 == 契约 oracle(容差 0.01 元,§7.4)。
|
||||
/// 这是本矩阵第一个"契约公式独立参考实现"级 oracle 的引擎对照用例(此前仅有 Excel 手算/工单值)。
|
||||
///
|
||||
/// 引擎对照用恒定 FR007 利率表——刻意免疫"利率确定日=重置日上一营业日 vs 当日"的取价日
|
||||
/// 约定差异(任何确定日取到的都是同一利率),单独验证 ∏ 公式/重置期切分/算头不算尾/末段收口;
|
||||
/// 取价日维度(E 维,66a97e03)由变利率用例在 oracle 侧钉住(§①第 4 例),引擎侧后续补。
|
||||
///
|
||||
/// 坐标登记:mode9/mode2 × 复利 × "10" × T+0 × 部分平仓30% × B=跨12个完整重置期+末段 × E=恒定利率。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class ContractReferenceOracleTest
|
||||
{
|
||||
// ── 生产参数(TEST-MATRIX §8:7 天重置 / A365 / 真实点差 +0.25% / 千万级名义)──
|
||||
private const decimal Spread = 0.0025m; // 点差 +0.25%(确认书真实点差)
|
||||
private const decimal Fr007 = 0.018m; // FR007 示意水平 1.8% → all-in 2.05%
|
||||
private const int ResetDays = 7;
|
||||
private const int AnnualDaysConst = 365;
|
||||
private const decimal Notional = 50_000_000m; // 名义 5000 万
|
||||
private const decimal ClosedNotional = 15_000_000m; // 平掉 30% = 1500 万
|
||||
private const decimal ClosePercent = 0.3m;
|
||||
|
||||
private static readonly DateTime StartDate = new(2026, 4, 27); // 周一,起息日
|
||||
private static readonly DateTime Unwind90 = new(2026, 7, 26); // 90 天 = 12×7 + 6 末段
|
||||
private static readonly DateTime Unwind89 = new(2026, 7, 25); // 89 天 = 12×7 + 5 末段
|
||||
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
|
||||
|
||||
#region ① oracle 自验证(手算锚点)
|
||||
|
||||
[TestMethod]
|
||||
public void 契约公式_恒定利率_90天12整期加6天末段_等于手算()
|
||||
{
|
||||
var rate = ContractReferenceCalc.ReferenceRateAbsolute(
|
||||
StartDate, Unwind90, ResetDays, Spread, _ => Fr007,
|
||||
calcFirst: true, calcLast: false, annualDays: AnnualDaysConst);
|
||||
// 手算:(1+0.0205×7/365)^12 × (1+0.0205×6/365) − 1(python 高精度复核)
|
||||
Assert.AreEqual(0.0050666026m, rate, 0.0000000009m, "90 天参考利率(绝对)必须等于 ∏ 公式手算值");
|
||||
|
||||
var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate);
|
||||
Assert.AreEqual(75999.04m, interest, 0.01m, "平掉 1500 万 × 参考利率 = 裁决文档 §1.1 应结值");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 契约公式_恒定利率_89天末段5天_等于手算()
|
||||
{
|
||||
var rate = ContractReferenceCalc.ReferenceRateAbsolute(
|
||||
StartDate, Unwind89, ResetDays, Spread, _ => Fr007,
|
||||
calcFirst: true, calcLast: false, annualDays: AnnualDaysConst);
|
||||
Assert.AreEqual(0.0050101727m, rate, 0.0000000009m, "89 天参考利率(绝对)手算值");
|
||||
|
||||
var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate);
|
||||
Assert.AreEqual(75152.59m, interest, 0.01m);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 契约公式_玩具参数_算头算尾4天_等于裁决文档手算锚点()
|
||||
{
|
||||
// 裁决文档 §1.2:300×[(1+0.011×3/365)×(1+0.011×1/365)−1] = 0.0361652(重置 3 天,利差 1%,FR 0.1%)
|
||||
var rate = ContractReferenceCalc.ReferenceRateAbsolute(
|
||||
new DateTime(2026, 4, 27), new DateTime(2026, 4, 30), resetDays: 3,
|
||||
spread: 0.01m, fixing: _ => 0.001m,
|
||||
calcFirst: true, calcLast: true, annualDays: 365);
|
||||
var interest = ContractReferenceCalc.ClosedInterest(300m, rate);
|
||||
Assert.AreEqual(0.0361652m, interest, 0.000001m);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 契约公式_分段变利率_利率确定日为重置日上一营业日()
|
||||
{
|
||||
// 计息期 [5/4(一), 5/15(五)) "10" → 11 天 = 7 + 4 末段;重置日 5/4、5/11(均为周一)
|
||||
// 契约:利率确定日 = 重置日上一营业日 → 5/1(五)、5/8(五)
|
||||
Assert.AreEqual(new DateTime(2026, 5, 1), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 4)), "5/4(一)的上一营业日是 5/1(五)");
|
||||
Assert.AreEqual(new DateTime(2026, 5, 8), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 11)), "5/11(一)的上一营业日是 5/8(五)");
|
||||
|
||||
var fixings = new Dictionary<DateTime, decimal>
|
||||
{
|
||||
[new DateTime(2026, 5, 1)] = 0.02m, // 第一段 FR007 2.0% → all-in 2.25%
|
||||
[new DateTime(2026, 5, 8)] = 0.03m, // 第二段 FR007 3.0% → all-in 3.25%
|
||||
};
|
||||
var rate = ContractReferenceCalc.ReferenceRateAbsolute(
|
||||
new DateTime(2026, 5, 4), new DateTime(2026, 5, 15), ResetDays, Spread,
|
||||
d => fixings[d], calcFirst: true, calcLast: false, annualDays: AnnualDaysConst);
|
||||
// 手算:(1+0.0225×7/365)×(1+0.0325×4/365)−1 = 0.0007878249
|
||||
Assert.AreEqual(0.0007878249m, rate, 0.0000000009m,
|
||||
"分段变利率下每段必须用各自确定日的 FR007(E 维:取价日=重置日上一营业日)");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ② 引擎对照(恒定 FR007,免疫取价日约定)
|
||||
|
||||
private sealed class StubSwapDealService : SwapDealService
|
||||
{
|
||||
public StubSwapDealService() : base(
|
||||
new OptUserInfo(0, nameof(ContractReferenceOracleTest), OptUserFrom.UnitTest)) { }
|
||||
|
||||
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
|
||||
{
|
||||
if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; }
|
||||
rate = (double)Fr007;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>fresh 重放无历史已结利息,覆写掉 DB 查询(本场景语义即 0)。</summary>
|
||||
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m;
|
||||
}
|
||||
|
||||
private static trade CreateTrade()
|
||||
{
|
||||
var extend = new trade_extend
|
||||
{
|
||||
TradeId = 1,
|
||||
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||||
{
|
||||
AnnualDays = AnnualDaysConst,
|
||||
InterestCalcMode = "10", // 算头不算尾(生产主力条款)
|
||||
SettlementRules = 0
|
||||
})
|
||||
};
|
||||
return new trade
|
||||
{
|
||||
id = 1, TradeNumber = "UT-CONTRACT-REF-ORACLE", ClientId = 999998,
|
||||
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
|
||||
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
|
||||
trade_extend = extend
|
||||
};
|
||||
}
|
||||
|
||||
private static swap_position CreatePosition(InterestModeEnum mode) =>
|
||||
new()
|
||||
{
|
||||
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)mode,
|
||||
InterestRateDefault = Spread, InterestPrincipalFix = Notional,
|
||||
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
|
||||
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利,
|
||||
IsAnnualized = true, interest_rest_days = ResetDays, interest_rule = 0,
|
||||
FloatRateUnderlyingCode = "FR007",
|
||||
InterestSwapInterval = JsonConvert.SerializeObject(
|
||||
new List<IntervalModel> { new() { Date = ExerciseDate, Rate = Spread, Settlement = 0 } })
|
||||
};
|
||||
|
||||
/// <summary>引擎盘中重放(T+0 fresh 持仓,T0 形状)vs 契约 oracle,容差 0.01 元。</summary>
|
||||
private static void AssertEngineMatchesOracle(
|
||||
InterestModeEnum mode, DateTime unwindDate, decimal posi, decimal closePosi,
|
||||
decimal expectedOracleInterest)
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var position = CreatePosition(mode);
|
||||
var interests = new StubSwapDealService().GetInterests(
|
||||
td, td.trade_extend, unwindDate, unwindDate,
|
||||
new List<eod_swap_position>(), new List<swap_position> { position },
|
||||
posi, closePosi, ClosePercent,
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
tdClose: false, orginPv: posi, add: false, settment: false, newCalcLast: false, closeList: null);
|
||||
|
||||
Assert.AreEqual(1, interests.Count);
|
||||
Assert.IsTrue(Math.Abs(interests[0].InterestAmount - expectedOracleInterest) <= 0.01m,
|
||||
$"mode={mode} 引擎重放 {interests[0].InterestAmount} vs 契约 oracle {expectedOracleInterest}," +
|
||||
$"diff={interests[0].InterestAmount - expectedOracleInterest}——引擎偏离确认书公式(TEST-MATRIX §8a)");
|
||||
}
|
||||
|
||||
private static decimal OracleInterest(DateTime unwindDate) =>
|
||||
ContractReferenceCalc.ClosedInterest(ClosedNotional,
|
||||
ContractReferenceCalc.ReferenceRateAbsolute(
|
||||
StartDate, unwindDate, ResetDays, Spread, _ => Fr007,
|
||||
calcFirst: true, calcLast: false, annualDays: AnnualDaysConst));
|
||||
|
||||
[TestMethod]
|
||||
public void 引擎_mode9_复利FR007_10_部分平仓30_90天_等于契约oracle()
|
||||
=> AssertEngineMatchesOracle(InterestModeEnum.标的期初全价, Unwind90, Notional, Notional, OracleInterest(Unwind90));
|
||||
|
||||
[TestMethod]
|
||||
public void 引擎_mode9_复利FR007_10_部分平仓30_89天_等于契约oracle()
|
||||
=> AssertEngineMatchesOracle(InterestModeEnum.标的期初全价, Unwind89, Notional, Notional, OracleInterest(Unwind89));
|
||||
|
||||
[TestMethod]
|
||||
public void 引擎_mode2_复利FR007_10_部分平仓30_显式平掉额_等于契约oracle()
|
||||
=> AssertEngineMatchesOracle(InterestModeEnum.合约名义本金规模, Unwind90, Notional, ClosedNotional, OracleInterest(Unwind90));
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
-144
@@ -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 = endDate(1天),无重置日。
|
||||
/// 期望利息 = 本金 × 日利率 = 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 列");
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
using YLErp;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
using YLErp.Modules.EodModule;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 端到端:盘中收益互换(DividendIn 由生产方法 GetPreEodDividendSum 真实算出)→ 保存 → EOD,
|
||||
/// 验证分红【不重复累计】(EOD TdCloseDividend 扣减 DividendIn)且【不丢失】(当日新计进 PosiDividendSum)。
|
||||
///
|
||||
/// 与 MultiUnwindDividendConservationTest.MU_001 的区别:MU_001 的互换 DividendIn 是测试喂的常量;
|
||||
/// 本测试的 DividendIn 由生产方法 GetPreEodDividendSum 真实算出(读 EOD 快照),再喂给 EOD——
|
||||
/// 覆盖"预览算 DividendIn + EOD 扣减"的完整链路(MU_001 的缺口)。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class DividendEodNoDoubleCountTest
|
||||
{
|
||||
private const int SwapTradeId = 9200;
|
||||
private const long PositionId = 9201;
|
||||
private const decimal InitialQty = 1000m;
|
||||
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>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>真实 BondPaymentService(reg_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/CopyEodPosition;CalcBondPayment 桥接真实 BondPaymentService(reg_date 口径,不再用线性假公式)。</summary>
|
||||
private sealed class EodSvcStub : TestableSwapEodPositionService
|
||||
{
|
||||
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)
|
||||
{
|
||||
// 桥接真实生产口径: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 };
|
||||
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
|
||||
{ vobp = 0m; return 1.00m; }
|
||||
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);
|
||||
public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate)
|
||||
=> CopyEodPosition(eod, null, td, valueDate, preSettleDate);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 数据构建
|
||||
|
||||
private static trade CreateTrade() => new trade
|
||||
{
|
||||
id = SwapTradeId, TradeNumber = "UT-DIV-EOD-001", ClientId = 999999,
|
||||
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
|
||||
ExerciseDate = new DateTime(2027, 1, 5), TradeStatus = "确认成交", ValidState = "Valid",
|
||||
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
|
||||
OriginalStockEqvNotional = (double)(InitialQty * 1.00m)
|
||||
};
|
||||
|
||||
private static swap_position CreatePosition() => new swap_position
|
||||
{
|
||||
id = PositionId, SwapTradeId = SwapTradeId,
|
||||
PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long,
|
||||
UnderlyingCode = "210210.IB", ContractSize = 1m,
|
||||
PosiQuantity = InitialQty, PosiNotionalValue = InitialQty,
|
||||
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 = SwapTradeId, PositionId = PositionId,
|
||||
ValueDate = StartDate, PosiQuantity = InitialQty,
|
||||
PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long,
|
||||
UnderlyingCode = "210210.IB", 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 SwapEvent(decimal dividendIn, DateTime eventDate) => new swap_flow_event
|
||||
{
|
||||
SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.互换,
|
||||
PositionId = PositionId, Quantity = 0m, DividendIn = dividendIn,
|
||||
MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m,
|
||||
EventDate = eventDate, PayDate = eventDate,
|
||||
DataState = (int)SwapFlowDateStateEnum.完成
|
||||
};
|
||||
|
||||
private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) => new swap_flow_event
|
||||
{
|
||||
SwapTradeId = SwapTradeId, 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(Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}");
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。
|
||||
/// 验证:不重复(EOD TdCloseDividend 扣 DividendIn)+ 不丢失(当日新计进 PosiDividendSum)+ 守恒。
|
||||
///
|
||||
/// 序列(StartDate=1/5,reg_date 1/6、1/7 各一期,每期 = qty×per100/100 = 10):
|
||||
/// D1=1/6 无事件 Copy:窗口(1/5,1/6] 命中 reg_date 1/6 → TdPosiDividend=10,PosiDividendSum=10
|
||||
/// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_event;EOD 窗口(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(BondPayments());
|
||||
var td = CreateTrade();
|
||||
var position = CreatePosition();
|
||||
var initialEod = CreateInitialEod();
|
||||
|
||||
// D1=1/6 无事件 EOD
|
||||
var d1 = new DateTime(2026, 1, 6);
|
||||
var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate);
|
||||
AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum(0+1天×10)");
|
||||
|
||||
// D2=1/7 盘中:DividendIn 由生产方法 GetPreEodDividendSum 真实算(读 D1 EOD,当日 EOD 未生成)
|
||||
var d2 = new DateTime(2026, 1, 7);
|
||||
var dealSvc = new DealSvcStub(
|
||||
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } },
|
||||
new List<eod_swap_position> { r1 });
|
||||
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2);
|
||||
AssertDecimalEqual(10m, dividendIn, 0.01m, "盘中 DividendIn=GetPreEodDividendSum 读 T-1(D1)=10");
|
||||
Console.WriteLine($"[盘中预览] DividendIn={dividendIn}(读 T-1 EOD PosiDividendSum={r1.PosiDividendSum})");
|
||||
|
||||
// 保存互换事件(DividendIn=真实算出的值,模拟界面点收益互换后保存)
|
||||
var swapEvent = SwapEvent(dividendIn, d2);
|
||||
|
||||
// D2=1/7 EOD(UpdateEodPosition,真实生产递推)
|
||||
var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List<swap_flow_event> { swapEvent });
|
||||
|
||||
// 断言:不重复 + 不丢失
|
||||
AssertDecimalEqual(10m, r2.TdPosiDividend, 0.01m, "D2 当日新计(1天×10)");
|
||||
AssertDecimalEqual(dividendIn, r2.TdCloseDividend, 0.01m, "D2 TdCloseDividend=互换DividendIn(扣减→不重复累计)");
|
||||
AssertDecimalEqual(10m, r2.PosiDividendSum, 0.01m, "D2 PosiDividendSum=前日10+新计10-实现10=10(当日新计挂着→不丢失)");
|
||||
|
||||
// 守恒:全程新计 - 全程实现 = 末尾 PosiDividendSum
|
||||
decimal totalNew = r1.TdPosiDividend + r2.TdPosiDividend;
|
||||
decimal totalRealized = r2.TdCloseDividend;
|
||||
AssertDecimalEqual(r2.PosiDividendSum, totalNew - totalRealized, 0.01m,
|
||||
$"守恒:末尾 PosiDividendSum({r2.PosiDividendSum}) = 全程新计({totalNew}) - 全程实现({totalRealized})");
|
||||
|
||||
Console.WriteLine($"[EOD 后] TdPosiDividend={r2.TdPosiDividend} TdCloseDividend={r2.TdCloseDividend} PosiDividendSum={r2.PosiDividendSum}");
|
||||
Console.WriteLine($"结论:互换实现 {dividendIn} 被扣减(不重复);当日新计 {r2.TdPosiDividend} 挂 PosiDividendSum(不丢失)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红
|
||||
/// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。
|
||||
///
|
||||
/// 系统行为:①盘中 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(BondPayments());
|
||||
var td = CreateTrade();
|
||||
var position = CreatePosition();
|
||||
var initialEod = CreateInitialEod();
|
||||
|
||||
// D1=1/6 无事件 EOD
|
||||
var d1 = new DateTime(2026, 1, 6);
|
||||
var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate);
|
||||
AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum");
|
||||
|
||||
// D2=1/7 盘中全平:DividendIn 由生产方法真实算(读 D1 EOD,当日 EOD 未生成)
|
||||
var d2 = new DateTime(2026, 1, 7);
|
||||
var dealSvc = new DealSvcStub(
|
||||
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } },
|
||||
new List<eod_swap_position> { r1 });
|
||||
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2);
|
||||
AssertDecimalEqual(10m, dividendIn, 0.01m, "全平 DividendIn=读T-1(D1)=10(漏 D2 当日新计)");
|
||||
|
||||
// 全平事件(扣全部持仓)
|
||||
var closeEvent = CloseEvent(InitialQty, dividendIn, d2);
|
||||
|
||||
// D2=1/7 EOD(UpdateEodPosition,全平→PosiQuantity=0)
|
||||
var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List<swap_flow_event> { closeEvent });
|
||||
|
||||
// 业务规定:登记日当日全平(盘中平仓→收盘持仓为 0),按各交易场所规定不享有登记日当日的分红
|
||||
// (股权登记日以收盘在册为准)。故应得 = T日(登记日)之前的待实现累计 = r1.PosiDividendSum(不含登记日当日)。
|
||||
// 系统行为正确:①DividendIn 读 T-1(=T日前待实现,正确不含当日);②EOD 全平 PosiQuantity=0 不计提当日。
|
||||
// 即登记日当日分红既不进 DividendIn 也不进 PosiDividendSum = 正确不享有。
|
||||
decimal expectedTotal = r1.PosiDividendSum; // 应得 = T日前待实现(不含登记日当日,因全平不享有)
|
||||
decimal actualGot = dividendIn + r2.PosiDividendSum;
|
||||
|
||||
Console.WriteLine($"[登记日全平] 应得(T日前待实现)={expectedTotal}, 实拿(DividendIn+PosiDividendSum)={actualGot}");
|
||||
Console.WriteLine($"[登记日全平] DividendIn={dividendIn}, EOD:TdPosiDividend={r2.TdPosiDividend} PosiDividendSum={r2.PosiDividendSum} PosiQuantity={r2.PosiQuantity}");
|
||||
|
||||
// 断言:实拿 = 应得(登记日全平不享有当日,符合交易场所规定)
|
||||
AssertDecimalEqual(expectedTotal, actualGot, 0.01m,
|
||||
$"实拿应=应得(T日前待实现{expectedTotal}),登记日全平不享有当日分红(符合交易场所规定)");
|
||||
AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "登记日全平 EOD 不计提当日(PosiQuantity=0,正确)");
|
||||
AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【死代码删除的边界规格】脏数据(OriginalStockEqvNotional=null / PosiNetPrice=0)不得让
|
||||
/// UpdateEodPosition 崩溃,且分红产出与正常数据完全一致。
|
||||
/// 背景:这两个字段在 UpdateEodPosition 内的唯一消费点是历史遗留死代码
|
||||
/// (originNotional→totalPayment 全历史重算,结果从未被使用,2026-08 论证后删除)——
|
||||
/// 删除前该脏数据会在 EOD 抛 InvalidOperationException/除零;删除后是设计内行为。
|
||||
/// 本测试同时钉住:删除后输出等价(与同输入正常数据路径一致)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void 脏数据边界_死代码涉及字段_不影响EOD分红产出()
|
||||
{
|
||||
// 正常数据基准
|
||||
var eodSvcClean = new EodSvcStub(BondPayments());
|
||||
var tdClean = CreateTrade();
|
||||
var positionClean = CreatePosition();
|
||||
var initialEod = CreateInitialEod();
|
||||
var d1 = new DateTime(2026, 1, 6);
|
||||
var d2 = new DateTime(2026, 1, 7);
|
||||
var r1Clean = eodSvcClean.ExecuteCopyEodPosition(initialEod, tdClean, d1, StartDate);
|
||||
var r2Clean = eodSvcClean.ExecuteUpdateEodPosition(positionClean, r1Clean, tdClean, d2, d1,
|
||||
new List<swap_flow_event> { CloseEvent(InitialQty, r1Clean.PosiDividendSum, d2) });
|
||||
|
||||
// 脏数据:死代码涉及的两字段置脏(活路径零消费,见方法内 grep 论证)
|
||||
var eodSvcDirty = new EodSvcStub(BondPayments());
|
||||
var tdDirty = CreateTrade();
|
||||
tdDirty.OriginalStockEqvNotional = null; // 死代码 (decimal) 强转崩溃点
|
||||
var positionDirty = CreatePosition();
|
||||
positionDirty.PosiNetPrice = 0m; // 死代码除零崩溃点
|
||||
var r1Dirty = eodSvcDirty.ExecuteCopyEodPosition(initialEod, tdDirty, d1, StartDate);
|
||||
var r2Dirty = eodSvcDirty.ExecuteUpdateEodPosition(positionDirty, r1Dirty, tdDirty, d2, d1,
|
||||
new List<swap_flow_event> { CloseEvent(InitialQty, r1Dirty.PosiDividendSum, d2) });
|
||||
|
||||
// 脏数据不崩 + 输出与正常数据逐字段一致
|
||||
AssertDecimalEqual(r2Clean.TdPosiDividend, r2Dirty.TdPosiDividend, 0.0001m, "TdPosiDividend 不受脏字段影响");
|
||||
AssertDecimalEqual(r2Clean.TdCloseDividend, r2Dirty.TdCloseDividend, 0.0001m, "TdCloseDividend 不受脏字段影响");
|
||||
AssertDecimalEqual(r2Clean.PosiDividendSum, r2Dirty.PosiDividendSum, 0.0001m, "PosiDividendSum 不受脏字段影响");
|
||||
AssertDecimalEqual(r2Clean.RealizedDividend, r2Dirty.RealizedDividend, 0.0001m, "RealizedDividend 不受脏字段影响");
|
||||
Console.WriteLine($"[脏数据边界] 正常={r2Clean.PosiDividendSum} 脏数据={r2Dirty.PosiDividendSum}(应相等且不抛异常)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,16 @@ namespace YLErp.Modules.SwapModule
|
||||
private static readonly DateTime PayDate = new(2026, 4, 6);
|
||||
private static readonly DateTime PreRegDate = new(2026, 4, 2);
|
||||
|
||||
// 多次付息日历(截图:债券 230004.IB,每期票息 0.1808,共 5 次登记日)
|
||||
private static readonly DateTime[] RegDates = {
|
||||
new(2026, 2, 28), new(2026, 4, 3), new(2026, 4, 29),
|
||||
new(2026, 5, 29), new(2026, 6, 29)
|
||||
};
|
||||
private static readonly DateTime[] PayDates = {
|
||||
new(2026, 3, 2), new(2026, 4, 6), new(2026, 4, 30),
|
||||
new(2026, 6, 1), new(2026, 6, 30)
|
||||
};
|
||||
|
||||
#region 成因 A:日期口径 seam
|
||||
|
||||
private sealed class TestableBondPaymentService : BondPaymentService
|
||||
@@ -60,6 +70,60 @@ namespace YLErp.Modules.SwapModule
|
||||
"当前按支付日(pay_date_PL=4/6)过滤会漏选->0条,导致分红不计提。");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CauseA_MultiRegDate_跨登记日区间命中正确子集()
|
||||
{
|
||||
var records = Enumerable.Range(0, 5).Select(i => new BondPayment
|
||||
{
|
||||
underlyingCode = BondCode,
|
||||
reg_date = RegDates[i],
|
||||
payment_date_pl = PayDates[i],
|
||||
payment_date = PayDates[i],
|
||||
payment_interest = PaymentPer100
|
||||
}).ToList();
|
||||
var svc = new TestableBondPaymentService(records);
|
||||
|
||||
// 单次窗口:每个登记日各自命中 1 条(验证按 reg_date 过滤,非支付日)
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var prev = i == 0 ? RegDates[i].AddDays(-1) : RegDates[i - 1];
|
||||
var hit = svc.GetBondPayments(BondCode, prev, RegDates[i]);
|
||||
Assert.AreEqual(1, hit.Count, $"窗口({prev:yyyy-MM-dd},{RegDates[i]:yyyy-MM-dd}] 应仅命中登记日 {RegDates[i]:yyyy-MM-dd} 那条");
|
||||
Assert.AreEqual(RegDates[i], hit[0].reg_date, "命中的应是该登记日记录");
|
||||
}
|
||||
|
||||
// 长区间应命中全部 5 条,不漏不混
|
||||
var all = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]);
|
||||
Assert.AreEqual(5, all.Count, "长区间(登记日1前,登记日5] 应命中全部 5 次付息");
|
||||
|
||||
// 跨登记日中间区间:(4/2, 4/29] 应命中 4/3 与 4/29 两条(不含 2/28、5/29、6/29)
|
||||
var mid = svc.GetBondPayments(BondCode, new DateTime(2026, 4, 2), new DateTime(2026, 4, 29));
|
||||
Assert.AreEqual(2, mid.Count, "(4/2,4/29] 应命中 4/3+4/29 两条");
|
||||
CollectionAssert.AreEquivalent(
|
||||
new[] { new DateTime(2026, 4, 3), new DateTime(2026, 4, 29) },
|
||||
mid.Select(x => x.reg_date!.Value).ToArray());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CauseA_MultiRegDate_CalcPayment累加五期票息()
|
||||
{
|
||||
var records = Enumerable.Range(0, 5).Select(i => new BondPayment
|
||||
{
|
||||
underlyingCode = BondCode,
|
||||
reg_date = RegDates[i],
|
||||
payment_date_pl = PayDates[i],
|
||||
payment_date = PayDates[i],
|
||||
payment_interest = PaymentPer100
|
||||
}).ToList();
|
||||
var svc = new TestableBondPaymentService(records);
|
||||
|
||||
// 长区间取全部 5 期,CalcPayment 应累加 = 5 × 36160 = 180,800(原测试仅覆盖单期)
|
||||
var payments = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]);
|
||||
var total = svc.CalcPayment(payments, Qty, 1, 1);
|
||||
Assert.AreEqual(5 * ExpectedDividend, total, 0.01m,
|
||||
"5 期票息累加应为 5 × 36,160 = 180,800;单期口径会漏计其余 4 期");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 成因 B:T-1 快照 seam
|
||||
@@ -107,6 +171,65 @@ namespace YLErp.Modules.SwapModule
|
||||
"当前 GetPreEodDividendSum 用 ValueDate < dealDate 读 T-1 快照->0。");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CauseB_MultiRegDate_Auto实现归0后下次登记日重新累加()
|
||||
{
|
||||
// 模拟:登记日1(2/28)计提 36160 → auto互换实现归0(3/1) → 登记日2(4/3)再计提 36160
|
||||
var eodSwaps = new List<eod_swap>
|
||||
{
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,27) },
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,28) },
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,3,1) },
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,2) },
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) },
|
||||
};
|
||||
var eodPositions = new List<eod_swap_position>
|
||||
{
|
||||
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,27), PosiDividendSum = 0m, PosiQuantity = Qty },
|
||||
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,28), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty },
|
||||
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,3,1), PosiDividendSum = 0m, PosiQuantity = Qty },
|
||||
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,2), PosiDividendSum = 0m, PosiQuantity = Qty },
|
||||
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty },
|
||||
};
|
||||
var svc = new TestableSwapDealService(eodSwaps, eodPositions);
|
||||
|
||||
// 登记日2(4/3)当天手动互换:应读 4/3 EOD = 36160(第二次,非第一次已实现的、非 0)
|
||||
var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3));
|
||||
Assert.AreEqual(ExpectedDividend, dividend, 0.01m,
|
||||
"登记日2(4/3)手动互换应读当日EOD=第二次分红36160;" +
|
||||
"若读T-1(4/2=0)则漏当日,若读2/28则错取第一次已实现的。");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CauseB_MultiRegDate_手动互换期间分红挂账累计四期()
|
||||
{
|
||||
// 模拟:多次登记日之间未 auto 实现,分红挂账累加
|
||||
// 4/3=36160, 4/29=72320, 5/29=108480, 6/29=144640(4期累计)
|
||||
var eodSwaps = new List<eod_swap>
|
||||
{
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) },
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,29) },
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,5,29) },
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,6,29) },
|
||||
};
|
||||
var eodPositions = new List<eod_swap_position>
|
||||
{
|
||||
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = 1 * ExpectedDividend, PosiQuantity = Qty },
|
||||
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,29), PosiDividendSum = 2 * ExpectedDividend, PosiQuantity = Qty },
|
||||
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,5,29), PosiDividendSum = 3 * ExpectedDividend, PosiQuantity = Qty },
|
||||
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,6,29), PosiDividendSum = 4 * ExpectedDividend, PosiQuantity = Qty },
|
||||
};
|
||||
var svc = new TestableSwapDealService(eodSwaps, eodPositions);
|
||||
|
||||
// 每次登记日当天手动互换应读到该日累计值(验证多次付息累计被正确读取)
|
||||
Assert.AreEqual(1 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3)), 0.01m, "4/3 应读 36160");
|
||||
Assert.AreEqual(2 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 29)), 0.01m, "4/29 应读 72320(2期累计)");
|
||||
Assert.AreEqual(3 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 5, 29)), 0.01m, "5/29 应读 108480(3期累计)");
|
||||
// 关键:第 4 期登记日累计 = 4 × 36160 = 144640(原 9df39491 仅覆盖单期 36160,未验证多次付息累计)
|
||||
Assert.AreEqual(4 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 6, 29)), 0.01m,
|
||||
"6/29 应读 144640(4期累计);原 9df39491 仅覆盖单期 36160,未验证多次付息累计。");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,470 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// GetInterests 双显式入口语义字符化测试(Step3"特判降级"的前置钉子)。
|
||||
///
|
||||
/// 背景:GetIntradayUnwindInterests(盘中:平仓前剩余×实际比例)与
|
||||
/// CalcEodPostCloseSettleInterests(EOD平仓后收盘:平仓后剩余×恒1)是同一经济事件
|
||||
/// (部分平仓)的两套传参语义,靠 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底粘合。
|
||||
/// 本测试钉死当前行为,使后续特判降级/语义重构有回归网:
|
||||
/// ① 复利×mode2:closePrincipal(特判产物)是 CalcDailyCompoundInterest 的重放本金——
|
||||
/// 两入口 closePosiNotionalValue 均为实际平掉额 → InterestAmount 必须相等;
|
||||
/// ② 单利×mode2:CalcDailySimpleInterest 消费的是 posiPrincipal×closePercent——
|
||||
/// 盘中(平仓前×比例) vs EOD(剩余×1) 数值口径可能不同,本测试【记录现状】(见各断言注释);
|
||||
/// ③ mode9 全平(posi=0):兜底覆盖生效,结息额非零。
|
||||
///
|
||||
/// 数据基建复用 GetInterestsUnitTest_T0 的构建器口径(T+0,4/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;
|
||||
}
|
||||
// 离线自洽:本测试场景无历史已结利息,等价于此前"空库查询返回 0"的行为,
|
||||
// 使复利路径(GetConsumedInterest)不再依赖数据库连通(YLErp_UNIT_TEST_SKIP_INITIALIZATION=1 可跑)。
|
||||
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
|
||||
=> 0m;
|
||||
}
|
||||
|
||||
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%:【同请求形状⇒同额】oracle(契约目标语义,修复落地时的现成回归网)。
|
||||
///
|
||||
/// 修复前(b01b485e 钉住的分歧):盘中 0.036165(平掉额全程重放=确认书公式)vs
|
||||
/// EOD 0.059042(恒1 掉进全平专属分支,全腿待实现+末段增量,无契约依据,重算结果被丢弃)。
|
||||
/// 修复(契约修复§六):EOD 普通当日平仓重算(autoSwap=false)改传 Intraday 形状
|
||||
/// (平仓前剩余+实际平掉额+真实比例),部分平仓不再进 closePrecent==1 分支。
|
||||
/// 依据:项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md(契约公式唯一确定应结=平掉额×全程参考利率)。
|
||||
/// 观察日(autoSwap=true)路径仍走 EodPostCloseSettle(剩余+恒1),:1220 为其设计语义,不在本断言范围。
|
||||
/// </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));
|
||||
|
||||
// 契约目标形状(修复暂缓中,生产仍传 剩余+恒1):与盘中一致(平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3)
|
||||
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
eodPositions, positions, PreClose, Closed, ClosePercent,
|
||||
(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}");
|
||||
|
||||
// 契约 oracle:两入口同请求形状必须同额(=确认书公式"平掉额×全程参考利率")
|
||||
Assert.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m,
|
||||
"GetInterests 层契约目标:同请求形状必须同额(生产入口修复暂缓中,本断言为落地时的现成回归网)");
|
||||
// 手算锚点(300×[(1+0.011×3/365)×(1+0.011×1/365)−1],与裁决文档§二玩具参数一致)
|
||||
Assert.AreEqual(0.036165m, Math.Round(intraday[0].InterestAmount, 6, MidpointRounding.AwayFromZero),
|
||||
"盘中重放=契约公式手算锚点 0.036165");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【回归钉子】复利×mode2×部分平仓:观察日路径(EodPostCloseSettle 剩余+恒1)保持设计语义不回退。
|
||||
/// 修复只改 autoSwap=false 分支;观察日恒1 全量结息是 :1220 分支的设计意图(结现),锁死其当前值。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void 复利_mode2_部分平仓_观察日恒1语义保持()
|
||||
{
|
||||
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 observationDay = 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, observationDay.Count);
|
||||
Assert.AreEqual(0.059041913305m, observationDay[0].InterestAmount, 0.000000001m,
|
||||
"观察日(autoSwap=true)路径:剩余+恒1 的全平分支为其设计语义(结现),修复不得改变此值");
|
||||
}
|
||||
|
||||
/// <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));
|
||||
|
||||
// 契约目标形状(修复暂缓中,生产仍传 剩余+恒1):与盘中一致(平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3)
|
||||
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
eodPositions, positions, PreClose, Closed, ClosePercent,
|
||||
(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.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m,
|
||||
"GetInterests 层契约目标:单利×mode2 同请求形状必须同额(生产入口修复暂缓中)");
|
||||
Assert.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// mode9 全平(契约目标形状:平仓前剩余=平掉额=1000、比例恒1):
|
||||
/// 结息额非零且=全平语义(:1220 全平分支:待实现+末段增量,尾差一次带走——裁决§五.2 维持)。
|
||||
/// </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 };
|
||||
|
||||
// 全平:平仓前剩余=平掉=1000,比例恒1(全平专属分支)
|
||||
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
eodPositions, positions, PreClose, 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 全平:结息本金=平掉额(1000),结息额非零(全平语义钉子)");
|
||||
}
|
||||
|
||||
#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
|
||||
|
||||
#region 守恒不变量(§7-1, 免 oracle/免库, 守 EOD平仓后收盘×部分平仓 裸格)
|
||||
|
||||
// 守恒不变量统一断言在"剩余持仓前递"(preEod.PosiNotionalValue)上:该字段由 CalcUnwindInterest/
|
||||
// InitSwapDealInterest 在 preEod.id==0 时写入(posiPrincipal),与利息算法(单/复、FR007)无关,
|
||||
// 是最稳健、码算、免库的守恒观测点。期初(orginPv) = 前递剩余 + 平掉额(closePosiNotionalValue) 必须成立。
|
||||
// 全部内存构造(StubSwapDealService 避库);funding-leg(mode2)不触发早路由 continue,故亦是早路由改动护栏。
|
||||
|
||||
/// <summary>
|
||||
/// 建一个"无历史 eod"快照(id==0),使引擎把本次剩余持仓写入 preEod.PosiNotionalValue。
|
||||
/// </summary>
|
||||
private static eod_swap_position NewPreEod(decimal carryPrincipal)
|
||||
=> new()
|
||||
{
|
||||
id = 0, SwapTradeId = 1, PositionId = 1001,
|
||||
ValueDate = new DateTime(2026, 4, 29), ClientId = 999998,
|
||||
FloatRate = 0m, TdInterestPrincipal = carryPrincipal,
|
||||
PosiNotionalValue = carryPrincipal, InterestIncomeSum = 0.05m, InterestProfitSum = 0.05m
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// §7-1 守恒①:EOD平仓后收盘×部分平仓,引擎把剩余持仓(700)前递进 preEod.PosiNotionalValue,
|
||||
/// 且 期初 = 前递剩余(码算) + 平掉额(输入) = 1000。
|
||||
/// 守 2035e1df 裸格(§6 空洞1):若 EOD 入口把前递值误写成平掉额/期初,守恒等式即破。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void EOD平仓后收盘_部分平仓_守恒_剩余前递且期初等于剩余加平掉额()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
|
||||
var preEod = NewPreEod(Remaining); // 无历史 eod → 引擎写回剩余
|
||||
var eodPositions = new List<eod_swap_position> { preEod };
|
||||
var positions = new List<swap_position> { position };
|
||||
|
||||
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
eodPositions, positions, Remaining, Closed, 1m,
|
||||
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
|
||||
add: false, settment: false, newCalcLast: false, closeList: null);
|
||||
|
||||
Assert.AreEqual(1, result.Count, "EOD平仓后收盘部分平仓应产生 1 条利息事件");
|
||||
// 码算:引擎把剩余持仓前递(return 700)
|
||||
Assert.AreEqual(Remaining, preEod.PosiNotionalValue,
|
||||
"EOD平仓后收盘必须把剩余持仓(700)前递进 preEod.PosiNotionalValue;若误写平掉额/期初则守恒破坏");
|
||||
// 守恒:期初 = 前递剩余(码算) + 平掉额(输入)
|
||||
Assert.AreEqual(PreClose, preEod.PosiNotionalValue + Closed,
|
||||
"期初(orginPv=1000) 必须 = 剩余(700) + 平掉额(300);本金口径不守恒则利息算错");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §7-1 守恒②:EOD平仓后收盘×全平,剩余持仓前递=0(清仓)。守全平非零边界的互补面。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void EOD平仓后收盘_全平_守恒_剩余前递归零()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
|
||||
var preEod = NewPreEod(0m);
|
||||
var eodPositions = new List<eod_swap_position> { preEod };
|
||||
var positions = new List<swap_position> { position };
|
||||
|
||||
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
eodPositions, positions, 0m, PreClose, 1m,
|
||||
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
|
||||
add: false, settment: false, newCalcLast: false, closeList: null);
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual(0m, preEod.PosiNotionalValue,
|
||||
"全平后剩余持仓前递必须为 0;非 0 表示平仓未清仓,守恒破坏");
|
||||
Assert.AreEqual(PreClose, preEod.PosiNotionalValue + PreClose,
|
||||
"全平守恒:期初(1000) = 剩余(0) + 平掉额(1000)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §7-1 守恒③(逐日):两次部分平仓,Day2 剩余前递 = 当日剩余(码算),且 期初 - 前递剩余 = 平掉额,
|
||||
/// 构成跨日携带链守恒。Day1 期初1000→平300剩700;Day2 期初700→平210剩490;累计平掉510+剩余490=1000。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void EOD平仓后收盘_两次部分平仓_逐日守恒_期初减剩余前递等于平掉额()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
|
||||
|
||||
// Day1:期初1000,平300,剩700
|
||||
var preEod1 = NewPreEod(PreClose);
|
||||
var result1 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
new List<eod_swap_position> { preEod1 }, new List<swap_position> { position },
|
||||
Remaining, Closed, 1m,
|
||||
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
|
||||
add: false, settment: false, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, result1.Count);
|
||||
Assert.AreEqual(Remaining, preEod1.PosiNotionalValue, "Day1 剩余前递应为 700");
|
||||
|
||||
// Day2:期初=Day1剩余700,平210,剩490
|
||||
const decimal day2OrginPv = 700m;
|
||||
const decimal day2Closed = 210m;
|
||||
const decimal day2Remaining = 490m;
|
||||
var preEod2 = NewPreEod(day2OrginPv); // 承载=Day1剩余700
|
||||
var result2 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
|
||||
new List<eod_swap_position> { preEod2 }, new List<swap_position> { position },
|
||||
day2Remaining, day2Closed, 1m,
|
||||
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: day2OrginPv,
|
||||
add: false, settment: false, newCalcLast: false, closeList: null);
|
||||
|
||||
Assert.AreEqual(1, result2.Count);
|
||||
// 码算:Day2 剩余前递=当日剩余(490)
|
||||
Assert.AreEqual(day2Remaining, preEod2.PosiNotionalValue, "Day2 剩余前递=剩余(490,码算值)");
|
||||
// 逐日守恒:期初 - 剩余前递 = 平掉额(210)
|
||||
Assert.AreEqual(day2Closed, day2OrginPv - preEod2.PosiNotionalValue,
|
||||
"Day2 守恒:期初(700) - 剩余前递(490) 必须 = 平掉额(210);跨日携带链本金不守恒则利息算错");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §7-1 守恒④(纯数学,ClosePercentMath):多次平仓累计占期初比例 = 1 - ∏(1 - 各次剩余口径)。
|
||||
/// 初次占期初30%(平300/名义1000)→剩余口径0.3;二次占期初50%(平350/剩余700)→剩余口径0.5;
|
||||
/// 累计平掉 = 1 - 0.7×0.5 = 0.65。验证 ClosePercentMath 双口径换算在多次平仓下不漂移。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void 多次平仓_占期初累计比例等于各次剩余口径连乘补数()
|
||||
{
|
||||
var b1 = ClosePercentMath.ToRemainingClosePercent(0.3m, 1000m, 1000m);
|
||||
Assert.AreEqual(0.3m, b1, "初次平仓占期初30% → 剩余口径应为 0.3");
|
||||
var b2 = ClosePercentMath.ToRemainingClosePercent(0.5m, 700m, 700m);
|
||||
Assert.AreEqual(0.5m, b2, "二次平仓占期初50%(占剩余700) → 剩余口径应为 0.5");
|
||||
|
||||
var cumulativeClosed = 1m - (1m - b1) * (1m - b2);
|
||||
Assert.AreEqual(0.65m, cumulativeClosed, 0.0000001m,
|
||||
"多次平仓累计平掉比例必须=各次剩余口径连乘的补数;否则本金口径在多次平仓下分裂");
|
||||
|
||||
var back = ClosePercentMath.ToOriginalClosePercent(cumulativeClosed, 1000m, 1000m);
|
||||
Assert.AreEqual(0.65m, back, 0.0000001m, "累计占期初比例反向还原必须一致");
|
||||
}
|
||||
|
||||
#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
|
||||
{
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YLErp.Derivatives.Interest;
|
||||
using YLErp.Modules.SwapModule.Margin;
|
||||
|
||||
namespace UnitTestProject.Modules.SwapModule.Margin
|
||||
{
|
||||
/// <summary>
|
||||
/// 保证金账户(MarginAccount)单测。验证余额变动(追加/释放/返还)。
|
||||
/// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class MarginLegTest
|
||||
{
|
||||
private const decimal Opening = 2_000_000m;
|
||||
|
||||
#region MarginAccount 余额变动
|
||||
|
||||
[TestMethod]
|
||||
public void 账户_初始余额等于期初保证金()
|
||||
{
|
||||
var account = new MarginAccount(new MarginBalance(Opening));
|
||||
Assert.AreEqual(Opening, account.Balance.Balance);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 账户_追加保证金_余额增加()
|
||||
{
|
||||
var account = new MarginAccount(new MarginBalance(Opening));
|
||||
account.Deposit(500_000m);
|
||||
Assert.AreEqual(2_500_000m, account.Balance.Balance);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 账户_释放保证金_余额减少()
|
||||
{
|
||||
var account = new MarginAccount(new MarginBalance(Opening));
|
||||
account.Withdraw(800_000m);
|
||||
Assert.AreEqual(1_200_000m, account.Balance.Balance);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 账户_释放超过余额_不低于零()
|
||||
{
|
||||
var account = new MarginAccount(new MarginBalance(Opening));
|
||||
account.Withdraw(3_000_000m);
|
||||
Assert.AreEqual(0m, account.Balance.Balance, "保证金余额不低于零");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 三种保证金形态解析器
|
||||
|
||||
[TestMethod]
|
||||
public void 三种形态解析器_各自返回正确Form和余额()
|
||||
{
|
||||
IMarginResolver cash = new CashMargin();
|
||||
IMarginResolver credit = new CreditMargin();
|
||||
IMarginResolver guarantee = new GuaranteeMargin();
|
||||
|
||||
Assert.AreEqual(MarginForm.Cash, cash.Form);
|
||||
Assert.AreEqual(MarginForm.Credit, credit.Form);
|
||||
Assert.AreEqual(MarginForm.Guarantee, guarantee.Form);
|
||||
|
||||
Assert.AreEqual(Opening, cash.Resolve(Opening).Balance);
|
||||
Assert.AreEqual(Opening, credit.Resolve(Opening).Balance);
|
||||
Assert.AreEqual(Opening, guarantee.Resolve(Opening).Balance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MarginAccount 计息
|
||||
|
||||
[TestMethod]
|
||||
public void 计息_单利7天_余额200万年化3pct()
|
||||
{
|
||||
var account = new MarginAccount(new MarginBalance(2_000_000m));
|
||||
// 200万 × 3% / 365 × 7天 = 1150.68...
|
||||
var r = account.AccrueInterest(
|
||||
rate: 0.03m,
|
||||
startDate: new System.DateTime(2026, 5, 4),
|
||||
endDate: new System.DateTime(2026, 5, 11),
|
||||
boundary: AccrualBoundary.StartOnly,
|
||||
annualDays: 365);
|
||||
|
||||
Assert.IsTrue(r.Accrued > 0, "7天利息应大于0");
|
||||
System.Console.WriteLine($"保证金7天利息={r.Accrued}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 计息_零余额_利息为零()
|
||||
{
|
||||
var account = new MarginAccount(new MarginBalance(0m));
|
||||
var r = account.AccrueInterest(0.03m,
|
||||
new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11),
|
||||
AccrualBoundary.StartOnly, 365);
|
||||
|
||||
Assert.AreEqual(0m, r.Accrued);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void 计息_释放后余额减少_利息相应减少()
|
||||
{
|
||||
var full = new MarginAccount(new MarginBalance(2_000_000m));
|
||||
var half = new MarginAccount(new MarginBalance(2_000_000m));
|
||||
half.Withdraw(1_000_000m);
|
||||
|
||||
var rFull = full.AccrueInterest(0.03m,
|
||||
new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11),
|
||||
AccrualBoundary.StartOnly, 365);
|
||||
var rHalf = half.AccrueInterest(0.03m,
|
||||
new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11),
|
||||
AccrualBoundary.StartOnly, 365);
|
||||
|
||||
Assert.IsTrue(rHalf.Accrued < rFull.Accrued, "释放后利息应更少");
|
||||
Assert.IsTrue(System.Math.Abs(rFull.Accrued - rHalf.Accrued * 2m) < 0.01m,
|
||||
"余额减半, 利息也应减半");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace UnitTestProject.Modules.SwapModule.Margin
|
||||
{
|
||||
/// <summary>
|
||||
/// MarginModes 统一判断口径测试。
|
||||
/// 验证它和现有散落的 marginTypes/InterestMarginModels/premiumModes 内容一致。
|
||||
/// 验证 MarginModes 由框架常量 ConsTrade.InterestMarginModels 派生,内容一致。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class MarginModesTest
|
||||
@@ -37,7 +37,7 @@ namespace UnitTestProject.Modules.SwapModule.Margin
|
||||
Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.标的期初全价));
|
||||
}
|
||||
|
||||
/// <summary>守护:和 ConsTrade.InterestMarginModels 内容必须一致(迁移期对齐)。</summary>
|
||||
/// <summary>回归护栏:MarginModes 由 ConsTrade.InterestMarginModels 派生,内容须一致(防止有人又独立重写集合导致口径分裂)。</summary>
|
||||
[TestMethod]
|
||||
public void 与ConsTradeInterestMarginModels内容一致()
|
||||
{
|
||||
|
||||
@@ -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 重新桥接回【真实的 BondPaymentService(reg_date 口径)】,
|
||||
/// 仅用内存 BondPayment 数据(不连库),使端到端流程(CopyEodPosition/UpdateEodPosition + GetPreEodDividendSum)
|
||||
/// 真正跑生产日期逻辑:
|
||||
/// ① EOD 引擎在登记日计提、支付日不计提(证明 reg_date 口径);
|
||||
/// ② 登记日下一日(T+1)全平:经 GetPreEodDividendSum 读到登记日当日 EOD 分红(收盘在册→享有);
|
||||
/// ③ 部分平仓 T+1:DividendIn 为全量(非按比例缩放),剩余 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 stub(CalcBondPayment 桥接真实 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 stub(GetPreEodDividendSum,注入 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_date(4/3 不>4/3;4/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:引擎算出分红 36160(reg_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=全量 36160;T+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+1:DividendIn 仍为全量 36160(非按 50% 缩放)");
|
||||
|
||||
// T+1=4/4 EOD 部分平仓(Quantity=Qty/2):PosiQuantity>0;TdPosiDividend=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];
|
||||
}
|
||||
|
||||
@@ -30,9 +30,24 @@ namespace YLErp
|
||||
|
||||
YLServiceLocator.SetServiceCollection(services);
|
||||
|
||||
AppManager.Initialize(YLErp.Enums.SubSystemName.UnitTest, configuration);
|
||||
|
||||
DataCacheManager.UpdateOnce();
|
||||
// P0 容错(2026-08-16):初始化段(AppManager.Initialize 内部 InitializePsConfig 读 AppConfig 表、
|
||||
// DataCacheManager.UpdateOnce 预热)在测试库不可达时不再让 ModuleInitializer 抛异常连坐全部 903 个
|
||||
// 测试——降级为醒目警告,纯内存测试照常可跑;依赖配置/缓存/库的测试将以各自的连接错误失败
|
||||
// (与降级前表现一致,只是不再全红归因到"类创建失败")。完全跳过初始化仍用
|
||||
// YLErp_UNIT_TEST_SKIP_INITIALIZATION=1。实测触发链:M1→AppManager.Initialize→ConfigDic→
|
||||
// ServerVersion.AutoDetect→MySQL 不可达(Program.cs:33,2026-08-16 栈实证)。
|
||||
try
|
||||
{
|
||||
AppManager.Initialize(YLErp.Enums.SubSystemName.UnitTest, configuration);
|
||||
DataCacheManager.UpdateOnce();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var warn = $"[UnitTest初始化降级] 初始化段失败(测试库不可达?):{ex.GetType().Name}: {ex.Message}。" +
|
||||
"纯内存测试继续;依赖配置/缓存/数据库的测试将失败——这是网络问题不是代码问题。";
|
||||
Console.WriteLine(warn);
|
||||
logger.Warn(warn);
|
||||
}
|
||||
|
||||
services.AddHttpClient("")
|
||||
.ConfigurePrimaryHttpMessageHandler(messageHandler =>
|
||||
|
||||
@@ -19,6 +19,8 @@ namespace BaseOUDAL
|
||||
|
||||
public DbSet<client_black> client_black { get; set; }
|
||||
|
||||
public DbSet<ClientBlackLog> client_blacklog { get; set; }
|
||||
|
||||
public DbSet<client_file> client_file { get; set; }
|
||||
|
||||
public DbSet<client_file_audit> client_file_audit { get; set; }
|
||||
@@ -57,4 +59,4 @@ namespace BaseOUDAL
|
||||
|
||||
public DbSet<ClientCustomerManage> client_customer_manage { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace YLErp.Model
|
||||
{
|
||||
public class ClientBlackApprovalQueryRes
|
||||
{
|
||||
public int id { get; set; }
|
||||
public string EncryptId { get; set; }
|
||||
public string ProcessStatus { get; set; }
|
||||
public int ProcessOrderId { get; set; }
|
||||
public string ProcessRoleName { get; set; }
|
||||
public string ClientName { get; set; }
|
||||
public int ProcessRoleId { get; set; }
|
||||
public string Comments { get; set; }
|
||||
public string ApprovalOptName { get; set; }
|
||||
public DateTime? ApprovalOptDate { get; set; }
|
||||
public string State { get; set; }
|
||||
public int? creator_id { get; set; }
|
||||
public string creator_name { get; set; }
|
||||
public DateTime? creator_time { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using YLErp.Helpers;
|
||||
|
||||
namespace YLErp.Model
|
||||
{
|
||||
/// <summary>
|
||||
/// 黑名单审批请求。
|
||||
/// </summary>
|
||||
public class ClientBlackAuditReq
|
||||
{
|
||||
public string enid { get; set; }
|
||||
|
||||
public int id => DataProtectHelper.DecryptInt(enid);
|
||||
|
||||
public string status { get; set; }
|
||||
|
||||
public string auditComment { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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;}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ namespace YLErp.Model
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
public DateTime? DateFromOptDate { get; set; }
|
||||
|
||||
public DateTime? DateToOptDate { get; set; }
|
||||
|
||||
public string ClientBlackStates { get; set; }
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using YLErp.Model;
|
||||
|
||||
namespace YLErp.Modules.ClientModule
|
||||
{
|
||||
public static class ClientBlackApprovalPolicy
|
||||
{
|
||||
public static readonly string[] EffectiveStates =
|
||||
{
|
||||
client_black.已加入,
|
||||
client_black.删除审批中,
|
||||
client_black.删除已拒绝
|
||||
};
|
||||
|
||||
public static bool IsEffective(string state)
|
||||
{
|
||||
return EffectiveStates.Contains(state);
|
||||
}
|
||||
|
||||
public static bool CanSubmitAddition(string state)
|
||||
{
|
||||
return state == client_black.未提交 || state == client_black.新增已拒绝;
|
||||
}
|
||||
|
||||
public static ClientBlackAdditionResult GetAdditionResult(bool hasApprovalProcess)
|
||||
{
|
||||
return hasApprovalProcess
|
||||
? new ClientBlackAdditionResult(client_black.未提交, 0, false)
|
||||
: new ClientBlackAdditionResult(client_black.已加入, -2, true);
|
||||
}
|
||||
|
||||
public static bool CanRequestRemoval(string state)
|
||||
{
|
||||
return state == client_black.已加入 || state == client_black.删除已拒绝;
|
||||
}
|
||||
|
||||
public static ClientBlackRemovalResult GetRemovalResult(bool hasApprovalProcess)
|
||||
{
|
||||
return hasApprovalProcess
|
||||
? new ClientBlackRemovalResult(client_black.删除审批中, 1, false)
|
||||
: new ClientBlackRemovalResult(client_black.已加入, -2, true);
|
||||
}
|
||||
|
||||
public static bool CanReplaceRemarks(string state)
|
||||
{
|
||||
return state != client_black.新增审批中 && state != client_black.删除审批中;
|
||||
}
|
||||
|
||||
public static bool CanWithdraw(string state, int approvalProcess)
|
||||
{
|
||||
return approvalProcess == 1 &&
|
||||
(state == client_black.新增审批中 || state == client_black.删除审批中);
|
||||
}
|
||||
|
||||
public static string GetRejectedState(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
client_black.新增审批中 => client_black.新增已拒绝,
|
||||
client_black.删除审批中 => client_black.删除已拒绝,
|
||||
_ => throw new ArgumentException("当前状态不允许拒绝审批", nameof(state))
|
||||
};
|
||||
}
|
||||
|
||||
public static ClientBlackWithdrawResult GetWithdrawResult(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
client_black.新增审批中 => new ClientBlackWithdrawResult(client_black.未提交, 0),
|
||||
client_black.删除审批中 => new ClientBlackWithdrawResult(client_black.已加入, -2),
|
||||
_ => throw new ArgumentException("当前状态不允许撤回审批", nameof(state))
|
||||
};
|
||||
}
|
||||
|
||||
public static ClientBlackFinalResult GetFinalResult(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
client_black.新增审批中 => new ClientBlackFinalResult(client_black.已加入, false),
|
||||
client_black.删除审批中 => new ClientBlackFinalResult(null, true),
|
||||
_ => throw new ArgumentException("当前状态不允许完成审批", nameof(state))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct ClientBlackWithdrawResult(string State, int ApprovalProcess);
|
||||
public readonly record struct ClientBlackFinalResult(string State, bool ShouldDelete);
|
||||
public readonly record struct ClientBlackAdditionResult(string State, int ApprovalProcess, bool IsEffective);
|
||||
public readonly record struct ClientBlackRemovalResult(string State, int ApprovalProcess, bool ShouldDelete);
|
||||
}
|
||||
@@ -41,6 +41,19 @@ namespace YLErp.Modules.ClientModule
|
||||
{
|
||||
predicate = predicate.And(d => d.Name.Contains(req.Name));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(req.ClientBlackStates))
|
||||
{
|
||||
var states = req.ClientBlackStates.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
predicate = predicate.And(d => states.Contains(d.State));
|
||||
}
|
||||
if (req.DateFromOptDate.HasValue)
|
||||
{
|
||||
predicate = predicate.And(d => d.OptDate >= req.DateFromOptDate.Value);
|
||||
}
|
||||
if (req.DateToOptDate.HasValue)
|
||||
{
|
||||
predicate = predicate.And(d => d.OptDate < req.DateToOptDate.Value.AddDays(1));
|
||||
}
|
||||
}
|
||||
|
||||
var query = DbContext.client_black.AsNoTracking().Where(predicate);
|
||||
@@ -93,6 +106,314 @@ namespace YLErp.Modules.ClientModule
|
||||
return retListResult;
|
||||
}
|
||||
|
||||
public List<approvalprocess> ProcessList()
|
||||
{
|
||||
return DbContextFactory.GetYLDbContext().approvalprocess
|
||||
.Where(s => s.processType == "ClientBlackProcess")
|
||||
.OrderBy(s => s.order)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public void DeleteClientBlack(IEnumerable<int> ids)
|
||||
{
|
||||
var idList = ids?.Distinct().ToList() ?? new List<int>();
|
||||
if (idList.Count == 0)
|
||||
{
|
||||
throw new ServiceException("请选择要移出的黑名单客户");
|
||||
}
|
||||
|
||||
var rows = DbContext.client_black.Where(x => idList.Contains(x.id)).ToList();
|
||||
if (rows.Count != idList.Count)
|
||||
{
|
||||
throw new ServiceException("未找到要删除的数据");
|
||||
}
|
||||
|
||||
var hasProcess = ProcessList().Any();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!ClientBlackApprovalPolicy.CanRequestRemoval(row.State))
|
||||
{
|
||||
throw new ServiceException($"黑名单客户{row.Name}当前状态不允许移出");
|
||||
}
|
||||
|
||||
if (hasProcess)
|
||||
{
|
||||
var result = ClientBlackApprovalPolicy.GetRemovalResult(true);
|
||||
row.State = result.State;
|
||||
row.ApprovalProcess = result.ApprovalProcess;
|
||||
row.ApprovalOptName = UserName;
|
||||
row.ApprovalOptDate = DateTime.Now;
|
||||
ClientBlackCategoryLog(row.id, client_black.删除审批中);
|
||||
}
|
||||
else
|
||||
{
|
||||
RemoveEffectiveBlack(row);
|
||||
}
|
||||
}
|
||||
|
||||
DbContext.SaveChanges();
|
||||
}
|
||||
|
||||
public void WithdrawApprovalClientBlack(List<int> ids, out int withdrawCount, out string msg)
|
||||
{
|
||||
withdrawCount = 0;
|
||||
msg = "";
|
||||
var rows = DbContext.client_black.Where(x => ids.Contains(x.id)).ToList();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!ClientBlackApprovalPolicy.CanWithdraw(row.State, row.ApprovalProcess))
|
||||
{
|
||||
if (row.ApprovalProcess > 1)
|
||||
{
|
||||
msg += row.Name + ",";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = ClientBlackApprovalPolicy.GetWithdrawResult(row.State);
|
||||
row.State = result.State;
|
||||
row.ApprovalProcess = result.ApprovalProcess;
|
||||
row.ApprovalOptName = null;
|
||||
row.ApprovalOptDate = null;
|
||||
ClientBlackCategoryLog(row.id, row.State);
|
||||
withdrawCount++;
|
||||
}
|
||||
DbContext.SaveChanges();
|
||||
}
|
||||
|
||||
public void SubmitApprovalClientBlack(List<int> ids)
|
||||
{
|
||||
var rows = DbContext.client_black.Where(x => ids.Contains(x.id)).ToList();
|
||||
var process = ProcessList();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!ClientBlackApprovalPolicy.CanSubmitAddition(row.State))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (process.Count == 0)
|
||||
{
|
||||
row.State = client_black.已加入;
|
||||
row.ApprovalProcess = -2;
|
||||
row.ApprovalOptName = UserName;
|
||||
row.ApprovalOptDate = DateTime.Now;
|
||||
var notifications = new List<(Client oldClient, Client newClient)>();
|
||||
ApplyEffectiveAddition(row.Name, notifications);
|
||||
ClientBlackCategoryLog(row.id, client_black.已加入, "未设置审批流程,直接通过");
|
||||
DbContext.SaveChanges();
|
||||
SendClientNotifications(notifications);
|
||||
continue;
|
||||
}
|
||||
|
||||
row.State = client_black.新增审批中;
|
||||
row.ApprovalProcess = 1;
|
||||
row.ApprovalOptName = UserName;
|
||||
row.ApprovalOptDate = DateTime.Now;
|
||||
ClientBlackCategoryLog(row.id, client_black.新增审批中);
|
||||
}
|
||||
DbContext.SaveChanges();
|
||||
}
|
||||
|
||||
public string AuditClientBlack(ClientBlackAuditReq req, bool isBatch = false, string optType = "")
|
||||
{
|
||||
var row = DbContext.client_black.Find(req.id);
|
||||
if (row == null)
|
||||
{
|
||||
throw new ServiceException("审批失败,系统中没有该黑名单记录");
|
||||
}
|
||||
if (row.State != client_black.新增审批中 && row.State != client_black.删除审批中)
|
||||
{
|
||||
throw new ServiceException("当前黑名单不在审批中");
|
||||
}
|
||||
|
||||
var process = ProcessList();
|
||||
var currentNode = process.FirstOrDefault(x => x.order == row.ApprovalProcess);
|
||||
if (currentNode == null || !UserBLL.GetRolesByUserId(UserId).Any(x => x.Id == currentNode.roleId))
|
||||
{
|
||||
throw new ServiceException("当前用户无权审批该节点");
|
||||
}
|
||||
if (req.status == "reject")
|
||||
{
|
||||
row.State = ClientBlackApprovalPolicy.GetRejectedState(row.State);
|
||||
row.ApprovalProcess = -1;
|
||||
row.ApprovalOptDate = DateTime.Now;
|
||||
row.OptId = UserId;
|
||||
row.OptName = UserName;
|
||||
row.OptDate = DateTime.Now;
|
||||
ClientBlackCategoryLog(row.id, row.State, req.auditComment);
|
||||
DbContext.SaveChanges();
|
||||
return "提交成功";
|
||||
}
|
||||
|
||||
if (req.status != "pass")
|
||||
{
|
||||
throw new ServiceException("status参数不支持:" + req.status);
|
||||
}
|
||||
|
||||
var nextNode = process.FirstOrDefault(x => x.order > row.ApprovalProcess);
|
||||
if (nextNode != null)
|
||||
{
|
||||
row.ApprovalProcess = nextNode.order;
|
||||
row.ApprovalOptDate = DateTime.Now;
|
||||
row.OptId = UserId;
|
||||
row.OptName = UserName;
|
||||
row.OptDate = DateTime.Now;
|
||||
ClientBlackCategoryLog(row.id, row.State, req.auditComment);
|
||||
DbContext.SaveChanges();
|
||||
return "提交成功";
|
||||
}
|
||||
|
||||
var final = ClientBlackApprovalPolicy.GetFinalResult(row.State);
|
||||
if (final.ShouldDelete)
|
||||
{
|
||||
RemoveEffectiveBlack(row, req.auditComment, isBatch ? optType : null);
|
||||
DbContext.SaveChanges();
|
||||
}
|
||||
else
|
||||
{
|
||||
row.State = final.State;
|
||||
row.ApprovalProcess = -2;
|
||||
row.ApprovalOptDate = DateTime.Now;
|
||||
var notifications = new List<(Client oldClient, Client newClient)>();
|
||||
ApplyEffectiveAddition(row.Name, notifications);
|
||||
ClientBlackCategoryLog(row.id, isBatch ? optType : row.State, req.auditComment);
|
||||
DbContext.SaveChanges();
|
||||
SendClientNotifications(notifications);
|
||||
}
|
||||
return "提交成功";
|
||||
}
|
||||
|
||||
public SearchListResult<ClientBlackApprovalQueryRes> ClientBlackApprovalQuery(ClientBlackReq req)
|
||||
{
|
||||
var process = ProcessList();
|
||||
var predicate = PredicateBuilder.Create<client_black>(x => x.ApprovalProcess > 0);
|
||||
if (!string.IsNullOrWhiteSpace(req.Name))
|
||||
{
|
||||
predicate = predicate.And(x => x.Name.Contains(req.Name));
|
||||
}
|
||||
var query = from row in DbContext.client_black.AsNoTracking().Where(predicate)
|
||||
select new ClientBlackApprovalQueryRes
|
||||
{
|
||||
id = row.id,
|
||||
EncryptId = row.EncryptId,
|
||||
ProcessOrderId = row.ApprovalProcess,
|
||||
ProcessRoleId = 0,
|
||||
ProcessStatus = "审批中 流程" + (row.ApprovalProcess - 1) + "/" + process.Count,
|
||||
State = row.State,
|
||||
ClientName = row.Name,
|
||||
Comments = row.Remarks,
|
||||
ApprovalOptName = row.ApprovalOptName,
|
||||
ApprovalOptDate = row.ApprovalOptDate,
|
||||
creator_id = row.creator_id,
|
||||
creator_name = row.creator_name,
|
||||
creator_time = row.creator_time
|
||||
};
|
||||
if (string.IsNullOrEmpty(req.sidx))
|
||||
{
|
||||
req.sidx = "ApprovalOptDate";
|
||||
req.sord = "desc";
|
||||
}
|
||||
var result = query.OrderByDescending(x => x.ApprovalOptDate).ToSearchList(req);
|
||||
var roles = new ErpBaseContext().Roles
|
||||
.Select(x => new { x.Id, x.Name })
|
||||
.ToDictionary(x => x.Id, x => x.Name);
|
||||
foreach (var item in result.rows)
|
||||
{
|
||||
var node = process.FirstOrDefault(x => x.order == item.ProcessOrderId);
|
||||
if (node == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
item.ProcessRoleId = node.roleId;
|
||||
item.ProcessRoleName = roles.TryGetValue(node.roleId, out var roleName) ? roleName : string.Empty;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ApplyEffectiveAddition(string name, List<(Client oldClient, Client newClient)> notifications)
|
||||
{
|
||||
var client = DbContext.client.FirstOrDefault(c => c.Name == name);
|
||||
if (client == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var dt = DateTime.Now;
|
||||
var oldClient = client.Clone();
|
||||
if (client.ProcessStatus == "已开户")
|
||||
{
|
||||
client.ProcessOrderId = -4;
|
||||
client.ProcessStatus = "已休眠";
|
||||
client.OptId = UserId;
|
||||
client.OptName = UserName;
|
||||
client.OptDate = dt;
|
||||
DbContext.ClientAuditLog.Add(new ClientAuditLog
|
||||
{
|
||||
ClientId = client.id,
|
||||
OptType = "休眠",
|
||||
Changes = string.Empty,
|
||||
DataType = "00",
|
||||
OptId = UserId,
|
||||
OptName = UserName,
|
||||
OptDate = dt
|
||||
});
|
||||
notifications.Add((oldClient, client));
|
||||
}
|
||||
DbContext.ClientAuditLog.Add(new ClientAuditLog
|
||||
{
|
||||
ClientId = client.id,
|
||||
OptType = "加入黑名单",
|
||||
Changes = string.Empty,
|
||||
DataType = "00",
|
||||
OptId = UserId,
|
||||
OptName = UserName,
|
||||
OptDate = dt
|
||||
});
|
||||
}
|
||||
|
||||
private void RemoveEffectiveBlack(client_black row, string changes = null, string optType = null)
|
||||
{
|
||||
var client = DbContext.client.FirstOrDefault(c => c.Name == row.Name);
|
||||
if (client != null)
|
||||
{
|
||||
DbContext.ClientAuditLog.Add(new ClientAuditLog
|
||||
{
|
||||
ClientId = client.id,
|
||||
OptType = "移除黑名单",
|
||||
Changes = string.Empty,
|
||||
DataType = "00",
|
||||
OptId = UserId,
|
||||
OptName = UserName,
|
||||
OptDate = DateTime.Now
|
||||
});
|
||||
}
|
||||
DbContext.client_black.Remove(row);
|
||||
ClientBlackCategoryLog(row.id, optType ?? "已删除", changes);
|
||||
}
|
||||
|
||||
private void SendClientNotifications(List<(Client oldClient, Client newClient)> notifications)
|
||||
{
|
||||
foreach (var (oldClient, newClient) in notifications)
|
||||
{
|
||||
new ClientKafkaService(_kafkaProduce).Send(newClient, oldClient);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClientBlackCategoryLog(int clientblackId, string optType, string changes = null)
|
||||
{
|
||||
DbContext.client_blacklog.Add(new ClientBlackLog
|
||||
{
|
||||
ClientBlackId = clientblackId,
|
||||
OptType = optType,
|
||||
Changes = changes,
|
||||
DataType = "00",
|
||||
OptId = UserId,
|
||||
OptName = UserName,
|
||||
OptDate = DateTime.Now
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户黑名单导入
|
||||
/// </summary>
|
||||
@@ -165,37 +486,47 @@ namespace YLErp.Modules.ClientModule
|
||||
|
||||
public void AddClientBlack(IEnumerable<client_black> list, bool checkStatus)
|
||||
{
|
||||
var inputList = list?.ToList() ?? new List<client_black>();
|
||||
var errMsgList = new List<string>();
|
||||
var nameList = list.Select(O => O.Name);
|
||||
var dbList = DbContext.client_black.Where(O => nameList.Contains(O.Name));
|
||||
var nameList = inputList.Select(O => O.Name).ToList();
|
||||
var dbList = DbContext.client_black.Where(O => nameList.Contains(O.Name)).ToList();
|
||||
foreach (var item in dbList)
|
||||
{
|
||||
var obj = inputList.FirstOrDefault(O => O.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (obj == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!ClientBlackApprovalPolicy.CanReplaceRemarks(item.State))
|
||||
{
|
||||
throw new ServiceException("黑名单客户在审批中无法修改!");
|
||||
}
|
||||
if (checkStatus && !string.IsNullOrWhiteSpace(item.Remarks) && item.Remarks != obj.Remarks)
|
||||
{
|
||||
errMsgList.Add($"{item.Name}");
|
||||
}
|
||||
}
|
||||
if (checkStatus)
|
||||
{
|
||||
foreach (var item in dbList)
|
||||
{
|
||||
var obj = list.First(O => O.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(item.Remarks) && item.Remarks != obj.Remarks)
|
||||
{
|
||||
errMsgList.Add($"{item.Name}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (errMsgList.Count > 0)
|
||||
{
|
||||
var msg = "";
|
||||
if (errMsgList.Count <= 5)
|
||||
{
|
||||
msg = $"客户:{string.Join(",", errMsgList)},备注已存在,是否替换?";
|
||||
msg = $"客户:{string.Join(",", errMsgList)}当前已在黑名单中,本次将修改备注,备注已存在,是否确认?";
|
||||
}
|
||||
else
|
||||
{
|
||||
msg = $"{string.Join(",", errMsgList.Take(5))} 等{errMsgList.Count}个客户,备注已存在,是否替换?";
|
||||
msg = $"{string.Join(",", errMsgList.Take(5))} 等{errMsgList.Count}个客户当前已在黑名单中,本次将修改备注,备注已存在,是否确认?";
|
||||
}
|
||||
throw new ServiceException(msg);
|
||||
}
|
||||
}
|
||||
// 在外部定义列表来保存需要通知的客户对
|
||||
var clientsToNotify = new List<(Client oldClient, Client newClient)>();
|
||||
foreach (var item in list)
|
||||
var newItems = new List<client_black>();
|
||||
var processList = ProcessList();
|
||||
foreach (var item in inputList)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Name))
|
||||
{
|
||||
@@ -206,61 +537,45 @@ namespace YLErp.Modules.ClientModule
|
||||
item.OptId = UserId;
|
||||
item.OptName = UserName;
|
||||
item.OptDate = DateTime.Now;
|
||||
var clientexistence = DbContext.client.FirstOrDefault(c => c.Name == item.Name);
|
||||
if (clientexistence != null)
|
||||
var existing = dbList.FirstOrDefault(x => x.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing != null)
|
||||
{
|
||||
var dt = DateTime.Now;
|
||||
if (clientexistence.ProcessStatus == "已开户")
|
||||
var oldRemarks = existing.Remarks;
|
||||
existing.Remarks = item.Remarks;
|
||||
existing.OptId = UserId;
|
||||
existing.OptName = UserName;
|
||||
existing.OptDate = DateTime.Now;
|
||||
if (oldRemarks != existing.Remarks)
|
||||
{
|
||||
var oldClient= clientexistence.Clone();
|
||||
clientexistence.ProcessOrderId = -4;
|
||||
clientexistence.ProcessStatus = "已休眠";
|
||||
clientexistence.OptId = UserId;
|
||||
clientexistence.OptName = UserName;
|
||||
clientexistence.OptDate = dt;
|
||||
|
||||
DbContext.ClientAuditLog.Add(new ClientAuditLog
|
||||
{
|
||||
ClientId = clientexistence.id,
|
||||
OptType = "休眠",
|
||||
Changes = string.Empty,
|
||||
DataType = "00",
|
||||
OptId = UserId,
|
||||
OptName = UserName,
|
||||
OptDate = dt
|
||||
});
|
||||
// 如果原有状态是已开户,添加到通知列表
|
||||
if (oldClient != null)
|
||||
{
|
||||
clientsToNotify.Add((oldClient, clientexistence));
|
||||
}
|
||||
ClientBlackCategoryLog(existing.id, "修改备注", $"备注:{oldRemarks ?? string.Empty} -> {existing.Remarks ?? string.Empty}");
|
||||
}
|
||||
///日志记录
|
||||
DbContext.ClientAuditLog.Add(new ClientAuditLog
|
||||
{
|
||||
ClientId = clientexistence.id,
|
||||
OptType = "加入黑名单",
|
||||
Changes = string.Empty,
|
||||
DataType = "00",
|
||||
OptId = UserId,
|
||||
OptName = UserName,
|
||||
OptDate = dt
|
||||
});
|
||||
continue;
|
||||
}
|
||||
var additionResult = ClientBlackApprovalPolicy.GetAdditionResult(processList.Any());
|
||||
item.State = additionResult.State;
|
||||
item.ApprovalProcess = additionResult.ApprovalProcess;
|
||||
item.creator_id = UserId;
|
||||
item.creator_name = UserName;
|
||||
item.creator_time = DateTime.Now;
|
||||
if (additionResult.IsEffective)
|
||||
{
|
||||
ApplyEffectiveAddition(item.Name, clientsToNotify);
|
||||
}
|
||||
newItems.Add(item);
|
||||
}
|
||||
if (dbList.Any())
|
||||
DbContext.client_black.AddRange(newItems);
|
||||
DbContext.SaveChanges();
|
||||
foreach (var item in newItems)
|
||||
{
|
||||
DbContext.client_black.RemoveRange(dbList);
|
||||
DbContext.SaveChanges();
|
||||
ClientBlackCategoryLog(item.id, item.State);
|
||||
}
|
||||
DbContext.client_black.AddRange(list);
|
||||
DbContext.SaveChanges();
|
||||
// 发送Kafka消息
|
||||
foreach (var (oldClient, newClient) in clientsToNotify)
|
||||
{
|
||||
new ClientKafkaService(_kafkaProduce).Send(newClient, oldClient);
|
||||
}
|
||||
var importHasTagClientNames = list.Where(p => p.Tags != null && p.Tags.Count > 0).Select(p => p.Name).Distinct().ToList();
|
||||
var importHasTagClientNames = inputList.Where(p => p.Tags != null && p.Tags.Count > 0).Select(p => p.Name).Distinct().ToList();
|
||||
if (importHasTagClientNames != null && importHasTagClientNames.Count > 0)
|
||||
{
|
||||
var dbClients = DbContext.client.AsNoTracking().Where(p => importHasTagClientNames.Contains(p.Name)).Select(p => new ClientSimpleDto
|
||||
@@ -273,7 +588,7 @@ namespace YLErp.Modules.ClientModule
|
||||
var tagService = new TagService(OptUser);
|
||||
dbClients.ForEach(p =>
|
||||
{
|
||||
var importInfo = list.FirstOrDefault(d => d.Name.Equals(p.Name));
|
||||
var importInfo = inputList.FirstOrDefault(d => d.Name.Equals(p.Name));
|
||||
if (importInfo != null)
|
||||
{
|
||||
tagService.SetClientTagForClientImport(new TagModule.Dto.SetClientTagForClientEditRequest { ClientId = p.id, Tags = importInfo.Tags });
|
||||
|
||||
@@ -317,7 +317,7 @@ namespace YLErp.Modules.ClientModule
|
||||
{
|
||||
return "第" + rowNum + "行客户类别,机构属性,客户性质关联性质有误,导入失败";
|
||||
}
|
||||
if (DbContext.client_black.Any(c => c.Name == Name))
|
||||
if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
|
||||
{
|
||||
return $"客户'{Name}'已经存在于黑名单中”";
|
||||
}
|
||||
@@ -1070,7 +1070,7 @@ namespace YLErp.Modules.ClientModule
|
||||
}
|
||||
}
|
||||
}
|
||||
if (DbContext.client_black.Any(c => c.Name == Name))
|
||||
if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
|
||||
{
|
||||
return "" + Name + "客户已经存在于黑名单中”";
|
||||
}
|
||||
@@ -1733,7 +1733,7 @@ namespace YLErp.Modules.ClientModule
|
||||
|
||||
//默认为1
|
||||
IsReceiveEmail = 1;
|
||||
if (DbContext.client_black.Any(c => c.Name == Name))
|
||||
if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
|
||||
{
|
||||
return "" + Name + "客户已经存在于黑名单中";
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ namespace YLErp.Modules.ClientModule
|
||||
|
||||
try
|
||||
{
|
||||
if (DbContext.client_black.Any(c => c.Name == client.Name))
|
||||
if (DbContext.client_black.Any(c => c.Name == client.Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
|
||||
{
|
||||
client.RejectOrderId = client.ApprovalOrderId;
|
||||
client.ApprovalOrderId = -1;
|
||||
|
||||
@@ -158,7 +158,7 @@ namespace YLErp.Modules.ClientModule
|
||||
throw new ServiceException("客户名称 必须填写");
|
||||
}
|
||||
|
||||
if (DbContext.client_black.Any(c => c.Name == req.Name))
|
||||
if (DbContext.client_black.Any(c => c.Name == req.Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
|
||||
{
|
||||
throw new ServiceException("该客户为黑名单客户,无法进行下一步操作");
|
||||
}
|
||||
|
||||
@@ -703,7 +703,7 @@ namespace YLErp.Modules.ClientModule
|
||||
|
||||
//新增时,新的客户名如果在黑名单里,不允许新增
|
||||
//修改时,旧的客户名如果在黑名单里,不允许修改
|
||||
if (!isAddNew && !req.Name.Equals(blackNameForCheck) && DbContext.client_black.Any(x => x.Name == blackNameForCheck))
|
||||
if (!isAddNew && !req.Name.Equals(blackNameForCheck) && DbContext.client_black.Any(x => x.Name == blackNameForCheck && ClientBlackApprovalPolicy.EffectiveStates.Contains(x.State)))
|
||||
{
|
||||
throw new ServiceException("该客户为黑名单客户," + (req.id > 0 ? "不允许修改客户名称" : "不允许新增"));
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ namespace YLErp.Modules.EodModule
|
||||
var result = QueryBondPayments(underlyingCode)
|
||||
.Where(x => x.reg_date > startDate && x.reg_date <= endDate)
|
||||
.AsNoTracking().ToList();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,7 @@ SwapModule/
|
||||
│
|
||||
├── Margin/ 保证金(mode 5/6)
|
||||
│ ├── MarginModes mode 判断(含 ForLinq for EF Core)
|
||||
│ ├── MarginBalance 保证金余额(值对象)
|
||||
│ ├── MarginAccount 余额管理 + AccrueInterest 计息入口
|
||||
│ ├── MarginCalc 纯函数(PreviousBalance/FlipDirection/AccumulateSettlement)
|
||||
│ ├── IMarginResolver 保证金形态接口
|
||||
│ └── Cash/Credit/Guarantee 三种形态实现
|
||||
│ └── MarginCalc 纯函数(PreviousBalance/FlipDirection/AccumulateSettlement)
|
||||
│
|
||||
├── ReturnLegs/ 标的端
|
||||
│ ├── ReturnLegSummary 标的端汇总值
|
||||
@@ -66,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)
|
||||
@@ -76,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 契约)
|
||||
|
||||
```
|
||||
@@ -132,7 +140,7 @@ Unknown = 0
|
||||
|---|---|---|
|
||||
| 公司行为(送股/拆股) | QtyRollforward.corpActionDeltaQty | ✅ |
|
||||
| 公司行为(登记日快照) | DividendCalc + BondPayment | 见 corp-action-refactor-proposal.md |
|
||||
| 保证金配置/规则/占用 | MarginAccount + MarginCalc | ✅ |
|
||||
| 保证金配置/规则/占用 | MarginCalc | ✅ |
|
||||
| RecordMarginCashFlow 迁入 Margin | AddClientCash 加 virtual | 待做 |
|
||||
| EOD 编排拆分 | SwapPositionCompose | 待业务需求驱动 |
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+4
-8
@@ -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 realizedInterest(legacy 字段 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 / InterestProfitSum;AccruedToday → 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>
|
||||
/// 【盘中平仓/互换结息】场景(→ GetIntradayUnwindInterests,settment: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 当日有平仓后的收盘结息】场景(→ CalcEodPostCloseSettleInterests,settment: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,10 +0,0 @@
|
||||
namespace YLErp.Modules.SwapModule.Margin;
|
||||
|
||||
/// <summary>现金保证金:余额 = 现金余额。</summary>
|
||||
public sealed class CashMargin : IMarginResolver
|
||||
{
|
||||
public MarginForm Form => MarginForm.Cash;
|
||||
|
||||
public MarginBalance Resolve(decimal postedAmount)
|
||||
=> new(postedAmount);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace YLErp.Modules.SwapModule.Margin;
|
||||
|
||||
/// <summary>授信保证:余额 = 已用授信额度。</summary>
|
||||
public sealed class CreditMargin : IMarginResolver
|
||||
{
|
||||
public MarginForm Form => MarginForm.Credit;
|
||||
|
||||
public MarginBalance Resolve(decimal postedAmount)
|
||||
=> new(postedAmount);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace YLErp.Modules.SwapModule.Margin;
|
||||
|
||||
/// <summary>担保品:余额 = 担保品市值。</summary>
|
||||
public sealed class GuaranteeMargin : IMarginResolver
|
||||
{
|
||||
public MarginForm Form => MarginForm.Guarantee;
|
||||
|
||||
public MarginBalance Resolve(decimal postedAmount)
|
||||
=> new(postedAmount);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
namespace YLErp.Modules.SwapModule.Margin;
|
||||
|
||||
/// <summary>保证金形态:现金 / 授信 / 担保。预留扩展。</summary>
|
||||
public enum MarginForm
|
||||
{
|
||||
/// <summary>现金保证金:余额 = 现金余额。</summary>
|
||||
Cash,
|
||||
/// <summary>授信保证:余额 = 已用授信额度。</summary>
|
||||
Credit,
|
||||
/// <summary>担保品:余额 = 担保品市值。</summary>
|
||||
Guarantee,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按保证金形态解析余额。三种形态可互换地产出一个 MarginBalance(满足 LSP),
|
||||
/// 这是保证金领域唯一合理的多态点(差异仅在"余额如何取得")。
|
||||
/// 具体余额来源(资金流水 / 授信占用 / 担保估值)后续按形态填充。
|
||||
/// </summary>
|
||||
public interface IMarginResolver
|
||||
{
|
||||
MarginForm Form { get; }
|
||||
|
||||
MarginBalance Resolve(decimal postedAmount);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using YLErp.Core.Interest;
|
||||
using YLErp.Derivatives.Interest;
|
||||
|
||||
namespace YLErp.Modules.SwapModule.Margin;
|
||||
|
||||
/// <summary>
|
||||
/// 保证金账户。管理保证金余额的变动(追加/释放/返还),并提供计息入口(预留抽象,尚未接线)。
|
||||
///
|
||||
/// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保),与融资腿(funding leg)无关。
|
||||
/// 生产保证金计息入口为 SwapDealService.CalcMarginInterest(仍以 InterestMode 5/6 标识):
|
||||
/// EOD 用昨日终本金 preEod.TdInterestPrincipal(无差分);盘中用 accrualBasis 差分(orginPv 经 PreviousBalance)。
|
||||
/// 本类尚未被生产代码实例化——其扁平"余额×利率×天数"模型无法表达盘中差分与多行分段,留作未来简化抽象。
|
||||
/// </summary>
|
||||
public sealed class MarginAccount
|
||||
{
|
||||
/// <summary>当前保证金余额。</summary>
|
||||
public MarginBalance Balance { get; private set; }
|
||||
|
||||
public MarginAccount(MarginBalance openingBalance)
|
||||
=> Balance = openingBalance;
|
||||
|
||||
/// <summary>追加保证金(余额增加)。</summary>
|
||||
public void Deposit(decimal amount)
|
||||
=> Balance = new MarginBalance(Balance.Balance + amount);
|
||||
|
||||
/// <summary>释放/返还保证金(余额减少,不低于 0)。</summary>
|
||||
public void Withdraw(decimal amount)
|
||||
=> Balance = new MarginBalance(Math.Max(0m, Balance.Balance - amount));
|
||||
|
||||
/// <summary>
|
||||
/// 按当前余额计算保证金利息。委托 SwapInterest.AccrueSimple。
|
||||
/// 注意:当前未被生产代码调用——生产保证金计息入口为 SwapDealService.CalcMarginInterest
|
||||
/// (处理 EOD 昨日终本金与盘中差分;本方法的扁平余额模型不覆盖盘中差分口径)。
|
||||
/// </summary>
|
||||
/// <param name="rate">保证金利率(年化,如 0.03 = 3%)。</param>
|
||||
/// <param name="startDate">计息开始日。</param>
|
||||
/// <param name="endDate">计息结束日。</param>
|
||||
/// <param name="boundary">算头算尾规则。</param>
|
||||
/// <param name="annualDays">年化天数(365 或 360)。</param>
|
||||
public InterestResult AccrueInterest(decimal rate, System.DateTime startDate, System.DateTime endDate, AccrualBoundary boundary, int annualDays)
|
||||
=> SwapInterest.AccrueSimple(new AccrualContext(annualDays), Balance.Balance, rate, startDate, endDate, boundary);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace YLErp.Modules.SwapModule.Margin;
|
||||
|
||||
/// <summary>
|
||||
/// 保证金余额。现金、授信、担保等多种保证金形态的统一表达。
|
||||
///
|
||||
/// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。
|
||||
/// 余额随追加/释放/盈亏变动,利息由计息层(SwapDealService.CalcMarginInterest)按 EOD 昨日终本金 / 盘中差分口径计算。
|
||||
/// </summary>
|
||||
public readonly struct MarginBalance
|
||||
{
|
||||
/// <summary>保证金余额:现金余额 / 授信占用 / 担保品市值。</summary>
|
||||
public decimal Balance { get; }
|
||||
|
||||
public MarginBalance(decimal balance)
|
||||
=> Balance = balance;
|
||||
}
|
||||
@@ -7,14 +7,18 @@ namespace YLErp.Modules.SwapModule.Margin;
|
||||
/// <summary>
|
||||
/// 保证金计息模式(mode 5 初始预付金 / mode 6 追加预付金)的统一判断口径。
|
||||
///
|
||||
/// 现状(待收敛):同一集合 {初始预付金, 追加预付金} 在代码里复制了至少 6 次——
|
||||
/// ConsTrade.InterestMarginModels(框架级)
|
||||
/// SwapEodPositionService.marginTypes(实例字段)
|
||||
/// SwapEodPositionService.premiumModes(局部变量)
|
||||
/// SwapEventEmailService.marginTypes
|
||||
/// EodClientBalanceCalc.marginTypes
|
||||
/// ClientBalanceUtility.marginTypes
|
||||
/// 任何一处漏改(如新增保证金形态)都会导致口径分裂。本类收敛到单一来源。
|
||||
/// 依赖方向:本类位于 YLErpDAL 层,单一真源是框架层常量
|
||||
/// <see cref="ConsTrade.InterestMarginModels"/>(YLErp.DBModels)。Core 不能反向依赖 DAL,
|
||||
/// 故本类的集合直接由该框架常量派生(new HashSet/List),而非独立重写——
|
||||
/// 任何一处要新增保证金形态,只需改 ConsTrade.InterestMarginModels 即全局生效。
|
||||
///
|
||||
/// 收敛历史:早期同一集合 {初始预付金, 追加预付金} 在代码里被复制多次
|
||||
/// (ConsTrade.InterestMarginModels / SwapEodPositionService.marginTypes /
|
||||
/// SwapEodPositionService.premiumModes / SwapEventEmailService.marginTypes /
|
||||
/// EodClientBalanceCalc.marginTypes / ClientBalanceUtility.marginTypes)。
|
||||
/// 现余额/邮件/利息等入口已改用本类;SwapEodPositionService.premiumModes 局部变量
|
||||
/// 也已替换为 MarginModes.ForLinq。ConsTrade.InterestMarginModels 作为框架级常量保留
|
||||
/// (它是唯一真源,并非冗余)。
|
||||
///
|
||||
/// 注意:这里的"保证金 mode"是现有系统把保证金错误建模为计息腿的历史遗留。
|
||||
/// 按 Margin 限界上下文的设计方向,未来保证金不应用 InterestMode 标识,
|
||||
@@ -22,34 +26,24 @@ namespace YLErp.Modules.SwapModule.Margin;
|
||||
/// </summary>
|
||||
public static class MarginModes
|
||||
{
|
||||
/// <summary>所有属于保证金的 InterestMode(初始预付金 / 追加预付金)。</summary>
|
||||
public static readonly IReadOnlyCollection<int> All = new HashSet<int>
|
||||
{
|
||||
(int)InterestModeEnum.初始预付金,
|
||||
(int)InterestModeEnum.追加预付金,
|
||||
};
|
||||
/// <summary>所有属于保证金的 InterestMode(派生自 ConsTrade.InterestMarginModels)。</summary>
|
||||
public static readonly IReadOnlyCollection<int> All = new HashSet<int>(ConsTrade.InterestMarginModels);
|
||||
|
||||
/// <summary>
|
||||
/// List 形态,供 EF Core LINQ 表达式用(HashSet.Contains 无法翻译成 SQL)。
|
||||
/// 替代 ConsTrade.InterestMarginModels。
|
||||
/// 内容派生自框架常量 ConsTrade.InterestMarginModels(单一真源),本类仅做形态适配。
|
||||
/// </summary>
|
||||
public static readonly List<int> ForLinq = new()
|
||||
{
|
||||
(int)InterestModeEnum.初始预付金,
|
||||
(int)InterestModeEnum.追加预付金,
|
||||
};
|
||||
public static readonly List<int> ForLinq = new List<int>(ConsTrade.InterestMarginModels);
|
||||
|
||||
/// <summary>判断 mode 是否属于保证金(非 LINQ 场景用)。</summary>
|
||||
public static bool Contains(int interestMode) => All.Contains(interestMode);
|
||||
|
||||
/// <summary>固定值 + 保证金 mode 集合(固定值/初始预付金/追加预付金)。
|
||||
/// 用于 EOD 场景判断"计息基数取 InterestPrincipalFix 而非持仓名义本金"的腿。
|
||||
/// 替代 SwapEodPositionService 中 3 处内联 new List{固定值, 初始预付金, 追加预付金}。</summary>
|
||||
public static readonly IReadOnlyCollection<int> FixedAmountAndMargin = new HashSet<int>
|
||||
/// 保证金部分派生自 ConsTrade.InterestMarginModels,固定值额外并入。</summary>
|
||||
public static readonly IReadOnlyCollection<int> FixedAmountAndMargin = new HashSet<int>(ConsTrade.InterestMarginModels)
|
||||
{
|
||||
(int)InterestModeEnum.固定值,
|
||||
(int)InterestModeEnum.初始预付金,
|
||||
(int)InterestModeEnum.追加预付金,
|
||||
};
|
||||
|
||||
/// <summary>判断 mode 是否为固定值或保证金。</summary>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,6 +276,7 @@ namespace YLErp.Modules.SwapModule
|
||||
// DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径,
|
||||
// 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归)
|
||||
decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
|
||||
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;
|
||||
@@ -355,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);
|
||||
@@ -410,7 +410,9 @@ namespace YLErp.Modules.SwapModule
|
||||
floatEvent.PositionId = position.PositionId;
|
||||
// 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
|
||||
// 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
|
||||
floatEvent.DividendIn = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
|
||||
decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
|
||||
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;
|
||||
floatEvent.CloseFee = 0;
|
||||
@@ -458,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); // 上一日终的浮动端本金
|
||||
@@ -484,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;
|
||||
}
|
||||
|
||||
@@ -606,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,
|
||||
@@ -622,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,
|
||||
@@ -652,27 +657,30 @@ namespace YLErp.Modules.SwapModule
|
||||
// true 跳过 不计利息; false 正常利息
|
||||
bool swap = InitInterestDate(unwindDate, preDealDate, td, tdClose, out DateTime startDate, out DateTime endDate);
|
||||
|
||||
// 计算名义本金
|
||||
decimal closePrincipal;
|
||||
decimal posiPrincipal;
|
||||
decimal newClosePercent = closePrecent;
|
||||
var mode = (InterestModeEnum)position.InterestMode;
|
||||
// 获取利率(保证金/融资腿共用:SwapIntervalList 取当日适用固定利率 + 精度收口)
|
||||
decimal rate = Math.Round(GetFixedRate(position, unwindDate), InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 做精度调整 原数据有精度误差
|
||||
|
||||
// ── 边界隔离:保证金腿(5/6)在循环最外层路由,后续融资腿分支树不感知保证金概念 ──
|
||||
// 有意跳过 GetFloatRate:CalcMarginInterest 纯固定利率(FundingLegRate.Fixed)且 FloatRate 恒 0,
|
||||
// 浮动取价/回写对保证金无意义;即使脏数据填了 FloatRateUnderlyingCode 且缺价,也不应阻断保证金结算。
|
||||
if (MarginModes.Contains(position.InterestMode))
|
||||
{
|
||||
// 保证金腿: 计息基数 = InterestPrincipalFix(保证金余额)
|
||||
closePrincipal = position.InterestPrincipalFix * closePrecent;
|
||||
posiPrincipal = position.InterestPrincipalFix;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 融资腿(1/2/9): 走策略工厂
|
||||
var r = FundingLegStrategyFactory.Get(mode)
|
||||
.CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePrecent);
|
||||
closePrincipal = r.ClosePrincipal;
|
||||
posiPrincipal = r.PosiPrincipal;
|
||||
newClosePercent = r.ClosePercent;
|
||||
positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection);
|
||||
// 保证金腿: 计息基数 = InterestPrincipalFix(保证金余额),无融资腿差分公式与 orginPv 维度 hack
|
||||
interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate,
|
||||
position.InterestPrincipalFix * closePrecent, position.InterestPrincipalFix,
|
||||
closePrecent, annualDays, calcFirst, calcLast || newCalcLast, preEodPosition, eventType, add, settment, swap));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算名义本金(以下仅融资腿 1/2/9:走策略工厂)
|
||||
var mode = (InterestModeEnum)position.InterestMode;
|
||||
var r = FundingLegStrategyFactory.Get(mode)
|
||||
.CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePrecent);
|
||||
decimal closePrincipal = r.ClosePrincipal;
|
||||
decimal posiPrincipal = r.PosiPrincipal;
|
||||
decimal newClosePercent = r.ClosePercent;
|
||||
|
||||
// 根因位置:SwapEodPositionService.SaveAutoEodWithCloseInterestPosition 在平仓后收盘时传入
|
||||
// “收盘后剩余本金 + closePercent=1”,与盘中“平仓前本金 + 实际关闭比例”不是同一语义。
|
||||
// GetInterests 同时被盘中试算和 EOD 平仓后收盘调用:后者传入的
|
||||
@@ -681,29 +689,17 @@ namespace YLErp.Modules.SwapModule
|
||||
// 模式2(合约名义本金规模)的本次结息本金必须始终是实际平仓额,因此无条件覆盖,
|
||||
// 否则会错误地用剩余 70 结算本次平掉的 30。模式9(标的期初全价)的部分平仓
|
||||
// 仍保留既有的剩余/复利动态本金承接逻辑;仅最终全平时 posi=0,才覆盖以避免结息本金为 0。
|
||||
if ((InterestModeEnum)position.InterestMode == InterestModeEnum.合约名义本金规模
|
||||
|| ((InterestModeEnum)position.InterestMode == InterestModeEnum.标的期初全价
|
||||
if (mode == InterestModeEnum.合约名义本金规模
|
||||
|| (mode == InterestModeEnum.标的期初全价
|
||||
&& posiNotionalValue == 0m))
|
||||
{
|
||||
closePrincipal = closePosiNotionalValue;
|
||||
}
|
||||
if (MarginModes.Contains(position.InterestMode))
|
||||
{
|
||||
positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection);
|
||||
}
|
||||
|
||||
// 获取利率
|
||||
decimal rate = Math.Round(GetFixedRate(position, unwindDate), InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 做精度调整 原数据有精度误差
|
||||
decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone);
|
||||
|
||||
// 根据场景计算利息
|
||||
if (MarginModes.Contains(position.InterestMode))
|
||||
{
|
||||
// 保证金腿(5/6):专属计息,notional 直接取保证金余额,无融资腿差分公式与 orginPv 维度 hack
|
||||
interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate, closePrincipal, posiPrincipal,
|
||||
newClosePercent, annualDays, calcFirst, calcLast||newCalcLast, preEodPosition, eventType, add, settment, swap));
|
||||
}
|
||||
else if (settment)
|
||||
if (settment)
|
||||
{
|
||||
// 收盘归档场景,使用 CalcEodInterest
|
||||
interests.Add(CalcEodInterest(td, valueDate, positionClone, rate, floatRate, closePrincipal, posiPrincipal, annualDays, calcFirst, calcLast, preEodPosition, eventType, add));
|
||||
@@ -717,7 +713,7 @@ namespace YLErp.Modules.SwapModule
|
||||
: 0m;
|
||||
interests.Add(CalcUnwindInterest(td, valueDate, endDate, positionClone, rate, floatRate, posiPrincipal,
|
||||
closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, swap, orginPv, calcFirst,
|
||||
calcLast||newCalcLast, consumedInterest));
|
||||
calcLast || newCalcLast, consumedInterest));
|
||||
}
|
||||
}
|
||||
//当日有平仓或互换记录时,避免重复结算
|
||||
@@ -807,7 +803,9 @@ namespace YLErp.Modules.SwapModule
|
||||
protected virtual decimal GetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
|
||||
{
|
||||
var preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate);
|
||||
return preEod == null ? 0m : preEod.PosiDividendSum;
|
||||
var sum = preEod == null ? 0m : preEod.PosiDividendSum;
|
||||
Logger.Info($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}");
|
||||
return sum;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -821,6 +819,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.Info($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}");
|
||||
return QueryPreEodPosition(tradeId, positionId, preEodDate);
|
||||
}
|
||||
|
||||
@@ -953,6 +952,21 @@ namespace YLErp.Modules.SwapModule
|
||||
/// 本方法内部按保证金维度计算(PreviousBalance),消除原 InitSwapDealInterest 的外部维度 hack
|
||||
/// (融资腿 orginPv=浮动端名义本金)。保留累计语义(priorAccrued + 增量),满足下游字段契约。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 前提(由前端保证金表单 + SwapTradeService 构造保证):
|
||||
/// 1. <b>InterestType=单利</b>。本方法恒走 SimpleInterestAccrual 单利,不查 InterestType;
|
||||
/// 若库内 InterestMode=5/6 且 InterestType=复利(脏数据),会与旧 CalcEodInterest 复利分支不一致。
|
||||
/// 2. <b>rate 由 GetFixedRate 提供</b>(SwapDealService.cs:866)——从 SwapIntervalList 取 <c>Date ≤ unwindDate</c> 最近段的 Rate,
|
||||
/// 空表/单段时返回 InterestRateDefault。SwapIntervalList 是"互换观察日排期"(阶梯利率表 + 结息日历,非 FR007 浮动——
|
||||
/// 浮动由 FloatRateUnderlyingCode + interest_rest_days 独立驱动);保证金前端亦开放"设置观察日"分段录入。
|
||||
/// 盘中用该 rate 覆盖全程,与旧 CalcDailySimpleInterest 完全一致(BuildSegmentRates 的 spread 同样是 GetFixedRate 单一值全程,
|
||||
/// 不按 SwapIntervalList 切段)——SwapIntervalList 阶梯利率在盘中半路变更的精细处理是既有未覆盖口径,非本次引入;
|
||||
/// EOD 路径因每日重取 GetFixedRate(valueDate) 故能正确反映阶梯。
|
||||
/// 契约与副作用:
|
||||
/// 3. <b>position.InterestDirection 须已由调用方翻转</b>(GetInterests:742 FlipDirection);本方法不翻转。
|
||||
/// 4. <b>preEod 在 id==0 时被就地修改</b>(设 TdInterestPrincipal/PosiNotionalValue/FloatRate),与旧 CalcEodInterest 一致。
|
||||
/// 定位:SwapCalcTrace 落盘 AccrueEod/AccrualPeriod 的 notional/days/rate/accrued;盘中 accrualBasis 可从 trace 的 notional 反推。
|
||||
/// </remarks>
|
||||
/// <param name="settment">true=收盘归档(EOD),false=盘中平仓/互换。</param>
|
||||
/// <param name="swap">互换事件(仅盘中生效,true 时利息归零,同 InitSwapDealInterest)。</param>
|
||||
public swap_flow_event CalcMarginInterest(
|
||||
@@ -1076,7 +1090,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>
|
||||
@@ -1131,7 +1145,6 @@ namespace YLErp.Modules.SwapModule
|
||||
int annualDays,
|
||||
int eventType,
|
||||
eod_swap_position preEodPosition,
|
||||
bool needPrice,
|
||||
decimal orginPv,
|
||||
bool calcFirst,
|
||||
bool calcLast,
|
||||
@@ -1206,6 +1219,12 @@ namespace YLErp.Modules.SwapModule
|
||||
consumedInterest, resetCarryInterest);
|
||||
if (preEodPosition.id != 0 && closePrecent == 1m)
|
||||
{
|
||||
// 【全平专属分支触发标记】(快速定位):设计意图=真全平(尾差一次带走)与观察日恒1全额结息。
|
||||
// ⚠️ 契约修复暂缓期间,普通部分平仓经 EOD 恒1惯例【仍会进入本分支】(重算结果已被裁决
|
||||
// 证为不落库/不动钱/不进资金,零生产后果);修复落地后部分平仓不再进入——本行日志届时
|
||||
// 兼作落地验证哨兵(部分平仓出现在此=修复未生效/被回退)。
|
||||
Logger.Info($"[利息-全平专属分支] tradeId={td.id} posiId={position.id} valueDate={valueDate:yyyy-MM-dd} " +
|
||||
$"closePrecent={closePrecent} preEod.InterestIncomeSum={preEodPosition.InterestIncomeSum}");
|
||||
// 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。
|
||||
// InterestAmount 是本次最终应结金额;TdInterestAmount 是不按关闭比例缩放的参考累计值。
|
||||
// 二者在全平时都以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走。
|
||||
@@ -1435,7 +1454,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;
|
||||
@@ -1549,7 +1568,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);
|
||||
@@ -1675,153 +1694,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,42 @@ 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 转发,保持既有测试替身对该虚接缝的拦截不变。
|
||||
/// (契约修复§六暂缓中:落地时 autoSwap=false 分支改 Intraday 形状,见裁决文档与调用点注释。)
|
||||
/// </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 +135,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 +371,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 +443,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 +454,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 +502,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 +510,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 +523,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,7 +617,7 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
autoInterests.ForEach(x => x.PayDate = settleDate);
|
||||
|
||||
var premiumModes = new List<int>() { (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 };
|
||||
var premiumModes = MarginModes.ForLinq;
|
||||
var premiumInterests = autoInterests.Where(x => premiumModes.Contains(x.InterestMode)).ToList();
|
||||
var interestLegs = autoInterests.Where(x => !premiumModes.Contains(x.InterestMode)).ToList();
|
||||
|
||||
@@ -1079,7 +1094,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 +1133,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)
|
||||
@@ -1138,12 +1153,14 @@ namespace YLErp.Modules.SwapModule
|
||||
positions.Add(position);
|
||||
List<eod_swap_position> preEodPositions = new List<eod_swap_position>();
|
||||
preEodPositions.Add(eodPayPosition);
|
||||
// orginPv 在此仅对固定值腿(mode 1)生效;保证金腿(5/6)的 orginPv 虽在此赋值,
|
||||
// 但 GetInterests 保证金分支已走 CalcMarginInterest(内部自算 orginPv=PreviousBalance),忽略此处传入值。
|
||||
var interestModes = MarginModes.FixedAmountAndMargin;
|
||||
if (interestModes.Contains(position.InterestMode))
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -1228,7 +1245,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;
|
||||
@@ -1239,8 +1256,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);
|
||||
// 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。
|
||||
@@ -1276,6 +1293,8 @@ namespace YLErp.Modules.SwapModule
|
||||
newEodPayPosition = eodPayPosition.Clone();
|
||||
newEodPayPosition.id = 0;
|
||||
}
|
||||
// orginPv 在此仅对固定值腿(mode 1)生效;保证金腿(5/6)的 orginPv 虽在此赋值,
|
||||
// 但 GetInterests 保证金分支已走 CalcMarginInterest(内部自算 orginPv=PreviousBalance),忽略此处传入值。
|
||||
var interestModes = MarginModes.FixedAmountAndMargin;
|
||||
if (interestModes.Contains(position.InterestMode))
|
||||
{
|
||||
@@ -1302,9 +1321,21 @@ 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本金修正(见其"根因位置"注释,勿删)。
|
||||
// ⚠️ 契约修复暂缓中(2026-08-16 撤回):按裁决文档§六,autoSwap=false 本应改传 Intraday 形状
|
||||
// (平仓前剩余+真实比例),但该变更影响快照种子(TdInterestPrincipal等),黄金回放验收门未过前不落地;
|
||||
// 落地时见 项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md §六 与已就绪的回归网
|
||||
//(GetInterestsEntrySemanticsTest.复利_mode2_部分平仓_双入口契约口径一致)。
|
||||
// 口径选择常驻记录(快速定位第一入口):出问题先看这行确认当日本次事件的金额输入,再顺着
|
||||
// SwapCalcTrace 分段过程日志追计算;autoSwap=观察日结现路径。
|
||||
Log.Info($"[EOD平仓后收盘结息] tradeId={td.id} valueDate={valueDate:yyyy-MM-dd} autoSwap={autoSwap} " +
|
||||
$"口径=恒1全额结息(历史行为,契约修复暂缓) " +
|
||||
$"oriPosi(平仓前)={oriPosiNotionalValue} posi(剩余)={posiNotionalValue} close(平掉)={closeNational}");
|
||||
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:本次事件发生前理论应结的高精度利息。
|
||||
// manualSettledInterestAmount:swap_flow_event 实际落库的手工结息,金额已按分处理。
|
||||
@@ -1484,11 +1515,13 @@ 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;
|
||||
var tradeExtend = td.trade_extend.ExtendObj;
|
||||
// orginPv 在此仅对固定值腿(mode 1)生效;保证金腿(5/6)的 orginPv 虽在此赋值,
|
||||
// 但 GetInterests 保证金分支已走 CalcMarginInterest(内部自算 orginPv=PreviousBalance),忽略此处传入值。
|
||||
var interestModes = MarginModes.FixedAmountAndMargin;
|
||||
if (eodPayPosition == null)
|
||||
{
|
||||
@@ -1506,7 +1539,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;
|
||||
@@ -1529,7 +1562,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)
|
||||
{
|
||||
@@ -1554,7 +1587,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;
|
||||
@@ -1740,6 +1773,11 @@ namespace YLErp.Modules.SwapModule
|
||||
curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax);
|
||||
}
|
||||
curretEod.PosiDividendSum = eod.PosiQuantity > 0 ? Math.Round(eod.PosiDividendSum + curretEod.TdPosiDividend, 2) : 0;
|
||||
// 分红递推过程常驻记录(快速定位):窗口/数量/税率/当日新计/累计前后值——
|
||||
// 配合 BondPaymentService 的[分红-登记日口径]窗口命中日志,构成"命中哪些登记日→算出多少→账滚到多少"全链
|
||||
Log.Info($"[分红-EOD计提Copy] tradeId={td.id} posiId={eod.PositionId} valueDate={valueDate:yyyy-MM-dd} " +
|
||||
$"window=({eod.ValueDate:yyyy-MM-dd},{valueDate:yyyy-MM-dd}] qty={curretEod.PosiQuantity} tax={tax} " +
|
||||
$"TdPosiDividend={curretEod.TdPosiDividend} PosiDividendSum {eod.PosiDividendSum}->{curretEod.PosiDividendSum}");
|
||||
curretEod.PosiQuantity = eod.PosiQuantity;
|
||||
if (curretEod.PosiStatus == 1)
|
||||
{
|
||||
@@ -1812,11 +1850,11 @@ namespace YLErp.Modules.SwapModule
|
||||
int shortRatio = DirectionRatio.LongShort(eod.PositionType);
|
||||
int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection);
|
||||
var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp);
|
||||
var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents);
|
||||
var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice;
|
||||
decimal totalPayment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio);
|
||||
// 历史遗留死代码已删(2026-08-16,论证+边界测试见 DividendEodNoDoubleCountTest.脏数据边界_*):
|
||||
// todayConsumedDividend / originNotional / totalPayment / totalInterest 自 0910969e(2026-07-02
|
||||
// 改递推式) 起计算结果从未被消费,仅残留一次全历史 CalcBondPayment 只读查询+日志副作用,
|
||||
// 且构成脏数据(OriginalStockEqvNotional=null/PosiNetPrice=0)下的 EOD 崩溃点。回退=git revert 本提交。
|
||||
decimal tax = um.ValueAddedTax ?? 0;
|
||||
decimal totalInterest = DividendCalc.AfterTaxRaw(totalPayment, tax);
|
||||
SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, swapPosition);
|
||||
curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp);
|
||||
curretEod.UnderlyingPrice = price;
|
||||
@@ -1849,6 +1887,11 @@ namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
curretEod.PosiDividendSum = 0;
|
||||
}
|
||||
// 分红递推过程常驻记录(快速定位):当日事件路径含实现扣减(前日+新计-当日实现)
|
||||
Log.Info($"[分红-EOD计提Update] tradeId={td.id} posiId={eod.PositionId} valueDate={valueDate:yyyy-MM-dd} " +
|
||||
$"window=({eod.ValueDate:yyyy-MM-dd},{valueDate:yyyy-MM-dd}] qty={curretEod.PosiQuantity} tax={tax} " +
|
||||
$"TdPosiDividend={curretEod.TdPosiDividend} TdCloseDividend={curretEod.TdCloseDividend} " +
|
||||
$"PosiDividendSum {eod.PosiDividendSum}->{curretEod.PosiDividendSum}");
|
||||
EodPnlCalculator.SetFloatingRealizedPnl(curretEod);
|
||||
curretEod.SwapPositionValue -= curretEod.TdCloseDividend;
|
||||
|
||||
@@ -1871,20 +1914,6 @@ namespace YLErp.Modules.SwapModule
|
||||
return curretEod;
|
||||
}
|
||||
|
||||
private decimal CalcConsumedDividend(eod_swap_position curretEod, List<swap_flow_event> events)
|
||||
{
|
||||
decimal consumedDividend = 0;
|
||||
|
||||
List<int> swapEventTypes = new List<int>() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 };
|
||||
//这里要剔除掉平仓产生的分红
|
||||
consumedDividend = events
|
||||
.Where(x => x.SwapTradeId == curretEod.SwapTradeId
|
||||
&& swapEventTypes.Contains(x.EventType)
|
||||
&& x.DataState == (int)SwapFlowDateStateEnum.完成)
|
||||
.Sum(s => s.DividendIn);
|
||||
return consumedDividend;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据开平仓事件算价格及后付费用
|
||||
/// </summary>
|
||||
@@ -2108,7 +2137,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();//持仓腿
|
||||
// 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模,
|
||||
@@ -2170,7 +2199,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);
|
||||
@@ -2221,7 +2250,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;
|
||||
@@ -2462,7 +2491,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);
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,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();
|
||||
@@ -1550,7 +1550,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);
|
||||
|
||||
//展期
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# 融资腿计息测试矩阵
|
||||
|
||||
> 配套 [ARCHITECTURE.md](ARCHITECTURE.md)。目的:把"覆盖"从用例计数变成格子坐标运算——
|
||||
> 每个用例/fix 显式登记命中坐标,空洞一眼可见。2026-08 建立,依据近 6 周 fix 热力图回溯登记。
|
||||
|
||||
## 0. 范围声明
|
||||
|
||||
- 本矩阵只覆盖**融资腿 FundingLeg(mode 1 固定值 / 2 合约名义本金规模 / 9 标的期初全价)**。
|
||||
- **mode 5/6(保证金/预付金)不属于本矩阵**(历史遗留:被错误建模为计息腿,概念上与融资腿无关,
|
||||
见 `Margin/MarginModes.cs` 注释)。保证金有独立的余额模型与专属黄金回放(96 库 60 条,0 差异)作为护栏。
|
||||
禁止向本矩阵添加 5/6 格子。
|
||||
- 主力生产组合(确认书规定)**必须全格覆盖**,见 §1。
|
||||
|
||||
## 1. 主力族(第一优先级,必须全盖)
|
||||
|
||||
```
|
||||
InterestMode = 9 标的期初全价 × FR007 浮动(±点差) × InterestType = 复利 × InterestCalcMode = "10"(算头不算尾)
|
||||
```
|
||||
|
||||
代码锚点:`SwapDealService.GetInterests`(calcFirst=true / calcLast=false,SwapDealService.cs:646)。
|
||||
近 6 周 ≥9 个 fix 落在本族内——生产用得最多 = 人工测试打得最狠,fix 清单就是炸点热力图。
|
||||
|
||||
## 2. 维度定义
|
||||
|
||||
| 维度 | 取值 | 代码/数据锚点 |
|
||||
|---|---|---|
|
||||
| A 生命周期终点 | 持有至到期结算 / 盘中全平 / 盘中部分后持有 / 部分N次后全平 / EOD自动平仓(部分·全) / 互换(续作) | `SwapEventTypeEnum`;到期:`SwapEodPositionService` 到期结算路径 |
|
||||
| B 重置几何 | 第1重置期内平仓 / 跨≥1完整重置期 / 第3重置期内 / **平仓日=重置日** / 重置日±1天 / **末段非整周期**(di<7) | 重置频率=7天(已定格,§8);契约重置期定义见 §8a |
|
||||
| C 比例与次数 | 单次部分(30%) / 同日两次 / 跨重置期多次 / 全平(剩余=0) | `closePrecent`;双语义转换 `ClosePercentMath` |
|
||||
| D 交收 | T+0 / T+1 | `valueDate` vs `unwindDate` |
|
||||
| E FR007 形态 | 每重置日有价 / 加点(+0.25%) / 减点(-2.10%) / **取价日=重置日上一营业日**(契约规定) / 缺价分支 | `TryGetFloatRate` / `ResolveFloatRate`;66a97e03 对应此维 |
|
||||
| F 入口 | 见 §3 | |
|
||||
| G 断言投影 | ①最终利息金额 ②`TdInterestPrincipal` 逐日携带链 ③`InterestIncomeSum`+flow_event 全字段 ④方向/符号(报表口径) | 每格必须断言全部 4 个投影 |
|
||||
|
||||
## 3. 入口枚举(F 维)
|
||||
|
||||
| 入口 | 代码路径 |
|
||||
|---|---|
|
||||
| 盘中平仓/互换结息试算 | `SwapDealService.GetInterestsForUnwind`(SwapDealService.cs:617,settment:false → `CalcUnwindInterest`) |
|
||||
| EOD 正常收盘 | `GetInterests(settment:true)` → `CalcEodInterest` |
|
||||
| EOD 平仓后收盘 | `SwapEodPositionService.SaveAutoEodWithCloseInterestPosition`(:1246)→ `CalcSwapInterests`(:1579) |
|
||||
| EOD 自动互换 | `CalcSwapInterests`(:1161,EventType=自动互换) |
|
||||
|
||||
已知风险:`GetInterests` 参数语义随入口漂移(EOD 平仓后收盘传"剩余本金+percent=1",
|
||||
盘中传"平仓前本金+实际比例"),`GetInterestsEntrySemanticsTest` 曾实测双入口复利口径分歧(b01b485e)。
|
||||
|
||||
## 4. fix 热力图(本族,近 6 周)
|
||||
|
||||
| fix | 落点 | 格子坐标 | 自带测试 |
|
||||
|---|---|---|---|
|
||||
| 66a97e03 重置日=平仓日 calcLast 不跳过 FR007 取价 | SwapDealService:1249/1295 | B=重置日=平仓日 × E=取价边界 | GLMS20260805FR007UnderlyingIdDiagnoseTest(581行) |
|
||||
| 48e84479 重置日部分平仓本金 | SwapEodPositionService:1422 | B=重置日=平仓日 × C=部分 | SwapCloseConversationCasesRegressionTest(358行) |
|
||||
| d3afa6d2 T+1 部分平仓复利本金(算头不算尾快速路径) | SwapDealService:1243 | D=T+1 × C=部分 × A=部分后持有 | DealInterestsScenarioTest +36行 |
|
||||
| aa5a5ed8 算头不算尾期初复利部分平仓 | SwapEodPositionService:1418-1573 | **本族正中心** | DealInterestsScenarioTest |
|
||||
| a0be0eb0 复利平仓已结利息扣除 | SwapDealService:1294 | 已结利息差分(CalcDailyCompoundInterest 回放) | ConsumedInterestScenarioTest |
|
||||
| 5539bd9c 复利部分平仓后 EOD 本金 | SwapEodPositionService:782/1381 | G=携带链投影 | DealInterestsScenarioTest +34行 |
|
||||
| feffc196 Bug A/B/C 浮动部分/全平尾差 | SwapDealService:1214/1292 | C=部分/全平 × E=浮动 | **SwapInterestScenario3And4FloatingTest(24用例,Excel oracle)** |
|
||||
| 2035e1df EOD 平仓后收盘结息本金语义 | SwapDealService:1293 / EodService:860,1360 | **F=EOD平仓后收盘 × C** | **无测试** |
|
||||
| b01b485e 双入口口径分歧(实测发现) | — | F=入口 × 全族 | GetInterestsEntrySemanticsTest(字符化,非 oracle) |
|
||||
|
||||
**规律:fix 全部落在 `CalcUnwindInterest`(SwapDealService 1240-1300)和
|
||||
`SaveAutoEodWithCloseInterestPosition` 族(SwapEodPositionService 1380-1580)两个带。**
|
||||
|
||||
## 5. 现有用例登记
|
||||
|
||||
| 测试文件 | 覆盖格子 | oracle 类型 |
|
||||
|---|---|---|
|
||||
| SwapInterestScenario3And4FloatingTest(24) | 本族 A=全平/部分30%→全平 × B=第3重置期内 × D=T+0/T+1 × E=加减点 × F=EOD平仓后收盘 × G=仅金额投影 | Excel 手算(业务源) |
|
||||
| SwapInterestScenario1And2Test(32) | A=收盘平仓 × B=第1重置期内 × E=固定/浮动 | Excel 手算 |
|
||||
| DealInterestsScenarioTest(24 方法,工单逐个追加) | 部分平仓×复利族各点,含"10"×3 行 | 工单期望值 |
|
||||
| ConsumedInterestScenarioTest | 已结利息差分族 | 工单期望值 |
|
||||
| SwapUnwindSameDayDoublePartialTest | C=同日两次 | **字符化(非独立 oracle)** |
|
||||
| GetInterestsEntrySemanticsTest | F=双入口一致性 | **字符化** |
|
||||
| **ContractReferenceOracleTest(Accrual/,7)** | mode9/mode2 × 复利 × "10" × T+0 × 部分30% × B=跨12整期+末段(90/89天) × E=恒定利率(取价日免疫) | **契约公式参考实现(§7.4 第一级)**——引擎盘中重放已逐分对齐 oracle |
|
||||
| GetInterestsUnitTest_T0/T1(89) | mode 1 固定值 T+0/T+1 族(非本族) | 单点断言 |
|
||||
| GLMS20260805FR007UnderlyingIdDiagnoseTest | B=重置日=平仓日 × E | 诊断+断言 |
|
||||
|
||||
## 6. 空洞清单(热力图 ∩ 未覆盖,按优先级)
|
||||
|
||||
1. **F=EOD平仓后收盘 × C=部分平仓 × 本族** —— 2035e1df 无测试落地即合入,该入口×比例格子全裸。
|
||||
2. **G=携带链投影(全族)** —— 现有断言几乎全是最终金额;`TdInterestPrincipal` 逐日携带链无一处断言
|
||||
(7528670e 在单利上炸过同款,复利同投影裸奔)。
|
||||
3. **B=重置日±1天 / 跨重置期多次部分平仓** —— 热力图边缘未扫。
|
||||
4. **A=到期结算 × 本族** —— db46e48e 修过到期结算(28 断言),但非本族参数。
|
||||
5. **A=互换(续作) × 本族** —— 7411b9d2/421662a0 炸过续作初始化,本族续作无 oracle。
|
||||
6. **C=同日两次** —— 只有字符化测试,无独立 oracle(字符化=锁定现状,不证正确)。
|
||||
7. **E=缺价/取价日边界** —— 66a97e03 只修了取价跳过,缺价分支行为未钉。
|
||||
|
||||
## 7. 补盖执行顺序
|
||||
|
||||
1. 先铺**守恒不变量**(免 oracle,全格便宜):部分平仓后"期初=平掉+剩余"逐日守恒;全平后持仓=0;
|
||||
复利重置日动态本金=前段本金+利息;多次平仓 closePercent 连乘=累计比例。
|
||||
2. 空洞 1/2 优先:按 §2-G 四投影补 EOD平仓后收盘 × 部分 用例,oracle 用确认书公式 Excel 模板。
|
||||
3. 空洞 6 补独立 oracle(确认书公式),替换字符化地位(保留字符化作回归钉)。
|
||||
4. 每格期望值来源分级(已升级,见 §8a):**契约公式独立参考实现** > 生产已对账数字 > 业务签认 Excel > 新旧影子对比;**禁止当前代码输出充当 oracle**。
|
||||
5. 契约参考实现(§8a 公式)**已落地**(`UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs`,
|
||||
独立于生产引擎,禁止引用计息类防同源),引擎对照首批 3 例全绿(mode9/mode2 × "10" × 部分30%,
|
||||
`ContractReferenceOracleTest`)。后续补格直接复用:期望值 = `ClosedInterest(平掉额, ReferenceRateAbsolute(...))`。
|
||||
待办:变利率引擎侧对照(取价日 E 维)、确认书生成器参数同源断言(`swap_position`)。
|
||||
|
||||
## 8. 生产参数(已确认,2026-08)
|
||||
|
||||
- **重置频率 = 7 天**(确认书:"重置频率每【周】";完整重置期 di=7 天)
|
||||
- **年化基数 = 365**(确认书:"计息基准 A/365",固定利率公式同除 365)
|
||||
- 生产只有这一种组合,无 360/其他重置频率。现有测试参数 `ResetPeriod=7 / AnnualDays=365` **即为生产主力参数,格子按此定格**。
|
||||
|
||||
## 8a. 契约 oracle(确认书公式原文)
|
||||
|
||||
模板:`Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/*.docx`(看多/看空 × 现券/债券ETF 共 4 份,计息条款一致);
|
||||
变量替换:`Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs`
|
||||
(`重置频率=interest_rest_days天`、`利差=InterestRateDefault×10000bp`,与计息引擎同源 `swap_position` 字段)。
|
||||
|
||||
**浮动利率复利公式(本族契约正文)**:
|
||||
|
||||
```
|
||||
参考利率(绝对) = ∏[i=1..k] ( 1 + (FR007i + 利差) × di / 365 ) − 1
|
||||
```
|
||||
|
||||
- k = 计息期包含的重置期个数;di = 第 i 个基准利率适用的日历日数,**完整重置期 di=7,末段不足 7 按实际日历日**(测试必须盖非整周期:如持有 17 天 = 2×7+3)
|
||||
- **利率确定日 = 每个重置期首日(重置日)的上一个营业日**,取该日 FR007;营业日准则=上一营业日(→ E 维度取值锚点,与 66a97e03 修复直接对应)
|
||||
- FR007 取中国货币网每日公布值
|
||||
|
||||
**计息期定义(= 算头不算尾的契约原文)**:自起始日(含)至到期日(不含)的自然日天数。
|
||||
⚠ 债券ETF 模板变体:计息期自**期初观察日**(含)至**期末观察日**(不含)——观察日→代码日期字段的映射需单独核实,是一个潜在口径分叉点。
|
||||
|
||||
**重置期定义**:每个重置期自上一重置日(含)至下一重置日(不含);首个重置期始于计息期首日;最后一个重置期的最后一日为计息期最后一日(末段收口)。重置日从计息期首日按重置频率依次推算。
|
||||
|
||||
**固定利率公式**:参考利率(绝对) = 固定利率 × 计息期 / 365。
|
||||
**期初预付金利息**:支付日(含)至到期日(**含**)×利率/计息基准——注意预付金契约上是"含尾"的,与利率腿"不含尾"相反。
|
||||
|
||||
**oracle 使用方式(升级 §7)**:最强形式是**按契约公式写独立参考实现**(约 20 行:重置日推算 + 分段取价 + ∏ 公式 + 末段收口),作为测试 oracle 与生产引擎对照,容差 0.01。它比逐格 Excel 手算更便宜且零同源风险;Excel 模板退化为抽样校验参考实现本身。
|
||||
|
||||
## 9. 合入规则(硬约束)
|
||||
|
||||
1. 计息类 fix:**先失败测试,后修代码**;测试须登记本矩阵坐标。
|
||||
2. 修一格必须**扫同矩阵行兄弟格子**(同 fix 家族的邻格)。
|
||||
3. 任何触碰 `GetInterests`/`CalcUnwindInterest`/`SaveAutoEodWithCloseInterestPosition` 的 PR:
|
||||
跑 `DealInterestsGoldenReplayTest` 全量 + 保证金黄金回放(防共享管线殃及)。
|
||||
4. 登记 fix 时发现同格已有用例而 bug 仍发生 → 先修断言投影,再修代码。
|
||||
5. **oracle 用例与裁决材料一律取 §8 生产参数**(7 天重置 / A365 / 真实点差 ±0.25%·−2.10% /
|
||||
千万级名义本金,如 5000 万)。玩具参数(千元级/重置 3 天/点差 1%)仅限字符化钉子测试——
|
||||
其用途是锁行为防漂移,不承担"证明数字正确"职责;用玩具数字做裁决依据会掩盖金额量级
|
||||
(0.03 vs 0.06 看着"不大",同参数放大到生产即 7.6 万 vs 25 万/笔)。
|
||||
@@ -7,6 +7,7 @@ using System.Linq.Expressions;
|
||||
using YLErp.BLL.Eod;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.Helpers;
|
||||
using YLErp.Model;
|
||||
using YLErp.Model.Enum;
|
||||
using YLErp.Modules.TradeModule;
|
||||
|
||||
@@ -28,6 +29,17 @@ namespace YLErp.Modules.SystemModule
|
||||
{
|
||||
var clientQuery = DataCacheProvider.GetClientDataSource().AsQueryable();
|
||||
|
||||
if (type == "ClientBlackProcess")
|
||||
{
|
||||
var clientdb = DbContextFactory.GetClientDbContext(OptUser);
|
||||
if (clientdb.client_black.Any(x => x.State == client_black.新增审批中 || x.State == client_black.删除审批中))
|
||||
{
|
||||
throw new ServiceException(data == null || data.Count == 0
|
||||
? "有黑名单在审批中,不能删除审批流程!"
|
||||
: "有黑名单在审批中,不能修改审批流程!");
|
||||
}
|
||||
}
|
||||
|
||||
var delList = DbContext.approvalprocess.Where(s => s.processType == type).ToArray();
|
||||
|
||||
DbContext.approvalprocess.RemoveRange(delList);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -130,8 +130,11 @@
|
||||
<FunctionSub Name="客户修改" Type="Operate" Note="是否有权限修改客户信息"></FunctionSub>
|
||||
<FunctionSub Name="客户审批" Title="客户审批"></FunctionSub>
|
||||
<FunctionSub Name="黑名单客户" Title="黑名单客户"></FunctionSub>
|
||||
<FunctionSub Name="黑名单审批" Title="黑名单审批"></FunctionSub>
|
||||
<FunctionSub Name="审批中客户信息编辑" Title="审批中客户信息编辑" Type="Operate" Note="是否可以修改审批中的客户信息"></FunctionSub>
|
||||
<FunctionSub Name="黑名单客户管理" Type="Operate" Note="是否有权限进行客户黑名单操作" ></FunctionSub>
|
||||
<FunctionSub Name="黑名单客户提交审批" Type="Operate" Note="是否有权限提交黑名单新增审批" ></FunctionSub>
|
||||
<FunctionSub Name="黑名单客户撤回提交审批" Type="Operate" Note="是否有权限撤回黑名单审批" ></FunctionSub>
|
||||
<FunctionSub Name="客户销户" Type="Operate" Note="是否有权限进行客户销户操作" ></FunctionSub>
|
||||
<FunctionSub Name="客户休眠" Type="Operate" Note="是否有权限进行客户休眠操作" ></FunctionSub>
|
||||
<FunctionSub Name="客户等级管理" Type="Operate" Note="是否有权限进行客户等级操作" ></FunctionSub>
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
{Name:"客户列表",Rights:["客户管理-客户查看"],Url:"client/ClientList"},
|
||||
{Name:"客户审批",Rights:["客户管理-客户审批"],Url:"clientApproval/openingclientList"},
|
||||
{Name:"黑名单客户",Rights:["客户管理-黑名单客户"],Url:"clientblack/clientblacklist"},
|
||||
{Name:"黑名单审批",Rights:["客户管理-黑名单审批"],Url:"clientblack/clientblackApproval"},
|
||||
{Name:"授信管理",Rights:["客户管理-授信管理"],Url:"credit/creditList"},
|
||||
{Name:"资信评级",Rights:["客户管理-资信评级"],Url:"client_rating/List"},
|
||||
{Name:"机构账号设置",Rights:["客户管理-机构账号设置"],Url:"v3/client/account"}
|
||||
@@ -111,4 +112,4 @@
|
||||
{Name:"做市账户",Rights:["系统管理-做市账户"],Url:"TrsAccountManage/Index"}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -251,6 +251,12 @@ namespace YLErp.Web
|
||||
/// </summary>
|
||||
public bool 黑名单客户管理 => _user.HasRight("客户管理-黑名单客户管理");
|
||||
|
||||
public bool 黑名单审批 => _user.HasRight("客户管理-黑名单审批");
|
||||
|
||||
public bool 黑名单客户提交审批 => _user.HasRight("客户管理-黑名单客户提交审批");
|
||||
|
||||
public bool 黑名单客户撤回提交审批 => _user.HasRight("客户管理-黑名单客户撤回提交审批");
|
||||
|
||||
/// <summary>
|
||||
/// 客户管理-黑名单客户
|
||||
/// </summary>
|
||||
|
||||
@@ -93,7 +93,8 @@ namespace YLErp.Web.Controllers
|
||||
var creditProcess = list.Where(s => s.processType == "CreditProcess").OrderBy(s => s.order).ToList();
|
||||
var outCashProcess = list.Where(s => s.processType == "OutCashProcess").OrderBy(s => s.order).ToList();
|
||||
var clientProcess = list.Where(s => s.processType == "ClientProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList();
|
||||
return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CloseProcess = closeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess });
|
||||
var clientBlackProcess = list.Where(s => s.processType == "ClientBlackProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList();
|
||||
return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CloseProcess = closeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess, ClientBlackProcess = clientBlackProcess });
|
||||
|
||||
}
|
||||
|
||||
@@ -232,4 +233,4 @@ namespace YLErp.Web.Controllers
|
||||
return Json(sList);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2529,7 +2529,7 @@ namespace YLErp.Web.Controllers
|
||||
return JsonError(error);
|
||||
}
|
||||
var clientblack = clientDB.client_black.FirstOrDefault(c => c.Name == client.Name);
|
||||
if (clientblack != null)
|
||||
if (clientblack != null && YLErp.Modules.ClientModule.ClientBlackApprovalPolicy.IsEffective(clientblack.State))
|
||||
{
|
||||
return JsonError("该客户为黑名单客户,禁止取消休眠");
|
||||
}
|
||||
@@ -3402,4 +3402,4 @@ namespace YLErp.Web.Controllers
|
||||
return JsonSuccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,19 @@ namespace YLErp.Web.Controllers
|
||||
{
|
||||
public class clientblackController : BaseController
|
||||
{
|
||||
public static List<SelectListItem> GetClientBlackStates()
|
||||
{
|
||||
return new List<SelectListItem>
|
||||
{
|
||||
new() { Text = client_black.未提交, Value = client_black.未提交 },
|
||||
new() { Text = client_black.新增审批中, Value = client_black.新增审批中 },
|
||||
new() { Text = client_black.新增已拒绝, Value = client_black.新增已拒绝 },
|
||||
new() { Text = client_black.已加入, Value = client_black.已加入 },
|
||||
new() { Text = client_black.删除审批中, Value = client_black.删除审批中 },
|
||||
new() { Text = client_black.删除已拒绝, Value = client_black.删除已拒绝 }
|
||||
};
|
||||
}
|
||||
|
||||
[MyAuthorize("客户管理-黑名单客户")]
|
||||
public ActionResult clientblacklist()
|
||||
{
|
||||
@@ -55,38 +68,75 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
public ActionResult DeleteClientBlack(string ids)
|
||||
{
|
||||
var datalist = ids.Split(',');
|
||||
var list = new List<int>();
|
||||
foreach (var item in datalist)
|
||||
try
|
||||
{
|
||||
var data = clientDB.client_black.Find(int.Parse(item));
|
||||
if (data == null)
|
||||
{
|
||||
return JsonError("未找到要删除的数据");
|
||||
}
|
||||
else
|
||||
{
|
||||
var clitid = clientDB.client.Where(c => c.Name == data.Name).FirstOrDefault();
|
||||
if (clitid != null)
|
||||
{
|
||||
clientDB.ClientAuditLog.Add(new ClientAuditLog
|
||||
{
|
||||
ClientId = clitid.id,
|
||||
OptType = "移除黑名单",
|
||||
Changes = string.Empty,
|
||||
DataType = "00",
|
||||
OptId = UserId,
|
||||
OptName = UserName,
|
||||
OptDate = DateTime.Now
|
||||
});
|
||||
}
|
||||
|
||||
clientDB.client_black.Remove(data);
|
||||
}
|
||||
|
||||
var datalist = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||
var service = new ClientBlackService(CurUser);
|
||||
service.DeleteClientBlack(datalist);
|
||||
return JsonSuccess(service.ProcessList().Any() ? "已经提交删除审批!" : "删除成功");
|
||||
}
|
||||
clientDB.SaveChanges();
|
||||
return JsonSuccess("删除成功");
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonError(ex.GetBaseException().Message);
|
||||
}
|
||||
}
|
||||
|
||||
[MyAuthorize("客户管理-黑名单审批")]
|
||||
public ActionResult clientblackApproval()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[HttpPost, MyAuthorize("客户管理-黑名单审批")]
|
||||
public JsonResult clientblackApprovalQuery(ClientBlackReq req)
|
||||
{
|
||||
return Json(new ClientBlackService(CurUser).ClientBlackApprovalQuery(req));
|
||||
}
|
||||
|
||||
[HttpPost, MyAuthorize("客户管理-黑名单客户提交审批")]
|
||||
public JsonResult clientblackSubmit(string ids)
|
||||
{
|
||||
var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||
new ClientBlackService(CurUser).SubmitApprovalClientBlack(idList);
|
||||
return JsonSuccess("提交审批成功");
|
||||
}
|
||||
|
||||
[HttpPost, MyAuthorize("客户管理-黑名单客户撤回提交审批")]
|
||||
public JsonResult clientblackWithdraw(string ids)
|
||||
{
|
||||
var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||
new ClientBlackService(CurUser).WithdrawApprovalClientBlack(idList, out var withdrawCount, out var msg);
|
||||
if (withdrawCount == 0)
|
||||
{
|
||||
return JsonError(string.IsNullOrWhiteSpace(msg)
|
||||
? "所选记录当前状态无法撤回审批"
|
||||
: $"以下记录已进入后续节点无法撤回:{msg}");
|
||||
}
|
||||
return JsonSuccess("撤回审批成功" + (string.IsNullOrWhiteSpace(msg) ? "" : $",以下记录已进入后续节点无法撤回:{msg}"));
|
||||
}
|
||||
|
||||
[HttpPost, MyAuthorize("客户管理-黑名单审批")]
|
||||
public JsonResult Auditclientblack(ClientBlackAuditReq req)
|
||||
{
|
||||
new ClientBlackService(CurUser).AuditClientBlack(req);
|
||||
return JsonSuccess("审批成功");
|
||||
}
|
||||
|
||||
[MyAuthorize("客户管理-黑名单审批")]
|
||||
public ActionResult clientblackView(string enid)
|
||||
{
|
||||
var id = DataProtectHelper.DecryptInt(enid);
|
||||
var item = clientDB.client_black.FirstOrDefault(x => x.id == id);
|
||||
return View(item);
|
||||
}
|
||||
|
||||
[MyAuthorize("客户管理-黑名单客户")]
|
||||
public ActionResult clientblackLogList(int id)
|
||||
{
|
||||
var logs = clientDB.client_blacklog.Where(x => x.ClientBlackId == id)
|
||||
.OrderByDescending(x => x.id)
|
||||
.ToList();
|
||||
return View(logs);
|
||||
}
|
||||
|
||||
|
||||
@@ -101,4 +151,4 @@ namespace YLErp.Web.Controllers
|
||||
return File(bytes, xlsxMimeType, $"黑名单导出-{DateTime.Now:yyyy-MM-dd}.xlsx");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("删除成功");
|
||||
}
|
||||
|
||||
@@ -786,6 +786,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="isClientBlack">
|
||||
<div style="margin: 10px auto">黑名单审批流程</div>
|
||||
<div>
|
||||
<div class="node-wrap">
|
||||
<div class="end-node">
|
||||
<div class="end-node-text">申请人</div>
|
||||
</div>
|
||||
<div class="node-add-btn-box">
|
||||
<div class="add-node-btn">
|
||||
<button class="addNodeClick" v-on:click="addProcess(0,false,0)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-for="(item,index) in clientBlackItems">
|
||||
<div class="node-wrap">
|
||||
<div class="node-wrap-box start-node">
|
||||
<div class="title" style="background: rgb(255, 148, 62);">
|
||||
<span class="userEdit">审核节点</span>
|
||||
<i class="glyphicon glyphicon-remove btnRemove" v-on:click="delProcess(item)"></i>
|
||||
</div>
|
||||
<div>
|
||||
<span>审核角色</span>
|
||||
<select v-model="item.SelectValue" style="width:143px;height:20px;">
|
||||
<option v-for="option in roleOptions" v-bind:value="option.Value">{{option.Text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-add-btn-box">
|
||||
<div class="add-node-btn">
|
||||
<button class="addNodeClick" v-on:click="addProcess(item.Index,false,0)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="end-node">
|
||||
<div class="end-node-circle"></div>
|
||||
<div class="end-node-text">结束流程</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="addtooltip-warpper">
|
||||
<div id="addtooltip-box">
|
||||
<div v-on:click="tradeAddProcess(1)">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.系统管理.标签编辑权限);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
@{
|
||||
ViewBag.Title = "黑名单客户审批";
|
||||
Layout = "~/Views/Shared/_MainLayout.cshtml";
|
||||
var pageObj = new
|
||||
{
|
||||
roles = UserBLL.GetRolesByUserId(CurUser.UserId).Select(x => x.Id)
|
||||
};
|
||||
}
|
||||
@section CSS{
|
||||
<link href="~/Style/Css/tradeConfirmList.css" rel="stylesheet" />
|
||||
<style>
|
||||
.ui-jqgrid tr.jqgrow td { white-space: pre-wrap; }
|
||||
</style>
|
||||
}
|
||||
@section JS{
|
||||
<script type="text/javascript">
|
||||
const page = @Json.Serialize(pageObj);
|
||||
var g_grid = {};
|
||||
|
||||
$(function () {
|
||||
var PostData = {};
|
||||
@Html.Raw(JqGridSimple.OutGrid("/clientblack/clientblackApprovalQuery", true));
|
||||
g_grid = $('#listGrid');
|
||||
document.onkeydown = function (event) {
|
||||
if ((event || window.event).keyCode == 13) SearchClick(true);
|
||||
};
|
||||
});
|
||||
|
||||
var colModelGrid = [
|
||||
{ name: 'id', hidden: true },
|
||||
{ name: 'ProcessRoleId', hidden: true },
|
||||
{ name: '', label: '操作', width: 90, align: 'center', sortable: false, formatter: approvalButton },
|
||||
{ name: 'ProcessStatus', label: '黑名单审批', width: 135, align: 'center' },
|
||||
{ name: 'State', label: '审批状态', width: 135, align: 'center' },
|
||||
{ name: 'ProcessRoleName', label: '审批角色', width: 140, align: 'center', sortable: false },
|
||||
{ name: 'ClientName', label: '客户名称', width: 220, align: 'center', sortable: false },
|
||||
{ name: 'Comments', label: '黑名单备注', width: 260, align: 'center' },
|
||||
{ name: 'ApprovalOptName', label: '提交审批人', width: 120, align: 'center' },
|
||||
{ name: 'ApprovalOptDate', label: '提交审批时间', width: 160, align: 'center' }
|
||||
];
|
||||
|
||||
function approvalButton(cellValue, options, rowObject) {
|
||||
if (!isAuthorize(rowObject.ProcessRoleId)) {
|
||||
return '<input type="button" class="wentiEdit" value="审批" disabled="disabled" />';
|
||||
}
|
||||
return '<input type="button" class="wentiEdit" title="审批" value="审批" onclick="openApproval(\'' + rowObject.EncryptId + '\');return false;" />';
|
||||
}
|
||||
|
||||
function isAuthorize(roleId) {
|
||||
if (roleId === undefined || roleId === null || roleId === '') return false;
|
||||
return page.roles.some(function (id) { return id.toString() === roleId.toString(); });
|
||||
}
|
||||
|
||||
function openApproval(enid) {
|
||||
main.open('审批', '/clientblack/clientblackView?enid=' + enid + '&approval=true', {
|
||||
area: ['1000px', '75%'],
|
||||
end: function () { SearchClick(); }
|
||||
});
|
||||
}
|
||||
|
||||
function SearchClick(isSearchclick) {
|
||||
var listGrid = $('#listGrid');
|
||||
listGrid.appendPostData({ Name: $('#Name').val() });
|
||||
if (isSearchclick) listGrid.jqGrid('setGridParam', { page: 1 });
|
||||
listGrid.trigger('reloadGrid');
|
||||
}
|
||||
</script>
|
||||
}
|
||||
<div class="searchdiv">
|
||||
<label>客户名称</label>
|
||||
<input type="text" name="Name" id="Name" maxlength="50" />
|
||||
@MyControls.SearchBtn()
|
||||
</div>
|
||||
@Html.Raw(JqGridSimple.OutTable())
|
||||
@@ -0,0 +1,14 @@
|
||||
@model IEnumerable<YLErp.DBModels.ClientBlackLog>
|
||||
@{
|
||||
ViewBag.Title = "黑名单操作历史";
|
||||
Layout = "~/Views/Shared/_InfoLayout.cshtml";
|
||||
}
|
||||
<table class="table table-bordered">
|
||||
<thead><tr><th>时间</th><th>操作人</th><th>操作内容</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model ?? Enumerable.Empty<YLErp.DBModels.ClientBlackLog>())
|
||||
{
|
||||
<tr><td>@item.OptDate.ToString("yyyy-MM-dd HH:mm:ss")</td><td>@item.OptName</td><td>@item.OptType</td><td>@item.Changes</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,45 @@
|
||||
@using YLErp.Modules.ClientModule
|
||||
@model YLErp.Model.client_black
|
||||
@{
|
||||
ViewBag.Title = "黑名单客户审批";
|
||||
Layout = "~/Views/Shared/_InfoLayout.cshtml";
|
||||
var process = new ClientBlackService(CurUser).ProcessList();
|
||||
var currentNode = process.FirstOrDefault(x => x.order == Model?.ApprovalProcess);
|
||||
var canAudit = currentNode != null && UserBLL.GetRolesByUserId(CurUser.UserId).Any(x => x.Id == currentNode.roleId);
|
||||
}
|
||||
@section JS {
|
||||
<script>
|
||||
function audit(status) {
|
||||
main.confirmPost(status === 'pass' ? '确认审批通过?' : '确认拒绝?', '/clientblack/Auditclientblack', {
|
||||
enid: '@(Model?.EncryptId)', status: status, auditComment: $('#AuditComment').val()
|
||||
}).done(function (data) {
|
||||
if (!data.success) return;
|
||||
window.parent.location.reload();
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
}
|
||||
<div class="toolbarDiv" style="height:70px">
|
||||
<div style="display:inline-block;float:left;">
|
||||
@if (canAudit)
|
||||
{
|
||||
@MyControls.Btn("审批通过", "audit('pass');")
|
||||
@MyControls.Btn("拒绝", "audit('reject');")
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="yc-panel">
|
||||
<table class="table table-bordered">
|
||||
<colgroup><col span="1" width="200" /></colgroup>
|
||||
<tr><th class="tdRight">客户名称</th><td>@Model?.Name</td></tr>
|
||||
<tr><th class="tdRight">黑名单备注</th><td>@Model?.Remarks</td></tr>
|
||||
<tr><th class="tdRight">审批状态</th><td>@Model?.State</td></tr>
|
||||
<tr><th class="tdRight">提交审批人</th><td>@Model?.ApprovalOptName</td></tr>
|
||||
<tr><th class="tdRight">提交审批时间</th><td>@Model?.ApprovalOptDate?.ToString("yyyy-MM-dd HH:mm:ss")</td></tr>
|
||||
<tr>
|
||||
<th class="tdRight">审批说明</th>
|
||||
<td><textarea class="text-box text-left" rows="3" id="AuditComment" style="width:700px;height:70px;"></textarea></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -51,10 +51,10 @@
|
||||
var colModelGrid = [{
|
||||
name: 'id', label: 'id', index: 'id', width: 0, hidden: true, optionHide: true
|
||||
}, {
|
||||
name: 'opt', label: '操作', index: 'opt', width: 150, align: 'left', hidden: !page.canEdit, optionHide: !page.canEdit, sortable: false,
|
||||
name: 'opt', label: '操作', index: 'opt', width: 200, align: 'left', hidden: !page.canEdit, optionHide: !page.canEdit, sortable: false,
|
||||
formatter: function (cellValue, options, rowObject) {
|
||||
if (page.canEdit) {
|
||||
var html = ("<input type=\"button\" class=\"wentiEdit\" onclick=\"startAddclientblack('{0}');return false;\" value=\"设置\" /><input type=\"button\" class=\"wentiEdit\" onclick=\"ClientBlackDeleteRow('{0}');return false;\" value=\"删除\" />")
|
||||
var html = ("<input type=\"button\" class=\"wentiEdit\" onclick=\"clientblackLogView('{0}');return false;\" value=\"查看\" /><input type=\"button\" class=\"wentiEdit\" onclick=\"startAddclientblack('{0}');return false;\" value=\"设置\" /><input type=\"button\" class=\"wentiEdit\" onclick=\"ClientBlackDeleteRow('{0}');return false;\" value=\"删除\" />")
|
||||
.template(rowObject.id);
|
||||
return html;
|
||||
}
|
||||
@@ -65,7 +65,9 @@
|
||||
}, {
|
||||
name: 'Name', label: '客户名称', index: 'Name', width: 260
|
||||
}, {
|
||||
name: 'Remarks', label: '备注', index: 'Remarks', width: 500
|
||||
name: 'Remarks', label: '备注', index: 'Remarks', width: 500
|
||||
}, {
|
||||
name: 'State', label: '状态', index: 'State', width: 120
|
||||
}, {
|
||||
name: 'OptName', label: '操作人', index: 'OptName', width: 150
|
||||
}, {
|
||||
@@ -146,7 +148,7 @@
|
||||
function SearchClick(isSearchclick) {
|
||||
var listGrid = $('#listGrid');
|
||||
listGrid.appendPostData({ Name: $("#Name").val() });
|
||||
listGrid.appendPostData({ OptName: $("#OptName").val() });
|
||||
listGrid.appendPostData({ ClientBlackStates: $("#ClientBlackStates").val()?.join(',') || '' });
|
||||
if (typeof (isSearchclick) != "undefined" && isSearchclick) {
|
||||
//点击搜索时默认第一页
|
||||
listGrid.jqGrid('setGridParam', {page: 1});
|
||||
@@ -241,6 +243,19 @@
|
||||
});
|
||||
})
|
||||
}
|
||||
function ClientBlackSubmit() {
|
||||
var ids = main.GetGridIds($('#listGrid'));
|
||||
if (!ids.length) { main.message('请选择要提交的数据!'); return; }
|
||||
main.post('/clientblack/clientblackSubmit', { ids: ids.toString() }).done(function () { SearchClick(); });
|
||||
}
|
||||
function ClientBlackWithdraw() {
|
||||
var ids = main.GetGridIds($('#listGrid'));
|
||||
if (!ids.length) { main.message('请选择要撤回的数据!'); return; }
|
||||
main.post('/clientblack/clientblackWithdraw', { ids: ids.toString() }).done(function () { SearchClick(); });
|
||||
}
|
||||
function clientblackLogView(id) {
|
||||
main.open('操作历史', '/clientblack/clientblackLogList?id=' + id, { area: ['1000px', '75%'] });
|
||||
}
|
||||
|
||||
</script>
|
||||
}
|
||||
@@ -267,6 +282,7 @@
|
||||
<form class="form-inline search-form" onsubmit="return false;" autocomplete="off">
|
||||
<label>客户名称</label>
|
||||
<input type="text" name="Name" id="Name" maxlength="50" />
|
||||
@Html.MyAceDropdownInput("ClientBlackStates", "状态", clientblackController.GetClientBlackStates())
|
||||
<button type="button" class="btn btn-primary" onclick="return(SearchClick(true));"><span class="glyphicon glyphicon-search"></span> 查询</button>
|
||||
@if (CurUser.客户管理.黑名单客户管理)
|
||||
{
|
||||
@@ -275,6 +291,14 @@
|
||||
<button type="button" class="btn btn-primary" onclick="ExportClientBlack();">批量导出</button>
|
||||
<button type="button" class="btn btn-primary" onclick="ClientBlackDelete();">批量移除</button>
|
||||
}
|
||||
@if (CurUser.客户管理.黑名单客户提交审批)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" onclick="ClientBlackSubmit();">提交审批</button>
|
||||
}
|
||||
@if (CurUser.客户管理.黑名单客户撤回提交审批)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" onclick="ClientBlackWithdraw();">撤回审批</button>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
@Html.Raw(JqGridSimple.OutTable())
|
||||
@Html.Raw(JqGridSimple.OutTable())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -53,7 +53,8 @@ var app = new Vue({
|
||||
{ text: '交易新增与修改', value: '2' },
|
||||
{ text: '交易了结', value: '6' },
|
||||
/* { text: '资信与授信', value: '3' },*/
|
||||
{ text: '出金', value: '4' }
|
||||
{ text: '出金', value: '4' },
|
||||
{ text: '黑名单', value: '7' }
|
||||
|
||||
],
|
||||
isOpen: false,
|
||||
@@ -62,12 +63,14 @@ var app = new Vue({
|
||||
isCredit: false,
|
||||
isOutCash: false,
|
||||
isClient: false,
|
||||
isClientBlack: false,
|
||||
openItems: [],
|
||||
clientItems: [],
|
||||
tradeItems: [],
|
||||
closeItems: [],
|
||||
creditItems: [],
|
||||
outCashItems: [],
|
||||
clientBlackItems: [],
|
||||
openCounter: 0,
|
||||
tradeCounter: 0,
|
||||
creditCounter: 0,
|
||||
@@ -140,6 +143,7 @@ var app = new Vue({
|
||||
thisObj.isCredit = false;
|
||||
thisObj.isOutCash = false;
|
||||
thisObj.isClient = false;
|
||||
thisObj.isClientBlack = false;
|
||||
} else if (thisObj.selected === '2') {
|
||||
thisObj.isOpen = false;
|
||||
thisObj.isTrade = true;
|
||||
@@ -147,6 +151,7 @@ var app = new Vue({
|
||||
thisObj.isCredit = false;
|
||||
thisObj.isOutCash = false;
|
||||
thisObj.isClient = false;
|
||||
thisObj.isClientBlack = false;
|
||||
} else if (thisObj.selected === '6') { // 需求②:交易了结流程
|
||||
thisObj.isOpen = false;
|
||||
thisObj.isTrade = false;
|
||||
@@ -154,6 +159,7 @@ var app = new Vue({
|
||||
thisObj.isCredit = false;
|
||||
thisObj.isOutCash = false;
|
||||
thisObj.isClient = false;
|
||||
thisObj.isClientBlack = false;
|
||||
} else if (thisObj.selected === '3') {
|
||||
thisObj.isOpen = false;
|
||||
thisObj.isTrade = false;
|
||||
@@ -161,6 +167,7 @@ var app = new Vue({
|
||||
thisObj.isCredit = true;
|
||||
thisObj.isOutCash = false;
|
||||
thisObj.isClient = false;
|
||||
thisObj.isClientBlack = false;
|
||||
}
|
||||
else if (thisObj.selected === '4') {
|
||||
thisObj.isOpen = false;
|
||||
@@ -169,6 +176,7 @@ var app = new Vue({
|
||||
thisObj.isCredit = false;
|
||||
thisObj.isOutCash = true;
|
||||
thisObj.isClient = false;
|
||||
thisObj.isClientBlack = false;
|
||||
}
|
||||
else if (thisObj.selected === '5') {
|
||||
thisObj.isOpen = false;
|
||||
@@ -177,6 +185,16 @@ var app = new Vue({
|
||||
thisObj.isCredit = false;
|
||||
thisObj.isOutCash = false;
|
||||
thisObj.isClient = true;
|
||||
thisObj.isClientBlack = false;
|
||||
}
|
||||
else if (thisObj.selected === '7') {
|
||||
thisObj.isOpen = false;
|
||||
thisObj.isTrade = false;
|
||||
thisObj.isClose = false;
|
||||
thisObj.isCredit = false;
|
||||
thisObj.isOutCash = false;
|
||||
thisObj.isClient = false;
|
||||
thisObj.isClientBlack = true;
|
||||
}
|
||||
else {
|
||||
thisObj.isOpen = false;
|
||||
@@ -185,6 +203,7 @@ var app = new Vue({
|
||||
thisObj.isCredit = false;
|
||||
thisObj.isOutCash = false;
|
||||
thisObj.isClient = false;
|
||||
thisObj.isClientBlack = false;
|
||||
}
|
||||
thisObj.getProcess();
|
||||
},
|
||||
@@ -385,6 +404,18 @@ var app = new Vue({
|
||||
thisObj.addCloseNode(index, child, node);
|
||||
return;
|
||||
}
|
||||
else if (selectType === "7") { //黑名单
|
||||
var item = {
|
||||
Type: 'ClientBlackProcess',
|
||||
Index: index + 1,
|
||||
SelectValue: 0
|
||||
};
|
||||
thisObj.clientBlackItems.splice(index, 0, item);
|
||||
thisObj.clientBlackItems.forEach(function (x, itemIndex) {
|
||||
x.Index = itemIndex + 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
delProcess: function (openItem) {
|
||||
@@ -417,6 +448,14 @@ var app = new Vue({
|
||||
});
|
||||
return;
|
||||
}
|
||||
else if (selectType === "7") {//黑名单
|
||||
var index = thisObj.clientBlackItems.indexOf(openItem);
|
||||
thisObj.clientBlackItems.splice(index, 1);
|
||||
thisObj.clientBlackItems.forEach(function (x, itemIndex) {
|
||||
x.Index = itemIndex + 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
addOpenProcess(index, child, node) {
|
||||
var thisObj = this;
|
||||
@@ -528,6 +567,10 @@ var app = new Vue({
|
||||
thisObj.saveCloseProcess();
|
||||
return;
|
||||
}
|
||||
else if (selectType === "7") { //黑名单
|
||||
thisObj.clientBlackOk();
|
||||
return;
|
||||
}
|
||||
},
|
||||
openOk() {
|
||||
var thisObj = this;
|
||||
@@ -857,6 +900,38 @@ var app = new Vue({
|
||||
});
|
||||
}
|
||||
},
|
||||
clientBlackOk() {
|
||||
var thisObj = this;
|
||||
var items = thisObj.clientBlackItems;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].SelectValue === "" || items[i].SelectValue === 0) {
|
||||
main.message('流程中断,请重新选择');
|
||||
return;
|
||||
}
|
||||
for (var j = i + 1; j < items.length; j++) {
|
||||
if (parseInt(items[i].SelectValue) === parseInt(items[j].SelectValue)) {
|
||||
main.message('流程包含重复项,请重新选择');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
main.confirm("确认修改黑名单审批流程?", function () {
|
||||
main.post("/AccountOpeningProcess/AddProcess",
|
||||
{ type: "ClientBlackProcess", data: items },
|
||||
{ async: false }).done(function () {
|
||||
thisObj.getProcess();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
main.confirm("删除审批流程后,黑名单变更会直接生效,确认删除?", function () {
|
||||
main.post("/AccountOpeningProcess/AddProcess",
|
||||
{ type: "ClientBlackProcess" },
|
||||
{ async: false });
|
||||
});
|
||||
}
|
||||
},
|
||||
getProcess() {
|
||||
var thisObj = this;
|
||||
thisObj.openItems = [];
|
||||
@@ -865,6 +940,7 @@ var app = new Vue({
|
||||
thisObj.creditItems = [];
|
||||
thisObj.outCashItems = [];
|
||||
thisObj.clientItems = [];
|
||||
thisObj.clientBlackItems = [];
|
||||
main.post("/AccountOpeningProcess/GetProcess",
|
||||
{},
|
||||
{ async: false }).done(
|
||||
@@ -945,6 +1021,14 @@ var app = new Vue({
|
||||
triggerCondition: value.triggerCondition
|
||||
});
|
||||
});
|
||||
(res.ClientBlackProcess || []).forEach(function (value) {
|
||||
thisObj.clientBlackItems.push({
|
||||
id: value.id,
|
||||
Type: value.processType,
|
||||
Index: value.order,
|
||||
SelectValue: value.roleId
|
||||
});
|
||||
});
|
||||
// 需求①:加载后把 triggerCondition(JSON)解析为结构化对象供 UI 编辑
|
||||
['openItems', 'tradeItems', 'closeItems', 'clientItems'].forEach(function (arr) {
|
||||
thisObj[arr].forEach(function (item) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user