Merge remote-tracking branch 'dest/glms/feature/1.4.2' into feature/p132_74-risk-engine

This commit is contained in:
lisong
2026-07-17 15:41:32 +08:00
101 changed files with 13790 additions and 1083 deletions
@@ -54,5 +54,21 @@ namespace YLErp.DBModels
/// </summary>
[DisplayName("审批组条件")]
public int? approvalCondition { get; set; }
/// <summary>
/// 分支网关条件(JSON)。
/// <para>用于多分支流程的进入条件判断,结构见 ConditionExpressionConfig。</para>
/// <para>为空时回退到旧的 approvalGroupId/approvalCondition 二元判断(双写兼容)。</para>
/// </summary>
[DisplayName("分支网关条件")]
public string conditionConfig { get; set; }
/// <summary>
/// 节点触发条件(JSON)。
/// <para>仅挂在审批节点(node=0)上:推进到该节点时求值,满足才进入该节点审批;不满足则跳过该节点。</para>
/// <para>为空视为无条件(默认进入审批)。例:名义本金 A默认审核、B>100W再审核、C>=500W再审核。</para>
/// </summary>
[DisplayName("节点触发条件")]
public string triggerCondition { get; set; }
}
}
@@ -154,5 +154,18 @@ namespace YLErp.DBModels
/// 聚源id
/// </summary>
public long? JSID { get; set; }
/// <summary>
/// 创建人(登录用户ID)。聚源同步写入时为 NULL,手工编辑时由 SaveBondPrice 写入。
/// 对应库表 china_bond_valuation.create_user(bigint)。
/// </summary>
public long? create_user { get; set; }
/// <summary>
/// 更新人(登录用户ID)。聚源同步写入时为 NULL,手工编辑时由 SaveBondPrice 写入。
/// 对应库表 china_bond_valuation.update_user(bigint)。
/// 约定:NULL = 聚源/中债自动同步(无人手工维护);有值 = 被人手工改过、可溯源。
/// </summary>
public long? update_user { get; set; }
}
}
@@ -9,6 +9,7 @@ namespace YLErp.DBModels
public class EodPriceBase : DBModelWithOperator
{
public static string = "人工";
public static string = "系统";
/// <summary>
/// 合约代码
+48 -3
View File
@@ -52,19 +52,21 @@ namespace YLErp.DBModels
[DataChange]
public string StructureType { get; set; }
/// <summary>
/// 合约名义本金
/// 合约名义本金。取交易原始等价名义本金,表示合约约定规模;
/// 不等于多头与空头日终腿的代数和。
/// </summary>
[DisplayName("合约名义本金")]
[DataChange]
public decimal NotionalValue { get; set; }
/// <summary>
/// 合约多头名义本金
/// 合约多头名义本金。框架合约展示口径中多头始终为正数。
/// </summary>
[DisplayName("合约多头名义本金")]
[DataChange]
public decimal NotionalValueLong { get; set; }
/// <summary>
/// 合约空头名义本金
/// 合约空头名义本金。框架合约展示口径中空头始终为负数,
/// 以便与多头直接相加得到净方向。
/// </summary>
[DisplayName("合约空头名义本金")]
[DataChange]
@@ -174,5 +176,48 @@ namespace YLErp.DBModels
public int ClientId { get; set; }
public string SwapTradeTypeStr { get; set; }
public string UnderlyingType { get; set; }
/// <summary>
/// 合约期内已实现加待实现的付息/分红金额。
/// 该字段用于框架合约风险展示,不按每日估值报告的“期间付息/期间分红”列拆分。
/// </summary>
public decimal PeriodAmount { get; set; }
/// <summary>
/// 合约浮动端待实现收益,取浮动腿盯市收益及未结交易费用,
/// 不包含期间付息/分红,避免与 <see cref="PeriodAmount"/> 重复。
/// </summary>
public decimal FloatingUnrealizedPnl { get; set; }
/// <summary>
/// 付息/分红支付方式:到期轧差时计入到期轧差估值,派息日支付时在期间支付口径展示。
/// </summary>
public string InterestPaymentMethod { get; set; }
/// <summary>
/// 合约估值(到期轧差口径)= 浮动端待实现收益 + 利率端待实现收益 + 期间付息/分红。
/// 仅当支付方式为到期轧差时赋值。
/// </summary>
public decimal? MaturityNettingValuation { get; set; }
/// <summary>
/// 合约估值(派息日支付口径)= 浮动端待实现收益 + 利率端待实现收益。
/// 派息/分红在支付日独立结算,因此不计入该估值。
/// </summary>
public decimal? PeriodPaymentValuation { get; set; }
/// <summary>
/// 我方收取的保证金利息累计额。保证金腿原始“支付”方向表示
/// 对手方向我方支付保证金,利息现金流方向与保证金本金方向相反。
/// </summary>
public decimal MarginInterestGain { get; set; }
/// <summary>
/// 我方支付的保证金利息累计额。保证金腿原始“收取”方向表示
/// 我方收取对手方保证金,应向对手方支付利息;支付金额以负数展示。
/// </summary>
public decimal MarginInterestLoss { get; set; }
}
}
@@ -20,8 +20,7 @@ namespace YLErp.DBModels
/// </summary>
public string StructureType { get; set; }
/// <summary>
/// <summary>
/// 交易对手方名称
/// 交易对手方名称。每日估值报告页面当前不展示该列,但发送报告与其他调用方仍可使用。
/// </summary>
public string ClientName { get; set; }
/// <summary>
@@ -33,44 +32,65 @@ namespace YLErp.DBModels
/// </summary>
public string TradeNumber { get; set; }
/// <summary>
/// 期间付息
/// 期间付息。仅现券标的赋值;ETF、指数及其他标的返回 <c>null</c>,由前端和 Excel 显示为空白。
/// </summary>
public decimal PeriodAmount { get; set; }
public decimal? PeriodAmount { get; set; }
/// <summary>
/// 到期结算日,直接取日终浮动腿的到期日期,不叠加结算规则或节假日顺延。
/// </summary>
public DateTime? MaturitySettlementDate { get; set; }
/// <summary>
/// 期间分红。仅 ETF 标的赋值;现券、指数及其他标的返回 <c>null</c>,避免同一金额在不适用列展示。
/// </summary>
public decimal? DividendAmount { get; set; }
/// <summary>
/// 期初标的成交收益率。仅现券标的直接取交易录入的 <c>trade.InitYtm</c>;其他标的返回 <c>null</c>。
/// </summary>
public decimal? InitYtm { get; set; }
/// <summary>
/// 期限
/// 实际期限,按估值日与起始日的自然日差加一计算,包含起始日。
/// </summary>
public int DayCount { get; set; }
/// <summary>
/// 期初预付金-不包含追加预付金 取轧差
/// 期初预付金本金,仅汇总初始预付金交易腿;收取为正、支付为负。
/// </summary>
public decimal OpenMarginAmount { get; set; }
/// <summary>
/// 期初预付金利率-不包含追加预付金 取轧差
/// 预付金利率,初始和追加预付金腿按本金规模加权平均
/// </summary>
public decimal OpenMarginRate { get; set; }
/// <summary>
/// 预付金利息 取轧差
/// 预付金利息,初始和追加预付金腿按本金规模加权平均
/// </summary>
public decimal MarginInterestAmount { get; set; }
/// <summary>
/// 浮动利率(绝对)利率端待实现收益/(标的名义金额/期初标的交割价格全价)
/// 追加预付金本金,仅汇总估值日前已生效的追加预付金交易腿;收取为正、支付为负。
/// </summary>
public decimal AdditionalMarginAmount { get; set; }
/// <summary>
/// 浮动利率(绝对)= 利率收益金额 / 标的名义金额。
/// 该字段是展示型比例,不参与净额结算金额计算。
/// </summary>
public decimal FloatRateAbs { get; set; }
/// <summary>
/// 利差
/// 利差,汇总非预付金利息腿的约定利率。
/// </summary>
public decimal InterestRate { get; set; }
/// <summary>
/// 利率收益金额 利率端待实现收益
/// 利率收益金额,汇总非预付金利息腿的 <c>InterestIncomeSum</c>,并转换为我方视角。
/// </summary>
public decimal InterestAmount { get; set; }
/// <summary>
/// 净额结算金额 互换持仓价值+待返还的预付金本金
/// 净额结算金额 = 利率收益金额 + 浮动收益金额 + 开平仓交易费用 + 预付金利息
/// + 到期轧差方式下应计入的期间付息/分红;不包含两类预付金本金。
/// </summary>
public decimal NetSettmentAmount { get; set; }
/// <summary>
/// TRS估值 = 净额结算金额 + 期初预付金 + 追加预付金。
/// </summary>
public decimal TrsValue { get; set; }
/// <summary>
/// 交易费用
/// </summary>
public decimal TradingFee { get; set; }
@@ -130,6 +130,11 @@ namespace YLErp.DBModels
/// </summary>
public DateTime ValueDate { get; set; }
/// <summary>
/// 收益结算日期上限
/// </summary>
[NotMapped]
public DateTime? MaxIncomeValueDate { get; set; }
/// <summary>
/// 平仓/互换日期
/// </summary>
public DateTime? UnwindDate { get; set; }
@@ -222,7 +222,7 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator
var bond = JsonHelper.Deserialize<UnderlyingBond>(underlying.ExJson) ?? new UnderlyingBond();
dic["参考标的发行人"] = bond.UnderlyingIssuer ?? "";
dic["参考标的担保人"] = "";
dic["票面利率"] = ((double)(bond.CouponRate ?? 0)).ToString("0.00");
dic["票面利率"] = ((double)(bond.CouponRate ?? 0) * 100).ToString("0.00");
dic["参考标的到期日"] = underlying.MaturityDate?.ToString("【yyyy】年【M】月【d】日") ?? "";
}
@@ -0,0 +1,366 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using YLErp.Helpers;
namespace YLErp.Helpers.Tests
{
[TestClass]
public class ConditionEvaluatorTests
{
private static ConditionContext CreateContext(double notional = 0, string tradeType = "", int? userId = null, int? initGroupId = null, string processCategory = "")
{
return new ConditionContext
{
Trade = new DBModels.trade
{
StockEqvNotional = notional,
TradeType = tradeType
},
UserId = userId,
InitGroupId = initGroupId,
ProcessCategory = processCategory
};
}
[TestMethod]
public void Evaluate_EmptyOrNullCondition_ReturnsFalse()
{
Assert.IsFalse(ConditionEvaluator.Evaluate(null, CreateContext()));
Assert.IsFalse(ConditionEvaluator.Evaluate("", CreateContext()));
Assert.IsFalse(ConditionEvaluator.Evaluate(" ", CreateContext()));
}
[TestMethod]
public void Evaluate_InvalidJson_ReturnsFalse()
{
Assert.IsFalse(ConditionEvaluator.Evaluate("not a json", CreateContext()));
Assert.IsFalse(ConditionEvaluator.Evaluate("{\"tokens\": [", CreateContext()));
}
[TestMethod]
public void Evaluate_TokenSingleCondition_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken
{
type = "condition",
condition = new ConditionItem { field = "notional", op = ">", value = 100 }
}
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
}
[TestMethod]
public void Evaluate_TokenAnd_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } },
new ConditionToken { type = "operator", connector = "and" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<=", value = 500 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
}
[TestMethod]
public void Evaluate_TokenOr_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 100 } },
new ConditionToken { type = "operator", connector = "or" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 500 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 600)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 300)));
}
[TestMethod]
public void Evaluate_TokenWithParentheses_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "lparen" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } },
new ConditionToken { type = "operator", connector = "or" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 50 } },
new ConditionToken { type = "rparen" },
new ConditionToken { type = "operator", connector = "and" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } }
}
});
// (200>100 or 200<50) and tradeType=="香草" => true
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "香草")));
// (30>100 or 30<50) and tradeType=="雪球" => false
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 30, tradeType: "雪球")));
// (80>100 or 80<50) and tradeType=="香草" => false
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 80, tradeType: "香草")));
}
[TestMethod]
public void Evaluate_MixedAndOr_PriorityAndOverOr()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } },
new ConditionToken { type = "operator", connector = "or" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 50 } },
new ConditionToken { type = "operator", connector = "and" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } }
}
});
// A or (B and C) — and 优先级高于 or
// 200>100 -> true,无需计算右侧
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "")));
// 30>100=false, 30<50=true, 香草==香草=true -> false or (true and true) = true
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 30, tradeType: "香草")));
// 80>100=false, 80<50=false, 雪球==香草=false -> false or (false and false) = false
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 80, tradeType: "雪球")));
}
[TestMethod]
public void Evaluate_InitGroup_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "initGroup", op = "==", value = 5 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { InitGroupId = 5 }));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { InitGroupId = 3 }));
}
[TestMethod]
public void Evaluate_ProcessCategory_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "processCategory", op = "==", value = "CloseProcess" } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(processCategory: "CloseProcess")));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(processCategory: "TradeProcess")));
}
[TestMethod]
public void Evaluate_TradeTypeStringComparison_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "!=", value = "雪球" } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(tradeType: "雪球")));
}
[TestMethod]
public void Evaluate_IncompleteParentheses_DoesNotThrow()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "lparen" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } }
}
});
// Parser tolerates missing closing parenthesis and returns the value inside
var result = ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200));
Assert.IsTrue(result);
}
[TestMethod]
public void Evaluate_NotEquals_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "!=", value = 100 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
}
[TestMethod]
public void Evaluate_GreaterOrEqual_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">=", value = 100 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 99)));
}
// ===== 字母 op 标识符(前端改用 gt/lt/gte/lte/eq/neq 规避 > < 编码问题)=====
[TestMethod]
public void Evaluate_AlphaOp_GreaterThan_Works()
{
var condition = BuildTokens(new ConditionItem { field = "notional", op = "gt", value = 100 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
}
[TestMethod]
public void Evaluate_AlphaOp_LessThanOrEqual_Works()
{
var condition = BuildTokens(new ConditionItem { field = "notional", op = "lte", value = 100 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 101)));
}
[TestMethod]
public void Evaluate_AlphaOp_EqualAndNotEqual_Works()
{
var eqCond = BuildTokens(new ConditionItem { field = "tradeType", op = "eq", value = "香草" });
Assert.IsTrue(ConditionEvaluator.Evaluate(eqCond, CreateContext(tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(eqCond, CreateContext(tradeType: "雪球")));
var neqCond = BuildTokens(new ConditionItem { field = "tradeType", op = "neq", value = "雪球" });
Assert.IsTrue(ConditionEvaluator.Evaluate(neqCond, CreateContext(tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(neqCond, CreateContext(tradeType: "雪球")));
}
[TestMethod]
public void Evaluate_AlphaOp_CaseInsensitive_Works()
{
// 大写字母 op 也应识别(归一化为小写)
var condition = BuildTokens(new ConditionItem { field = "notional", op = "GTE", value = 100 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 99)));
}
[TestMethod]
public void Evaluate_MixedAlphaAndSymbolOp_Works()
{
// 字母 op 与符号 op 混用:notional gt 100 and tradeType == 香草
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "gt", value = 100 } },
new ConditionToken { type = "operator", connector = "and" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50, tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "雪球")));
}
// ===== 需求①触发条件字段:initialNotional(期初)/ currentNotional(本次)=====
[TestMethod]
public void Evaluate_InitialNotional_UsesOriginalStockEqvNotional()
{
// initialNotional 取 trade.OriginalStockEqvNotional(与 StockEqvNotional 是不同字段)
var condition = BuildTokens(new ConditionItem { field = "initialNotional", op = "gte", value = 1000000 });
var ctx = new ConditionContext
{
Trade = new DBModels.trade
{
StockEqvNotional = 500, // 当前份额,不应被 initialNotional 使用
OriginalStockEqvNotional = 2000000 // 期初名义本金
}
};
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, ctx));
var ctxBelow = new ConditionContext
{
Trade = new DBModels.trade { OriginalStockEqvNotional = 500000 }
};
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, ctxBelow));
}
[TestMethod]
public void Evaluate_CurrentNotional_UsesContextValue()
{
// currentNotional 取 ConditionContext.CurrentNotional(了结场景由 trade_cash 取绝对值传入)
var condition = BuildTokens(new ConditionItem { field = "currentNotional", op = "gt", value = 1000000 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 5000000 }));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 500000 }));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = null }));
}
[TestMethod]
public void Evaluate_CurrentNotional_AbsoluteValueSemantics()
{
// 需求:平仓500万,本次交易名义本金按绝对值判断。
// 调用方应传 Math.Abs 后的正值(BuildTriggerContext 已处理),这里验证传入正值即可。
var condition = BuildTokens(new ConditionItem { field = "currentNotional", op = "gte", value = 5000000 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 5000000 }));
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 8000000 }));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 4999999 }));
}
[TestMethod]
public void Evaluate_InitialNotional_AbsoluteValueStored()
{
// OriginalStockEqvNotional 的 setter 已做 Math.Abs,负值存入会变正
var condition = BuildTokens(new ConditionItem { field = "initialNotional", op = "gt", value = 100 });
var ctx = new ConditionContext
{
Trade = new DBModels.trade { OriginalStockEqvNotional = -500 } // setter 归一化为 500
};
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, ctx));
}
/// <summary>辅助:单条件 tokens 序列化为 JSON。</summary>
private static string BuildTokens(ConditionItem item)
{
return JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = item }
}
});
}
}
}
@@ -0,0 +1,162 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.Helpers;
using System.Collections.Generic;
using System.Linq;
namespace YLErp.Helpers.Tests
{
/// <summary>
/// 需求①:交易提交进入审批流程时的「起点触发条件跳过」测试。
/// <para>场景:交易一进来,若节点1、2配置的触发条件均不满足,应直接从节点3开始审核;
/// 若全部节点都不满足,则直接审批通过(无需任何审核)。</para>
/// </summary>
[TestClass]
public class TriggerNodeSkipTests
{
/// <summary>构造一个审批节点:order + 可选的触发条件JSON。</summary>
private static approvalprocess Node(int order, string triggerCondition = null)
{
return new approvalprocess
{
order = order,
node = 0,
triggerCondition = triggerCondition
};
}
/// <summary>构造"期初名义本金 > 阈值"的触发条件JSON。</summary>
private static string InitialNotionalGt(double threshold)
{
return JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new List<ConditionToken>
{
new ConditionToken
{
type = "condition",
condition = new ConditionItem { field = "initialNotional", op = "gt", value = threshold }
}
}
});
}
private static ConditionContext Ctx(double initialNotional)
{
return new ConditionContext
{
Trade = new trade { OriginalStockEqvNotional = initialNotional }
};
}
[TestMethod]
public void StartNode_NoTrigger_ReturnsStartDirectly()
{
// 节点1无触发条件 → 直接返回节点1
var nodes = new List<approvalprocess> { Node(1), Node(2), Node(3) };
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(500));
Assert.IsNotNull(result);
Assert.AreEqual(1, result.order);
}
[TestMethod]
public void StartNode_TriggerSatisfied_ReturnsStart()
{
// 节点1触发条件">100",交易期初200满足 → 返回节点1
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(100)),
Node(2),
Node(3)
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
Assert.IsNotNull(result);
Assert.AreEqual(1, result.order);
}
[TestMethod]
public void StartNode_TriggerNotSatisfied_SkipsToNextSatisfied()
{
// 节点1触发">100",节点2触发">500",交易期初200
// → 节点1不满足(200>100满足? 满足)... 重新设计:节点1触发">1000"200不满足;节点2无触发 → 返回节点2
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000)), // 200 不满足 >1000
Node(2), // 无触发条件
Node(3)
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
Assert.IsNotNull(result);
Assert.AreEqual(2, result.order);
}
[TestMethod]
public void AllStartNodesNotSatisfied_ReturnsNull_DirectlyApproved()
{
// 节点1、2、3都有触发条件,交易都不满足 → 返回null(表示直接审批通过)
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000)), // 200 不满足
Node(2, InitialNotionalGt(5000)), // 200 不满足
Node(3, InitialNotionalGt(10000)) // 200 不满足
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
Assert.IsNull(result);
}
[TestMethod]
public void SkipMultipleNodes_LandsOnThird()
{
// 节点1、2都不满足,节点3无触发 → 返回节点3
// 模拟"A默认审核、B>100W再审核、C>=500W再审核"中,小额交易跳过B、C直达... 实际应停在满足的节点
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000000)), // 50W 不满足
Node(2, InitialNotionalGt(2000000)), // 50W 不满足
Node(3) // 无触发条件(兜底审核)
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(500000));
Assert.IsNotNull(result);
Assert.AreEqual(3, result.order);
}
[TestMethod]
public void SatisfiedAtSecondNode_StopsThere()
{
// 节点1触发">1000"不满足,节点2触发">100"满足 → 返回节点2(不会继续到节点3)
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000)), // 200 不满足
Node(2, InitialNotionalGt(100)), // 200 满足
Node(3, InitialNotionalGt(50)) // 不会走到这
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
Assert.IsNotNull(result);
Assert.AreEqual(2, result.order);
}
[TestMethod]
public void StartFromMiddleNode_Works()
{
// 起点不是节点1(如分支调整后从节点2开始),从节点2起判断
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000)),
Node(2, InitialNotionalGt(1000)), // 200 不满足
Node(3) // 无触发
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[1], Ctx(200));
Assert.IsNotNull(result);
Assert.AreEqual(3, result.order);
}
[TestMethod]
public void NullStart_ReturnsNull()
{
var nodes = new List<approvalprocess> { Node(1) };
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, null, Ctx(500));
Assert.IsNull(result);
}
}
}
@@ -0,0 +1,72 @@
namespace YLErp.Modules.DataProviderModule
{
/// <summary>
/// TryGetSettlementEodPrice(债券感知统一取价)的白盒测试。
/// 覆盖期权/交易到期结算场景:债券标的应走中债估值表取到价(修复"结算价未找到"),
/// 非债券标的行为应与原 TryGetEodPrice 完全一致(不影响期货/股票)。
/// 注:DB 驱动,需连测试库;无数据时 Assert.Inconclusive 跳过。
/// </summary>
[TestClass]
public class EodPriceQueryServiceSettlementTest : YLUnitTestBase
{
[TestMethod]
public void BondUnderlying_RoutesToChinaBondValuation()
{
using var db = DbContextFactory.GetYLDbContext();
var bond = (from b in db.china_bond_valuation
join u in db.underlying_manager on b.bond_id equals u.UnderlyingCode
where b.dirty_price_close > 0
orderby b.valuation_date descending
select new { b.bond_id, vd = b.valuation_date }).FirstOrDefault();
if (bond == null) Assert.Inconclusive("测试库无债券估值数据,跳过");
var ok = EodPriceQueryService.TryGetSettlementEodPrice(bond.vd, bond.bond_id, out var ep);
Assert.IsTrue(ok, "债券标的应走中债估值表取到价(修复点)");
Assert.IsNotNull(ep);
// 债券 ClosePrice=全价(dirty_price_close),应与 GetBondPrice().ClosePrice 一致
var bondPrice = EodPriceQueryService.GetBondPrice(bond.vd, bond.bond_id);
Assert.IsNotNull(bondPrice);
Assert.AreEqual(bondPrice.ClosePrice, ep.ClosePrice, 1e-6);
}
[TestMethod]
public void NonBondUnderlying_RoutesToStockOrFuturePath()
{
using var db = DbContextFactory.GetYLDbContext();
var stock = (from s in db.eod_stock_price
join u in db.underlying_manager on s.UnderlyingCode equals u.UnderlyingCode
where s.ClosePrice > 0 && u.UnderlyingInstrumentType == "Stock"
select new { s.UnderlyingCode, s.ValueDate }).FirstOrDefault();
if (stock == null) Assert.Inconclusive("测试库无(股票类型)价格数据,跳过");
var ok = EodPriceQueryService.TryGetSettlementEodPrice(stock.ValueDate, stock.UnderlyingCode, out var ep);
var okOld = EodPriceQueryService.TryGetEodPrice(stock.ValueDate, stock.UnderlyingCode, out var epOld);
Assert.AreEqual(okOld, ok, "非债券标的行为应与原 TryGetEodPrice 一致");
if (ok)
{
Assert.IsNotNull(ep);
Assert.AreEqual(epOld.ClosePrice, ep.ClosePrice, 1e-6, "非债券标的取到的收盘价应与原路径相同");
}
}
[TestMethod]
public void BondOptionExpiry_Regression_OldPathFailsNewPathSucceeds()
{
using var db = DbContextFactory.GetYLDbContext();
var bond = (from b in db.china_bond_valuation
join u in db.underlying_manager on b.bond_id equals u.UnderlyingCode
where b.dirty_price_close > 0
orderby b.valuation_date descending
select new { b.bond_id, vd = b.valuation_date }).FirstOrDefault();
if (bond == null) Assert.Inconclusive("测试库无债券估值数据,跳过");
// 旧路径:TryGetEodPrice 只 join 期货/股票两表,债券取不到价
var oldOk = EodPriceQueryService.TryGetEodPrice(bond.vd, bond.bond_id, out _);
// 新路径:债券感知统一取价,应能取到
var newOk = EodPriceQueryService.TryGetSettlementEodPrice(bond.vd, bond.bond_id, out var ep);
Assert.IsFalse(oldOk, "回归基线:旧路径对债券标的应取不到价(这正是期权到期报'结算价未找到'的根因)");
Assert.IsTrue(newOk && ep != null && ep.ClosePrice > 0,
"修复验证:统一取价应能为债券标的取到结算价,期权到期不再报'结算价未找到'");
}
}
}
@@ -0,0 +1,221 @@
using YLErp;
namespace YLErp.Modules.EodModule
{
/// <summary>
/// 日终价格管理 —— 纯单元测试(不连库、秒级)。
/// 锁定两处改动的意图:
/// 问题4:列表"标的种类"按真实类型显示,且路由键 UnderlyingInstrumentType 不变;
/// 问题3:债券(china_bond_valuation)数据来源按是否手工改过区分"人工"/"系统"。
/// </summary>
[TestClass]
public class EodPriceDtoTest
{
#region 4"商品期货"
[TestMethod]
[Description("有真实类型时,标的种类按真实类型显示,而非硬编码'商品期货'")]
public void _按真实类型显示_而非商品期货()
{
// 模拟从 eod_commodity_future_price 出来、但真实是现券的一行
var dto = new EodUnderlyingPriceDto
{
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, // 路由键(旧硬编码值)
RealInstrumentType = ConsGlobal.InstrumentType.CreditBonds // 真实类型=信用债
};
Assert.AreEqual("信用债", dto.UnderlyingInstrumentTypeCn, "显示应走真实类型");
Assert.AreNotEqual("商品期货", dto.UnderlyingInstrumentTypeCn, "不应再一律显示商品期货");
}
[TestMethod]
[Description("贵金属现货从商品期货表出来,也应显示真实种类")]
public void _显示黄金现货_而非商品期货()
{
var dto = new EodUnderlyingPriceDto
{
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures,
RealInstrumentType = ConsGlobal.InstrumentType.GoldSpot
};
Assert.AreEqual("黄金现货", dto.UnderlyingInstrumentTypeCn);
Assert.AreNotEqual("商品期货", dto.UnderlyingInstrumentTypeCn);
}
[TestMethod]
[Description("真正的商品期货,真实类型=CommodityFutures,仍显示商品期货")]
public void _仍显示商品期货()
{
var dto = new EodUnderlyingPriceDto
{
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures,
RealInstrumentType = ConsGlobal.InstrumentType.CommodityFutures
};
Assert.AreEqual("商品期货", dto.UnderlyingInstrumentTypeCn);
}
[TestMethod]
[Description("RealInstrumentType 为空时,回退到路由键,保证 null 安全不崩")]
public void _回退到路由键()
{
var dto = new EodUnderlyingPriceDto
{
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures,
RealInstrumentType = null
};
Assert.AreEqual("商品期货", dto.UnderlyingInstrumentTypeCn, "?? 回退应等于路由键的中文");
}
[TestMethod]
[Description("路由键 UnderlyingInstrumentType 不受显示改动影响(保证'查看'不串表)")]
public void _保证查看不串表()
{
var dto = new EodUnderlyingPriceDto
{
UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures,
RealInstrumentType = ConsGlobal.InstrumentType.TBonds
};
// 显示变了,但路由键仍是 CommodityFutures → EodPriceView 仍会去 eod_commodity_future_price 取数
Assert.AreEqual("利率债", dto.UnderlyingInstrumentTypeCn);
Assert.AreEqual(ConsGlobal.InstrumentType.CommodityFutures, dto.UnderlyingInstrumentType);
}
#endregion
#region 3"人工"/"系统"
[TestMethod]
[Description("债券来源:被手工改过(update_user 有值)→人工,中债自动同步(update_user 为 NULL)→系统")]
public void _手工改过为人工_否则为系统()
{
Assert.AreEqual(EodPriceBase., EodPriceService.ResolveBondDisplaySource(1024L));
Assert.AreEqual(EodPriceBase., EodPriceService.ResolveBondDisplaySource(null));
}
[TestMethod]
[Description("来源常量应为约定的中文'人工'/'系统'")]
public void ()
{
Assert.AreEqual("系统", EodPriceBase.);
Assert.AreEqual("人工", EodPriceBase.);
}
#endregion
#region ()
[TestMethod]
[Description("新增债券估值:create_user 与 update_user 都写入当前登录用户ID")]
public void _写入创建人与更新人()
{
var m = new ChinaBondValuation();
EodPriceService.StampBondOperator(m, 1024, isNew: true);
Assert.AreEqual(1024L, m.create_user, "新增时应写创建人");
Assert.AreEqual(1024L, m.update_user, "新增时应写更新人");
}
[TestMethod]
[Description("更新已有债券估值:仅更新 update_user,保留原 create_user(不覆盖创建人)")]
public void _仅写更新人_保留创建人()
{
var m = new ChinaBondValuation { create_user = 7 };
EodPriceService.StampBondOperator(m, 1024, isNew: false);
Assert.AreEqual(7L, m.create_user, "更新时不应覆盖原创建人");
Assert.AreEqual(1024L, m.update_user, "更新人应为本次操作者");
}
[TestMethod]
[Description("聚源/中债自动同步(外部ETL)不调用 StampBondOperator,故 create_user/update_user 保持 NULL = 自动同步")]
public void _操作人列为NULL()
{
// 注意:SettlementPriceImportService 是"手工上传"入口(会戳操作人),不是自动同步。
// 真正的聚源/中债自动同步在外部 ETL(本仓库无代码),其写入不经 StampBondOperator。
var m = new ChinaBondValuation(); // 模拟自动同步:仅写价格字段,不戳操作人
Assert.IsNull(m.create_user);
Assert.IsNull(m.update_user);
}
[TestMethod]
[Description("手工上传(SettlementPriceImportService):新增行(id==0)应写 create_user+update_user,使来源列显示上传人")]
public void _写入创建人与更新人()
{
// 模拟上传债券新增分支:eodPrice.id 默认 0 → isNew=true
var m = new ChinaBondValuation();
EodPriceService.StampBondOperator(m, 2048, isNew: m.id == 0);
Assert.AreEqual(2048L, m.create_user, "上传新增应写创建人");
Assert.AreEqual(2048L, m.update_user, "上传新增应写更新人");
}
[TestMethod]
[Description("手工上传(SettlementPriceImportService):命中已有行(id!=0)只写 update_user,保留原 create_user")]
public void _仅写更新人_保留创建人()
{
// 模拟上传命中已有债券行:id!=0 → isNew=false
var m = new ChinaBondValuation { id = 55, create_user = 9 };
EodPriceService.StampBondOperator(m, 2048, isNew: m.id == 0);
Assert.AreEqual(9L, m.create_user, "上传更新不应覆盖原创建人");
Assert.AreEqual(2048L, m.update_user, "上传更新应写本次上传人");
}
#endregion
#region 3/"问题3"
#endregion
#region "页面始终5条"
[TestMethod]
[Description("前端未传日期(年份<=2000) → 回退到 [今天-1年, 今天+1年)")]
public void _回退最近一年到明年()
{
var (start, end) = EodPriceService.ResolveValueDateWindow(DateTime.MinValue, DateTime.MinValue);
Assert.AreEqual(DateTime.Today.AddYears(-1).Date, start.Date, "起始应回退到今天-1年");
Assert.AreEqual(DateTime.Today.AddYears(1).Date, end.Date, "结束应回退到今天+1年");
Assert.IsTrue(end > start, "窗口应正向");
}
[TestMethod]
[Description("列表页默认起止都填今天 → 窗口=[今天, 今天+1天),仅返回当天记录(即'5条'现象成因)")]
public void _窗口仅今天()
{
var today = DateTime.Today;
var (start, end) = EodPriceService.ResolveValueDateWindow(today, today);
Assert.AreEqual(today.Date, start.Date, "起始应为今天");
Assert.AreEqual(today.AddDays(1).Date, end.Date, "结束应为今天+1天(半开区间含今天)");
Assert.IsTrue(today >= start && today < end, "今天的记录应落入窗口");
Assert.IsFalse(today.AddDays(-1) >= start && today.AddDays(-1) < end, "昨天的记录不应落入仅今天窗口");
Assert.IsFalse(today.AddDays(1) >= start && today.AddDays(1) < end, "明天的记录不应落入仅今天窗口");
}
[TestMethod]
[Description("显式传区间(如近30天) → 原样生效,不被回退覆盖")]
public void _原样生效()
{
var start0 = DateTime.Today.AddDays(-30);
var end0 = DateTime.Today;
var (start, end) = EodPriceService.ResolveValueDateWindow(start0, end0);
Assert.AreEqual(start0.Date, start.Date, "起始应等于传入");
Assert.AreEqual(end0.AddDays(1).Date, end.Date, "结束应等于传入+1天");
}
[TestMethod]
[Description("结束日=今天 → 半开区间上界=今天+1天,今天当天记录可命中")]
public void _上界为明天_当天可命中()
{
var (start, end) = EodPriceService.ResolveValueDateWindow(DateTime.Today.AddDays(-365), DateTime.Today);
var today = DateTime.Today;
Assert.IsTrue(today >= start && today < end, "今天记录应命中");
Assert.IsFalse(today.AddDays(1) >= start && today.AddDays(1) < end, "明天记录不应命中");
}
#endregion
}
}
@@ -0,0 +1,264 @@
using Newtonsoft.Json;
using YLErp;
namespace YLErp.Modules.EodModule
{
#region Golden
/// <summary>
/// 日终价格"标的种类 + 数据来源"golden 场景模型。
/// 每个 JSON 文件存:一组原始输入行 + 每行的期望输出(种类中文/来源/路由键)。
/// 结构与 SwapModule 的 GoldenScenarioModel 对齐(Scenario/Description/Source + Rows)。
/// </summary>
public class EodPriceGoldenModel
{
public string Scenario { get; set; }
public string Description { get; set; }
/// <summary>synthetic(合成 Mock) / recorded(真实库录制)</summary>
public string Source { get; set; } = "synthetic";
public DateTime? RecordedAt { get; set; }
public List<EodPriceGoldenRow> Rows { get; set; } = new();
}
public class EodPriceGoldenRow
{
public string UnderlyingCode { get; set; }
/// <summary>存储表路由键 = DTO.UnderlyingInstrumentTypeEodPriceView 靠它选表)</summary>
public string RouteKey { get; set; }
/// <summary>真实标的种类 = underlying_manager.UnderlyingInstrumentType</summary>
public string RealInstrumentType { get; set; }
public bool IsBond { get; set; }
/// <summary>期望的"标的种类"列显示值</summary>
public string ExpectedTypeCn { get; set; }
/// <summary>期望的"数据来源"(仅债券行断言)</summary>
public string ExpectedDataSource { get; set; }
}
#endregion
/// <summary>
/// 日终价格 Golden 回放测试
/// ============================================================================
/// 仿 SwapModule/DealInterestsGoldenReplayTest
/// - Record_* :连真实库拉数据生成 golden JSON(标 [Ignore],手动跑)
/// - Replay_* :读 Mock/录制 JSON 重放并逐行断言(进 CI,不碰库)
///
/// 守护点(回放时任何一行不符即失败):
/// 1. 标的种类按真实类型显示(现券→信用债、贵金属→黄金现货…),不再一律"商品期货";
/// 2. 路由键 UnderlyingInstrumentType 保持不变(保证"查看"不串表);
/// 3. 债券数据来源固定为中债估值(聚源仅转发,无人手工维护,不随 JSID 变化)。
/// ============================================================================
/// </summary>
[TestClass]
public class EodPriceGoldenReplayTest
{
private static readonly string GoldenDir = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "EodPriceGolden");
#region golden + CI
[TestMethod]
public void Replay_AllGoldenFiles()
{
if (!Directory.Exists(GoldenDir))
{
Assert.Inconclusive($"golden 目录不存在: {GoldenDir}");
return;
}
var files = Directory.GetFiles(GoldenDir, "*.json").OrderBy(f => f).ToArray();
Assert.IsTrue(files.Length > 0, "应至少有 1 个 golden 文件");
int rowsChecked = 0;
foreach (var file in files)
{
var golden = JsonConvert.DeserializeObject<EodPriceGoldenModel>(File.ReadAllText(file));
Console.WriteLine($"\n回放: {Path.GetFileName(file)} - {golden.Scenario} [{golden.Source}]");
foreach (var row in golden.Rows)
{
// 用原始输入重建 DTO(等价于 SearchUnderlyingList 的投影结果)
var dto = new EodUnderlyingPriceDto
{
UnderlyingCode = row.UnderlyingCode,
UnderlyingInstrumentType = row.RouteKey, // 路由键
RealInstrumentType = row.RealInstrumentType, // 真实类型
IsBond = row.IsBond
};
// 债券来源:自动同步(中债)→系统(等价 SearchUnderlyingList 后处理赋值;synthetic 无 UpdateUser 故为系统)
if (dto.IsBond)
{
dto.DataSource = EodPriceBase.;
}
// 守护点1:显示按真实类型
Assert.AreEqual(row.ExpectedTypeCn, dto.UnderlyingInstrumentTypeCn,
$"[{row.UnderlyingCode}] 标的种类显示不符");
// 守护点2:路由键不变
Assert.AreEqual(row.RouteKey, dto.UnderlyingInstrumentType,
$"[{row.UnderlyingCode}] 路由键被改动,会导致查看串表");
// 守护点3:债券来源
if (row.IsBond)
{
Assert.AreEqual(row.ExpectedDataSource, dto.DataSource,
$"[{row.UnderlyingCode}] 债券数据来源判定不符");
}
rowsChecked++;
Console.WriteLine($" ✅ {row.UnderlyingCode}: {dto.UnderlyingInstrumentTypeCn}" +
(row.IsBond ? $" / {dto.DataSource}" : ""));
}
}
Console.WriteLine($"\n回放完成,共校验 {rowsChecked} 行");
Assert.IsTrue(rowsChecked > 0, "至少应校验 1 行");
}
#endregion
#region golden [Ignore]
/// <summary>
/// 从真实库拉一批 underlying_manager + china_bond_valuation
/// 按当前生产逻辑生成 recorded golden JSON。
/// 手动取消 [Ignore] 运行;生成后复制到 Resources/GoldenFiles/EodPriceGolden/ 持久化。
/// </summary>
[TestMethod]
[Ignore]
[TestCategory("GoldenRecord")]
public void Record_FromRealDb()
{
Directory.CreateDirectory(GoldenDir);
var golden = new EodPriceGoldenModel
{
Scenario = "标的种类与来源(真实库录制)",
Description = "从 underlying_manager/china_bond_valuation 采样,快照当前生产映射",
Source = "recorded",
RecordedAt = DateTime.Now
};
using (var db = DbContextFactory.GetYLDbContext())
{
// 采样若干上线标的(含真实类型)
var uns = db.underlying_manager
.Where(x => x.LaunchState == "1")
.Select(x => new { x.UnderlyingCode, x.UnderlyingInstrumentType })
.Take(30).ToList();
// 债券估值采样(来源:自动同步→系统,手工改过→人工)
var bonds = db.china_bond_valuation
.Select(b => new { b.bond_id })
.Take(200).ToList();
var bondCodes = new HashSet<string>(bonds.Select(b => b.bond_id));
foreach (var un in uns)
{
bool isBond = bondCodes.Contains(un.UnderlyingCode);
// 路由键:债券走真实类型,其余按来源表默认(这里录制以真实类型近似,
// 因为 recorded 主要用于快照真实分布;CI 用 synthetic 覆盖精确路由)。
string routeKey = isBond
? un.UnderlyingInstrumentType
: ConsGlobal.InstrumentType.CommodityFutures;
golden.Rows.Add(new EodPriceGoldenRow
{
UnderlyingCode = un.UnderlyingCode,
RouteKey = routeKey,
RealInstrumentType = un.UnderlyingInstrumentType,
IsBond = isBond,
ExpectedTypeCn = ConsGlobal.InstrumentType.GetDesc(un.UnderlyingInstrumentType),
ExpectedDataSource = isBond ? EodPriceBase. : null
});
}
}
var path = Path.Combine(GoldenDir, "golden_标的种类与来源_recorded.json");
File.WriteAllText(path, JsonConvert.SerializeObject(golden, Formatting.Indented));
Console.WriteLine($"✅ 录制 {golden.Rows.Count} 行 -> {path}");
}
#endregion
#region [Ignore]
/// <summary>
/// 回归"新增日终价格后是否查得出",直接跑生产查询 SearchUnderlyingList。
/// 守护点(与之前"新增后查不出"的修复一一对应):
/// (a) 今天 + 已上市(LaunchState=1) 标的 → 查得出;
/// (b) 估值日期=0001(未填) → 落在列表默认"仅今天"窗口外 → 查不出;
/// (c) 标的未上市(LaunchState!=1) → 被 inner join(underlying_manager.LaunchState=="1") 过滤 → 查不出。
/// 复用库中已有标的(不新建 underlying_manager,避免触碰该表约束),只插入/清理临时债券估值行。
/// </summary>
[TestMethod]
[Ignore]
[TestCategory("EodVisibility")]
[Description("新增日终价格可见性:(a)今天+已上市可查 (b)日期0001查不出 (c)未上市查不出")]
public void Record_NewRecordVisibility()
{
using (var db = DbContextFactory.GetYLDbContext())
{
var svc = new EodPriceService(OptUserInfo.SystemUser);
var today = DateTime.Today;
var req = new EodCommodityFuturePriceReq { ValueDateStart = today, ValueDateEnd = today };
// 取一个已上市的债券类标的(正向用例);退而求其次取任意已上市标的
var listedBond = db.underlying_manager
.FirstOrDefault(x => x.LaunchState == "1" && x.UnderlyingInstrumentType == ConsGlobal.InstrumentType.CreditBonds)
?? db.underlying_manager.FirstOrDefault(x => x.LaunchState == "1");
Assert.IsNotNull(listedBond, "需存在一个 LaunchState=1 的标的用于正向回归");
// 取一个未上市的标的(负向用例)
var unlisted = db.underlying_manager.FirstOrDefault(x => x.LaunchState != "1");
Assert.IsNotNull(unlisted, "需存在一个 LaunchState!=1 的标的用于负向回归");
var insertedIds = new List<long>();
try
{
// (a) 今天 + 已上市 → 查得出
var a = new ChinaBondValuation { bond_id = listedBond.UnderlyingCode, valuation_date = today, dirty_price_close = 100, net_price = 100, yield = 3 };
db.china_bond_valuation.Add(a);
db.SaveChanges();
insertedIds.Add(a.id);
var rA = svc.SearchUnderlyingList(req);
Assert.IsTrue(rA.rows.Any(x => x.id == a.id), "(a) 今天+已上市债券应查得出");
// (b) 日期=0001(未填) → 落在仅今天窗口外,查不出
var b = new ChinaBondValuation { bond_id = listedBond.UnderlyingCode, valuation_date = DateTime.MinValue, dirty_price_close = 100, net_price = 100, yield = 3 };
db.china_bond_valuation.Add(b);
db.SaveChanges();
insertedIds.Add(b.id);
var rB = svc.SearchUnderlyingList(req);
Assert.IsFalse(rB.rows.Any(x => x.id == b.id), "(b) 日期0001 应查不出");
// (c) 未上市标的 → 被 inner join 过滤,查不出
var c = new ChinaBondValuation { bond_id = unlisted.UnderlyingCode, valuation_date = today, dirty_price_close = 100, net_price = 100, yield = 3 };
db.china_bond_valuation.Add(c);
db.SaveChanges();
insertedIds.Add(c.id);
var rC = svc.SearchUnderlyingList(req);
Assert.IsFalse(rC.rows.Any(x => x.id == c.id), "(c) 未上市标的应查不出");
}
finally
{
foreach (var id in insertedIds)
{
var e = db.china_bond_valuation.Find(id);
if (e != null) db.china_bond_valuation.Remove(e);
}
db.SaveChanges();
}
}
}
#endregion
}
}
@@ -0,0 +1,88 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.DBModels;
namespace YLErp.Modules.EodModule
{
/// <summary>
/// FR007 错行根因的校正决策单测(GLMS-20260701)。
/// 对应最近提交的 bugfixeod_commodity_future_price 入库前以 UnderlyingCode(=FutureContractId) 为准
/// 重派生 UnderlyingId,防止 UnderlyingId 与 FutureContractId 失同步导致"网页能查到、EOD 结算查不到"。
/// 这里只测纯函数 ResolveUnderlyingIdForCode,不依赖数据库。
///
/// 生产事故还原:FR007 价格行的 FutureContractId='FR007',但 UnderlyingId 被错写成
/// 511160.SH 的 2173889 / 159111.SZ 的 2173890,正确应为 FR007 的 2170838。
/// 网页端按 UnderlyingId(int) JOIN underlying_manager 把 FR007 行误挂到 511160.SH
/// 而 EOD 结算按 UnderlyingCode(string) JOIN 查不到,报"结算价格缺失"。
/// </summary>
[TestClass]
public class EodPriceUnderlyingIdGuardTest
{
private const int Fr007CorrectId = 2170838;
private const int Id511160 = 2173889; // 511160.SH 的 id(被错写)
private const int Id159111 = 2173890; // 159111.SZ 的 id(被错写)
[TestMethod]
public void UnderlyingCode_维持原值()
{
Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode("", 123, null));
Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode(null, 123, 999));
Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode(" ", 123, 999));
}
[TestMethod]
public void _维持原值()
{
// resolvedId=null 表示 underlying_manager 无此代码,无法校正
Assert.AreEqual(Id511160,
EodPriceService.ResolveUnderlyingIdForCode("FR007", Id511160, null));
}
[TestMethod]
public void _维持原值()
{
Assert.AreEqual(Fr007CorrectId,
EodPriceService.ResolveUnderlyingIdForCode("FR007", Fr007CorrectId, Fr007CorrectId));
}
[TestMethod]
public void _校正为正确id_FR007生产错行_511160()
{
// 生产事故:FR007 行 UnderlyingId=2173889(511160.SH) → 应校正为 2170838
Assert.AreEqual(Fr007CorrectId,
EodPriceService.ResolveUnderlyingIdForCode("FR007", Id511160, Fr007CorrectId));
}
[TestMethod]
public void _校正为正确id_FR007生产错行_159111()
{
// 生产事故:另两条错行 UnderlyingId=2173890(159111.SZ) → 应校正为 2170838
Assert.AreEqual(Fr007CorrectId,
EodPriceService.ResolveUnderlyingIdForCode("FR007", Id159111, Fr007CorrectId));
}
[TestMethod]
public void _端到端校正_FR007()
{
var row = new eod_commodity_future_price
{
UnderlyingCode = "FR007",
UnderlyingId = Id511160
};
// 模拟 db.underlying_manager 解析到的正确 id
int resolved = Fr007CorrectId;
int? before = row.UnderlyingId;
row.UnderlyingId = EodPriceService.ResolveUnderlyingIdForCode(row.UnderlyingCode, row.UnderlyingId ?? 0, resolved);
Assert.AreNotEqual(before, row.UnderlyingId);
Assert.AreEqual((int?)Fr007CorrectId, row.UnderlyingId);
}
[TestMethod]
public void _不同标的_各自正确不互相覆盖()
{
// 511160.SH 自己的行(FutureContractId='511160.SH'),UnderlyingId 已是 2173889 → 不动
Assert.AreEqual(Id511160,
EodPriceService.ResolveUnderlyingIdForCode("511160.SH", Id511160, Id511160));
}
}
}
@@ -0,0 +1,180 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GLMS-20260701-0006 多次部分平仓后提前终止 Tab 序号2 平仓比例显示 32.50% 而非 50% 的回归测试
/// ================================================================================
/// 根因:SwapDealService.ApplySwapTrade 缺少 A→B 口径转换。
/// - SwapUnwind 在入口处将 ClosePercent 从口径A(占期初) 转为 口径B(占剩余)SaveSwapDealInternal 落库时 B→A 还原。
/// - ApplySwapTrade 没有做 A→B 转换,导致 SaveSwapDealInternal 的 B→A 还原出错:
/// 0.50(A) → ToOriginalClosePercent(0.50, 50000000, 32500000) = 0.50*32500000/50000000 = 0.325 ❌
/// - 修复后:0.50(A) → ToRemainingClosePercent → 0.769(B) → ToOriginalClosePercent → 0.50(A) ✅
///
/// 测试策略:
/// 1) 纯函数测试:验证 A→B→A 往返转换的正确性
/// 2) ApplySwapTrade 集成测试:验证 SaveSwapDeal 收到的 ClosePercent 已转为口径B
/// </summary>
[TestClass]
public class ApplySwapTradeClosePercentBugTest
{
// GLMS-20260701-0006 真实数据
private const decimal OriginalNotional = 50_000_000m; // 期初名义本金
private const decimal RemainingAfter1st = 32_500_000m; // 首次平35%后剩余
private const decimal FirstClosePercent = 0.35m; // 第一次平仓比例(口径A)
private const decimal SecondClosePercent = 0.50m; // 第二次平仓比例(口径A, 用户输入50%)
// ================================================================
// 1) 纯函数:A→B→A 往返转换应还原原值
// ================================================================
[TestMethod]
public void AC_001_口径转换_往返A到B到A应还原原值()
{
// 第二次部分平仓: 用户输入 50%(口径A)
decimal closePercentA = SecondClosePercent;
// A → BApplySwapTrade/SwapUnwind 入口转换)
decimal closePercentB = SwapDealService.ToRemainingClosePercent(
closePercentA, OriginalNotional, RemainingAfter1st);
// B → ASaveSwapDealInternal 落库还原)
decimal closePercentA_restored = SwapDealService.ToOriginalClosePercent(
closePercentB, OriginalNotional, RemainingAfter1st);
SwapDealTestFactory.AssertDecimalEqual(closePercentA, closePercentA_restored, 1e-10m,
"A→B→A 往返转换应还原原值");
Console.WriteLine($"A={closePercentA}, B={closePercentB}, A_restored={closePercentA_restored}");
}
[TestMethod]
public void AC_002_口径转换_未修复时B到A会得到错误的0_325()
{
// 模拟 bugApplySwapTrade 未做 A→B 转换,直接把 A 传给 SaveSwapDealInternal 的 B→A 还原
decimal closePercentA = SecondClosePercent; // 0.50
// bug 路径:SaveSwapDealInternal 误把 A 当 B 做还原
decimal buggyResult = SwapDealService.ToOriginalClosePercent(
closePercentA, OriginalNotional, RemainingAfter1st);
// 0.50 * 32500000 / 50000000 = 0.325
SwapDealTestFactory.AssertDecimalEqual(0.325m, buggyResult, 1e-10m,
"bug 路径:0.50(A) 被误当 B 做还原 → 0.325");
Assert.AreNotEqual(SecondClosePercent, buggyResult,
"bug 结果 0.325 不等于用户输入 0.50");
Console.WriteLine($"Bug: 0.50(A) 误当 B → ToOriginalClosePercent → {buggyResult} (应为 0.50)");
}
// ================================================================
// 2) ApplySwapTrade 集成测试:验证 SaveSwapDeal 收到的是口径B
// ================================================================
[TestMethod]
public void AC_003_ApplySwapTrade_第二次部分平仓50perc_应将ClosePercent转为口径B()
{
// 模拟 GLMS-20260701-0006 第二次部分平仓的场景
var td = new trade
{
id = 1991,
TradeNumber = "GLMS-20260701-0006",
TradeType = "收益互换",
TradeStatus = "确认成交",
ValidState = "Valid",
StockEqvNotional = (double)RemainingAfter1st, // 32500000
OriginalStockEqvNotional = (double)OriginalNotional, // 50000000
Notional = (double)RemainingAfter1st,
TradeAmount = (double)RemainingAfter1st
};
var service = new TestableSwapDealService(td);
// 前端传入的 UnwindDataClosePercent = 0.50, 口径A
var unwindData = new UnwindData
{
SwapTradeId = td.id,
SwapRealizedPnL = 1000m,
SwapCloseAmount = 1000m,
CloseMethod = (int)CloseMethodEnum.,
ClosePercent = SecondClosePercent, // 0.50 (口径A, 用户输入50%)
CloseQty = 25000000m,
CloseNotionalValue = 25000000m, // 50% of original
PositionQty = RemainingAfter1st, // 32500000
NotionalValue = OriginalNotional, // 50000000 (期初)
PosiNotionalValue = RemainingAfter1st, // 32500000 (剩余)
ValueDate = new DateTime(2026, 7, 14),
UnwindDate = new DateTime(2026, 7, 15),
StartDate = new DateTime(2026, 7, 1)
};
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.);
// 验证 SaveSwapDeal 被调用
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "ApplySwapTrade 应调用 SaveSwapDeal");
// 验证传给 SaveSwapDeal 的 ClosePercent 已转为口径B
var savedData = service.SaveSwapDealCalls[0].data;
decimal expectedB = SwapDealService.ToRemainingClosePercent(
SecondClosePercent, OriginalNotional, RemainingAfter1st);
SwapDealTestFactory.AssertDecimalEqual(expectedB, savedData.ClosePercent, 1e-10m,
"ApplySwapTrade 应将 ClosePercent 从口径A转为口径B");
// 关键验证:B 值不应等于 A 值(0.50),也不应等于 bug 值(0.325)
Assert.AreNotEqual(SecondClosePercent, savedData.ClosePercent,
"口径B 不应等于口径A (0.50)");
Assert.AreNotEqual(0.325m, savedData.ClosePercent,
"口径B 不应等于 bug 值 (0.325)");
Console.WriteLine($"输入: ClosePercent(A)={SecondClosePercent}");
Console.WriteLine($"输出: ClosePercent(B)={savedData.ClosePercent}");
Console.WriteLine($"期望: ClosePercent(B)={expectedB}");
Console.WriteLine($"往返还原: ClosePercent(A)={SwapDealService.ToOriginalClosePercent(savedData.ClosePercent, OriginalNotional, RemainingAfter1st)}");
}
[TestMethod]
public void AC_004_ApplySwapTrade_第一次平仓35perc_口径转换正确()
{
// 第一次平仓:remaining = original, 所以 A = B = 0.35
var td = new trade
{
id = 1991,
TradeNumber = "GLMS-20260701-0006",
TradeType = "收益互换",
TradeStatus = "确认成交",
ValidState = "Valid",
StockEqvNotional = (double)OriginalNotional,
OriginalStockEqvNotional = (double)OriginalNotional,
Notional = (double)OriginalNotional,
TradeAmount = (double)OriginalNotional
};
var service = new TestableSwapDealService(td);
var unwindData = new UnwindData
{
SwapTradeId = td.id,
SwapRealizedPnL = 1000m,
SwapCloseAmount = 1000m,
CloseMethod = (int)CloseMethodEnum.,
ClosePercent = FirstClosePercent, // 0.35 (口径A)
CloseQty = 17500000m,
CloseNotionalValue = 17500000m,
PositionQty = OriginalNotional,
NotionalValue = OriginalNotional,
PosiNotionalValue = OriginalNotional, // 首次平仓 remaining == original
ValueDate = new DateTime(2026, 7, 6),
UnwindDate = new DateTime(2026, 7, 7),
StartDate = new DateTime(2026, 7, 1)
};
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.);
var savedData = service.SaveSwapDealCalls[0].data;
// 首次平仓 remaining == original → A == B == 0.35
SwapDealTestFactory.AssertDecimalEqual(FirstClosePercent, savedData.ClosePercent, 1e-10m,
"首次平仓 remaining==original → 口径A==口径B==0.35");
Console.WriteLine($"首次平仓: ClosePercent(A=B)={savedData.ClosePercent}");
}
}
}
@@ -378,6 +378,39 @@ namespace YLErp.Modules.SwapModule
Console.WriteLine($"分红增值税调整: 付息100, 税率6% → TdPosiDividend={result.TdPosiDividend}(期望{expected})✅");
}
[TestMethod]
public void DF_008_CopyBranch_RealizedPnlIncludesRealizedFee()
{
var service = new StubEodService
{
UnderlyingPrice = 1.002m,
TaxRate = 0m,
BondPayment = 0m
};
var preEod = new eod_swap_position
{
id = 5001,
SwapTradeId = SwapTradeId,
PositionId = 3001,
ValueDate = PreSettleDate,
PosiQuantity = 10000m,
PosiGrossPrice = 1.002m,
PosiNetPrice = 1.005m,
UnderlyingCode = "210210.IB",
ContractSize = 1m,
PositionType = (int)PositionTypeFlag.Long,
PosiDirection = (int)SwapDirectionEnum.,
RealizedMtmPnL = 98000000m,
RealizedDividend = 0m,
RealizedFee = 100m,
RealizedPnl = 98000000m
};
var result = service.ExecuteCopyEodPosition(preEod, null, CreateTrade(), TradeDate, PreSettleDate);
Assert.AreEqual(98000100m, result.RealizedPnl);
}
private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "")
{
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
@@ -156,9 +156,9 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// [FC_006] 结息-债券多头-全量结算(基线)
/// income 用 CloseNotionalValue 而非 CloseQty,无 longRatio
/// EntryPrice=1.02, TradingAmountAvg=105(×100形态), CloseNotionalValue=10000
/// MarkClosePnl = 10000×(105×0.011.02)×1 = 10000×0.03 = 300
/// income 使用持仓数量和合约乘数,无 longRatio
/// EntryPrice=1.02, TradingAmountAvg=105(×100形态), PositionQty=10000, ContractSize=1
/// MarkClosePnl = 10000×1×(105×0.011.02)×1 = 10000×0.03 = 300
/// </summary>
[TestMethod]
public void FC_006_结息_债券多头_全量结算()
@@ -166,7 +166,9 @@ namespace YLErp.Modules.SwapModule
var input = new UnwindInput
{
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m,
CloseNotionalValue = 10000, // income 用名义本金
PositionQty = 10000,
ContractSize = 1,
CloseNotionalValue = 10200, // 与数量刻意不同,守卫 income 不再误用名义本金
CloseQty = 0, // income 不用数量
PayDirection = 1, PositionType = 1,
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
@@ -188,7 +190,8 @@ namespace YLErp.Modules.SwapModule
var input = new UnwindInput
{
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 110m,
CloseNotionalValue = 10000, CloseQty = 0,
PositionQty = 10000, ContractSize = 1,
CloseNotionalValue = 10200, CloseQty = 0,
PayDirection = 1, PositionType = 1,
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
};
@@ -209,7 +212,8 @@ namespace YLErp.Modules.SwapModule
var input = new UnwindInput
{
Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m,
CloseNotionalValue = 10000, CloseQty = 0,
PositionQty = 10000, ContractSize = 1,
CloseNotionalValue = 10200, CloseQty = 0,
PayDirection = 1, PositionType = 1,
TradingFee = "0", TradingFeePending = "0", DividendIn = "0"
};
@@ -225,6 +229,35 @@ namespace YLErp.Modules.SwapModule
Console.WriteLine($"FC_008: SwapRealizedPnL={result.SwapRealizedPnL}, SwapMarginRebatePnl={result.SwapMarginRebatePnl} ✅");
}
/// <summary>
/// [FC_009] 结息-债券支付端:价差盈亏必须按数量计算,不能按期初名义本金计算。
/// 纯价差 = 30000000×1×(80%98%)×(1) = 5400000;加分红-45000后合计5355000。
/// </summary>
[TestMethod]
public void FC_009_结息_债券价差按数量计算()
{
var input = new UnwindInput
{
Multiplier = 100,
PosiGrossPrice = 0.98m,
TradingAmountAvg = 80m,
PositionQty = 30000000m,
ContractSize = 1m,
CloseNotionalValue = 29400000m,
CloseQty = 0m,
PayDirection = 2,
PositionType = 1,
TradingFee = "0",
TradingFeePending = "0",
DividendIn = "-45000"
};
var result = FrontendCalcReference.CalcIncome(input);
AssertDecimalEqual(5400000m, result.MarkClosePnl, 0.01m, "income MarkClosePnl按数量计算");
AssertDecimalEqual(5355000m, result.FloatPnlSum, 0.01m, "income FloatPnlSum包含分红");
}
private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "")
{
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
@@ -0,0 +1,361 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 线上事故诊断:GLMS-20260701-0008 多次部分平仓后,预付金返还显示仍为原始值
/// ============================================================================
/// 直连测试库,录制真实数据快照并定位根因(DB 端还是计算端)。
/// 测试结构:
/// 1) RecordSnapshot - 录 trade/position/eod_swap_position/eod_swap/flow_event
/// 2) Diagnose - 把每次部分平仓前后 InterestPrincipalFix 实际值序列打印,
/// 验证是否双重扣减;并调用 GetUnwindInterests 1.0 看后端返还值
/// 3) 期望对比 - 多次部分平仓后,1.0 closePercent 应返"剩余本金"=已扣减后),
/// 若仍返原始值 ⇒ 后端 EOD 路径 bug (SaveAutoEodWithCloseInterestPosition 双重扣减)
/// </summary>
[TestClass]
public class GLMS20260701DbDiagnoseTest
{
private const string TradeNumber_0008 = "GLMS-20260701-0008";
private const string TradeNumber_0013 = "GLMS-20260701-0013";
#region 1)
[TestMethod]
[Ignore]
[TestCategory("DbDiagnose")]
public void Record_RealSnapshot()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber_0008);
Assert.IsNotNull(td, $"测试库无交易 {TradeNumber_0008},请确认环境");
var snapshot = new JObject
{
["TradeNumber"] = td.TradeNumber,
["TradeId"] = td.id,
["StockEqvNotional"] = td.StockEqvNotional,
["Notional"] = td.Notional,
["OriginalStockEqvNotional"] = td.OriginalStockEqvNotional,
["TradeDate"] = td.TradeDate,
["StartDate"] = td.StartDate,
["ExerciseDate"] = td.ExerciseDate
};
// 1.1 当前所有仓位(含 IsInitial=初始 + !IsInitial=已平后剩余)
var positions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
.ToList();
snapshot["Positions"] = JArray.FromObject(positions, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
// 1.2 EOD 持仓序列(关键:观察 InterestPrincipalFix 逐日变化)
var eodPositions = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid && e.InterestMode == 5 || e.InterestMode == 6)
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
.ToList();
snapshot["EodPositions_MarginLegOnly"] = JArray.FromObject(eodPositions, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
// 1.3 EOD 交易级(eod_swap.NotionalValue 应该是初始值不变)
var eodSwaps = db.eod_swap.Where(e => e.SwapTradeId == td.id).OrderBy(e => e.ValueDate).ToList();
snapshot["EodSwaps"] = JArray.FromObject(eodSwaps, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
// 1.4 所有 flow_event(看平仓/互换事件序列,及 InterestPrincipal 实际写入值)
var flows = db.swap_flow_event.Where(f => f.SwapTradeId == td.id).OrderBy(f => f.EventDate).ThenBy(f => f.id).ToList();
snapshot["FlowEvents"] = JArray.FromObject(flows, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260701");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"snapshot_{DateTime.Now:yyyyMMdd_HHmmss}.json");
File.WriteAllText(path, JsonConvert.SerializeObject(snapshot, Formatting.Indented,
new JsonSerializerSettings { DateFormatHandling = DateFormatHandling.IsoDateFormat }));
Console.WriteLine($"✅ 快照已保存: {path}");
}
#endregion
#region 2) "预付金腿" + API 1.0
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_InterestPrincipalFix_Progression_And_UnwindResult()
{
DiagnoseTrade(TradeNumber_0008);
}
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_0013_InterestPrincipalFix_Progression_And_UnwindResult()
{
DiagnoseTrade(TradeNumber_0013);
}
private void DiagnoseTrade(string tradeNumber)
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber);
if (td == null) { Assert.Inconclusive($"测试库无 {tradeNumber}"); return; }
// 2.1 预付金腿 position.InterestPrincipalFix 当前值(多次平仓后应该已被扣减)
var marginPositions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid
&& (p.InterestMode == (int)InterestModeEnum.
|| p.InterestMode == (int)InterestModeEnum.))
.ToList();
Console.WriteLine("============== 预付金腿 position 当前值(多次平仓后) ==============");
foreach (var p in marginPositions)
{
Console.WriteLine($"PositionId={p.id} Mode={p.InterestMode} Fix={p.InterestPrincipalFix} Rate={p.InterestRateDefault} Dir={p.InterestDirection} IsInitial={p.IsInitial}");
}
// 2.1b 所有 position 全景(含浮动腿),对比 IsInitial vs !IsInitial 的 PosiNotionalValue / Fix
var allPositions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
.ToList();
Console.WriteLine("\n============== 全部 position 全景(对比 IsInitial 原始 vs !IsInitial 剩余) ==============");
Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}");
foreach (var p in allPositions)
{
var ul = p.UnderlyingCode ?? "";
Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-7}{p.PosiDirection,-8}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}{ul,-15}");
}
// 2.1c 关键诊断:GetUnwindInterests 内部 origPositions vs realPostitions 差异
var origPositions = allPositions.Where(x => x.IsInitial).ToList();
var realPostitions = allPositions.Where(x => !x.IsInitial).ToList();
Console.WriteLine("\n============== GetUnwindInterests 关键源数据对比 ==============");
Console.WriteLine($" origPositions(IsInitial=True) 浮动腿 PosiNotionalValue 总和: {origPositions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)}");
Console.WriteLine($" realPostitions(IsInitial=False) 浮动腿 PosiNotionalValue 总和: {realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)} ← 应为剩余值");
Console.WriteLine($" origPositions(IsInitial=True) 预付金腿 Fix: {string.Join(",", origPositions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))}");
Console.WriteLine($" realPostitions(IsInitial=False) 预付金腿 Fix: {string.Join(",", realPostitions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))} ← 应为剩余值");
// 2.1d 关键诊断:realLeg.PositionId == origPos.id 匹配校验(修复后端 Clone 是否会触发)
Console.WriteLine("\n============== realLeg.PositionId ↔ origPos.id 匹配校验(决定 Clone 是否生效)==============");
foreach (var origPos in origPositions.Where(p => p.InterestMode == 5 || p.InterestMode == 6))
{
var realLeg = realPostitions.FirstOrDefault(r => r.PositionId == origPos.id);
Console.WriteLine($" origPos.id={origPos.id} Fix={origPos.InterestPrincipalFix} | realLeg found={(realLeg != null)} | realLeg.id={realLeg?.id} realLeg.PositionId={realLeg?.PositionId} realLeg.Fix={realLeg?.InterestPrincipalFix} | 需Clone={(realLeg != null && realLeg.InterestPrincipalFix != origPos.InterestPrincipalFix)}");
}
// 2.2 EOD 持仓 InterestPrincipalFix 逐日序列
var eodMarginSeq = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid
&& (e.InterestMode == (int)InterestModeEnum.
|| e.InterestMode == (int)InterestModeEnum.))
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
.ToList();
Console.WriteLine("============== EOD 预付金腿 InterestPrincipalFix 逐日变化 ==============");
foreach (var e in eodMarginSeq)
{
Console.WriteLine($" ValueDate={e.ValueDate:yyyy-MM-dd} PositionId={e.PositionId} Fix={e.InterestPrincipalFix} TdInterestPrincipal={e.TdInterestPrincipal} PosiStatus={e.PosiStatus} Invalid={e.Invalid}");
}
// 2.3 平仓事件序列(看 InterestPrincipal 实际入库值)
var closeFlows = db.swap_flow_event
.Where(f => f.SwapTradeId == td.id && f.EventType == (int)SwapEventTypeEnum.
&& f.DataState == (int)SwapFlowDateStateEnum.
&& (f.InterestMode == (int)InterestModeEnum.
|| f.InterestMode == (int)InterestModeEnum.))
.OrderBy(f => f.EventDate).ToList();
Console.WriteLine("============== 历史平仓事件-预付金腿 实际 InterestPrincipal 序列 ==============");
foreach (var f in closeFlows)
{
Console.WriteLine($" EventDate={f.EventDate:yyyy-MM-dd} PositionId={f.PositionId} InterestPrincipal={f.InterestPrincipal} InterestAmount={f.InterestAmount} Quantity={f.Quantity} TradingAmount={f.TradingAmount}");
}
// 2.3b 直接调 ResolveInterestLegPositions,验证 Clone 是否真的把 Fix 覆盖成 realLeg 值
var resolved = SwapDealService.ResolveInterestLegPositions(origPositions, realPostitions);
Console.WriteLine("\n============== ResolveInterestLegPositions 直接调用结果 ==============");
foreach (var rp in resolved.Where(x => x.InterestMode == 5 || x.InterestMode == 6))
{
Console.WriteLine($" resolved: id={rp.id} PositionId={rp.PositionId} Mode={rp.InterestMode} Fix={rp.InterestPrincipalFix} (期望=realLeg.Fix)");
}
// 2.3c 模拟前端调用 controller 完整流程:前端传 closePercent=0.7(占期初) + notionalValue/posiNotionalValue
// controller 调 ToRemainingClosePercent 转为占剩余,再调 GetUnwindInterests
// 等价于 HTTP POST /swaptrade2/GetUnwindInterestList
Console.WriteLine("\n============== 模拟 HTTP API 调用(前端 closePercent=0.7 占期初)==============");
decimal frontClosePercent = 0.7m;
decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初名义本金
decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余名义本金
Console.WriteLine($" 前端参数: closePercent={frontClosePercent} notionalValue={frontNotionalValue} posiNotionalValue={frontPosiNotionalValue}");
decimal convertedClosePercent = SwapDealService.ToRemainingClosePercent(frontClosePercent, frontNotionalValue, frontPosiNotionalValue);
Console.WriteLine($" ToRemainingClosePercent 转换后: closePercent={convertedClosePercent}(占剩余)");
var svc = new SwapDealService(new OptUserInfo(1, "UnitTest", OptUserFrom.UnitTest));
var apiInterests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, convertedClosePercent, (int)SwapEventTypeEnum.);
Console.WriteLine($" GetUnwindInterests 返回 {apiInterests.Count} 条,预付金腿:");
foreach (var ai in apiInterests.Where(x => x.InterestMode == (int)InterestModeEnum. || x.InterestMode == (int)InterestModeEnum.))
{
Console.WriteLine($" PositionId={ai.PositionId} Mode={ai.InterestMode} InterestPrincipal={ai.InterestPrincipal} InterestAmount={ai.InterestAmount}");
}
// 2.4 直调后端 GetUnwindInterests(closePercent=1.0) 看"按全部平仓应返"的预付金值
try
{
var user = new OptUserInfo(0, nameof(GLMS20260701DbDiagnoseTest), OptUserFrom.UnitTest);
var svcFull = new SwapDealService(user);
var interests = svcFull.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.);
Console.WriteLine("============== 后端 GetUnwindInterests(1.0) 实际返回值-预付金腿 ==============");
foreach (var it in interests.Where(i => i.InterestMode == 5 || i.InterestMode == 6))
{
Console.WriteLine($" PositionId={it.PositionId} Mode={it.InterestMode} InterestPrincipal={it.InterestPrincipal} InterestAmount={it.InterestAmount} InterestRate={it.InterestRate}");
}
// 诊断断言:1.0 全平应返 = realPostitions(剩余持仓)的 InterestPrincipalFix
// 后端为保持 eod_swap_position.PositionId 日终归档对齐,返回的 PositionId 仍是 origPositions.id
// 但 InterestPrincipal 应等于 realPostitions[real.PositionId == orig.id].Fix(剩余值)。
// 因此对比口径:apiRet.InterestPrincipal vs realLeg.Fix(剩余值),不是 vs origPos.Fix(原始值)。
Console.WriteLine("============== 修复验证(apiRet.InterestPrincipal vs realLeg.Fix 剩余值)==============");
int okCount = 0, badCount = 0;
foreach (var origPos in marginPositions.Where(p => p.IsInitial))
{
var apiRet = interests.FirstOrDefault(i => i.PositionId == origPos.id);
if (apiRet == null) { Console.WriteLine($" ⚠ PositionId={origPos.id} 后端未返回"); continue; }
var realLeg = marginPositions.FirstOrDefault(p => !p.IsInitial && p.PositionId == origPos.id);
decimal expectedFix = realLeg?.InterestPrincipalFix ?? origPos.InterestPrincipalFix;
var diff = Math.Abs((double)(apiRet.InterestPrincipal - expectedFix));
bool ok = diff < 0.01;
if (ok) okCount++; else badCount++;
Console.WriteLine($" {(ok ? "" : "")} PositionId={origPos.id}(origFix={origPos.InterestPrincipalFix}) → realLeg.Fix={expectedFix} 后端返={apiRet.InterestPrincipal} 差={diff:F4}");
}
Console.WriteLine($"\n 结论:通过 {okCount} 条 / 失败 {badCount} 条");
Assert.IsTrue(badCount == 0, $"修复未生效:{badCount} 条预付金腿后端返还值 ≠ realLeg.Fix 剩余值");
}
catch (Exception ex)
{
Console.WriteLine($"⚠ GetUnwindInterests 调用失败:{ex.Message}");
}
}
#endregion
#region 3) GLMS-20260701-0006 32.50% 50%
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_0006_UnwindPercentRate_Display()
{
const string tradeNumber = "GLMS-20260701-0006";
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber);
if (td == null) { Assert.Inconclusive($"测试库无 {tradeNumber}"); return; }
Console.WriteLine($"===== 交易 {tradeNumber} (id={td.id}) =====");
Console.WriteLine($" TradeType: {td.TradeType}");
Console.WriteLine($" TradeStatus: {td.TradeStatus}");
Console.WriteLine($" StockEqvNotional (剩余): {td.StockEqvNotional}");
Console.WriteLine($" OriginalStockEqvNotional (期初): {td.OriginalStockEqvNotional}");
Console.WriteLine($" Notional: {td.Notional}");
Console.WriteLine($" OriginalNotional: {td.OriginalNotional}");
Console.WriteLine($" TradeAmount: {td.TradeAmount}");
Console.WriteLine($" HasPartialUnWind: {td.HasPartialUnWind}");
Console.WriteLine($" 剩余比例 = StockEqvNotional/Original = {td.StockEqvNotional / td.OriginalStockEqvNotional}");
Console.WriteLine();
Console.WriteLine($"===== trade_cash 记录 =====");
var tradeCashList = db.trade_cash
.Where(t => t.TradeId == td.id && !t.IsDeleted &&
(t.Action == "系统操作-平仓费" || t.Action == "系统操作-行权费"))
.OrderBy(t => t.ValueDate).ThenBy(t => t.id)
.ToList();
foreach (var tc in tradeCashList)
{
Console.WriteLine($" [id={tc.id}] ValueDate={tc.ValueDate:yyyy-MM-dd} Action={tc.Action}");
Console.WriteLine($" UnwindType: {tc.UnwindType}");
Console.WriteLine($" UnwindPercentRate: {tc.UnwindPercentRate} (=> {tc.UnwindPercentRate * 100}%)");
Console.WriteLine($" UnwindStockEqvNotional: {tc.UnwindStockEqvNotional}");
Console.WriteLine($" UnwindNotional: {tc.UnwindNotional}");
Console.WriteLine($" UnwindTradeAmount: {tc.UnwindTradeAmount}");
Console.WriteLine($" UnwindMethod: {tc.UnwindMethod}");
Console.WriteLine($" ValidState: {tc.ValidState}");
Console.WriteLine($" IsLastAction: {tc.IsLastAction}");
Console.WriteLine($" ExerciseWay: {tc.ExerciseWay}");
Console.WriteLine();
}
Console.WriteLine($"===== swap_event 记录 =====");
var swapEvents = db.swap_event
.Where(e => e.SwapTradeId == td.id && !e.Invalid)
.OrderBy(e => e.ValueDate).ThenBy(e => e.id)
.ToList();
foreach (var se in swapEvents)
{
Console.WriteLine($" [id={se.id}] ValueDate={se.ValueDate:yyyy-MM-dd} EventType={se.EventType}");
Console.WriteLine($" EventReason: {se.EventReason}");
Console.WriteLine($" ClientCashId: {se.ClientCashId}");
if (!string.IsNullOrEmpty(se.EventData))
{
try
{
var ud = JsonConvert.DeserializeObject<JObject>(se.EventData);
Console.WriteLine($" EventData.ClosePercent: {ud["ClosePercent"]}");
Console.WriteLine($" EventData.CloseNotionalValue: {ud["CloseNotionalValue"]}");
Console.WriteLine($" EventData.CloseQty: {ud["CloseQty"]}");
Console.WriteLine($" EventData.NotionalValue: {ud["NotionalValue"]}");
Console.WriteLine($" EventData.PosiNotionalValue: {ud["PosiNotionalValue"]}");
Console.WriteLine($" EventData.PositionQty: {ud["PositionQty"]}");
Console.WriteLine($" EventData.CloseMethod: {ud["CloseMethod"]}");
}
catch (Exception ex)
{
Console.WriteLine($" EventData parse error: {ex.Message}");
}
}
Console.WriteLine();
}
// 查询 swap_flow_event 记录
Console.WriteLine($"===== swap_flow_event 记录 =====");
var flowEvents = db.swap_flow_event
.Where(f => f.SwapTradeId == td.id)
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
.ToList();
foreach (var fe in flowEvents)
{
Console.WriteLine($" [id={fe.id}] EventDate={fe.EventDate:yyyy-MM-dd} EventType={fe.EventType}");
Console.WriteLine($" PositionId: {fe.PositionId}");
Console.WriteLine($" Quantity: {fe.Quantity}");
Console.WriteLine($" PositionQty: {fe.PositionQty}");
Console.WriteLine();
}
}
#endregion
}
}
@@ -0,0 +1,103 @@
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// InitUnwind 默认 ClosePercent 计算的回归测试。
/// ---------------------------------------------------------------
/// 守卫提交 e2fb456b "fix 平仓(InitUnwind L267)硬编码 1 而不是剩余平仓比例"。
///
/// 旧 bugInitUnwind 默认把 ClosePercent 硬编码为 1(按"占剩余 100%"),
/// 但前端约定 ClosePercent 是"占期初(original)"口径(A)1 表示平掉原始本金的 100%。
/// 多次部分平仓后剩余本金 < 期初本金,此时默认 1 在前端语义上意味着"还要平掉原始全部",
/// 与"平掉剩余全部"意图不符,且会触发后端 ToRemainingClosePercent 转换后 >1 被 cap 到 1
/// 表面看无差异但语义混乱,且若前端 / 事件展示直接用此值会出错。
///
/// 修复:ClosePercent = PosiNotionalValue / NotionalValue(占期初口径的"平剩余全部")。
/// 抽出为纯函数 CalcDefaultInitClosePercent 以支持无库单测。
/// </summary>
[TestClass]
public class InitUnwindDefaultClosePercentTest
{
// ================================================================
// 场景1:未平仓 PosiNotionalValue == NotionalValue → ClosePercent = 1
// ================================================================
[TestMethod]
public void _剩余等于期初_默认ClosePercent为1()
{
var result = SwapDealService.CalcDefaultInitClosePercent(
notionalValue: 1_000_000m, posiNotionalValue: 1_000_000m);
Assert.AreEqual(1m, result, "未平仓:默认应平 100%(占期初)");
}
// ================================================================
// 场景2:已平 30%(剩 70%)→ ClosePercent = 0.7
// ================================================================
[TestMethod]
public void 30_70_ClosePercent为0_7()
{
var result = SwapDealService.CalcDefaultInitClosePercent(
notionalValue: 1_000_000m, posiNotionalValue: 700_000m);
Assert.AreEqual(0.7m, result, 0.0001m, "已平 30% 剩 70%:默认 ClosePercent=0.7(占期初)");
}
// ================================================================
// 场景3:除零保护 NotionalValue = 0 → 返回 1(容错)
// ================================================================
[TestMethod]
public void _返回1_容错不除零()
{
var result = SwapDealService.CalcDefaultInitClosePercent(
notionalValue: 0m, posiNotionalValue: 100_000m);
Assert.AreEqual(1m, result, "期初本金为 0 时容错返回 1,不应抛除零异常");
}
// ================================================================
// 场景4GLMS-20260701-0013 真实快照(已平 2 次)
// 期初 NotionalValue = 980,000 / 剩余 PosiNotionalValue = 686,000.07
// 期望 ClosePercent ≈ 0.7686000.07/980000
// ================================================================
[TestMethod]
public void GLMS20260701_0013_已平两次_默认ClosePercent约为0_7()
{
var result = SwapDealService.CalcDefaultInitClosePercent(
notionalValue: 980_000m, posiNotionalValue: 686_000.07m);
// 686000.07 / 980000 = 0.700000071...
Assert.AreEqual(0.7m, result, 0.0001m,
"GLMS-20260701-0013 已平两次:默认 ClosePercent 应≈0.7(占期初),旧 bug 会硬编码 1");
}
// ================================================================
// 场景5:与 ToRemainingClosePercent 联动验证
// 前端拿 InitUnwind 返回的 A(占期初) 默认值,经 ToRemainingClosePercent 转 B(占剩余)
// 应恰好 = 1.0(因为"平剩余全部"在占剩余语义下就是 100%)
// ================================================================
[TestMethod]
public void A经ToRemainingClosePercent转B应为1_平剩余全部()
{
const decimal notionalValue = 1_000_000m;
const decimal posiNotionalValue = 600_000m; // 已平 40%,剩 60%
var defaultA = SwapDealService.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue);
var convertedB = SwapDealService.ToRemainingClosePercent(defaultA, notionalValue, posiNotionalValue);
Assert.AreEqual(0.6m, defaultA, 0.0001m, "占期初默认 A=0.6");
Assert.AreEqual(1.0m, convertedB, 0.0001m,
"A=0.6 经 ToRemainingClosePercent 转换 → B=1.0(占剩余 100% = 平剩余全部),此为占期初/占剩余双语义自洽的关键不变式");
}
// ================================================================
// 场景6:全平完(PosiNotionalValue=0)→ ClosePercent=0(边界,实际不会进 InitUnwind)
// ================================================================
[TestMethod]
public void _ClosePercent为零()
{
var result = SwapDealService.CalcDefaultInitClosePercent(
notionalValue: 1_000_000m, posiNotionalValue: 0m);
Assert.AreEqual(0m, result, "剩余本金为 0 时 ClosePercent=0(边界场景,实际全部平完不会再进 InitUnwind");
}
}
}
@@ -1,359 +0,0 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapDealService 手动结算(SwapIncome/SwapUnwind)内存单元测试
/// ============================================================================
/// 背景:SwapIncome/SwapUnwind 是写客户资金流水(ClientCashInCashOut)的核心入口,
/// 此前零单元测试(仅 DBRecording,CI 不跑)。本测试通过 7 个 virtual seam
/// 把 DB/事务/外部服务打桩,在纯内存下验证控制流、资金流水金额、持仓状态变更。
///
/// 命名规范说明(见《互换价格字段命名规范决策文档》):
/// 本测试引用现状字段(如 PosiGrossPrice/PosiNetPrice)时加对照注释,
/// 标明其真实含义与规范名,让测试可读、可作规范示范。
/// - PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice
/// - PosiNetPrice 现状名,实为"期初全价含费"(非净价!),规范名 EntryDirtyFeePrice
/// ============================================================================
[TestClass]
public class SwapDealSettlementTest
{
private const int SwapTradeId = 7700;
private static readonly DateTime ValueDate = new(2026, 6, 15);
private static readonly DateTime UnwindDate = new(2026, 6, 16);
#region Stub
/// <summary>
/// 继承 SwapDealServiceoverride 7 个 seam,把 DB/事务/外部服务替换为内存收集器。
/// 生产路径零改动(seam 生产实现 = 原逻辑),测试可纯内存运行。
/// </summary>
private sealed class StubDealService : SwapDealService
{
private readonly trade _trade;
private readonly Dictionary<int, swap_event> _swapEvents;
private readonly Dictionary<long, List<swap_flow_event>> _flowEventsByEventId;
public List<(double amount, string action, DateTime date)> ClientCashCalls = new();
public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls = new();
public int SaveAllChangesCount;
public int CloseReCheckCallCount;
public StubDealService(trade td,
Dictionary<int, swap_event> swapEvents = null,
Dictionary<long, List<swap_flow_event>> flowEventsByEventId = null)
: base(new OptUserInfo(0, nameof(SwapDealSettlementTest), OptUserFrom.UnitTest))
{
_trade = td;
_swapEvents = swapEvents ?? new Dictionary<int, swap_event>();
_flowEventsByEventId = flowEventsByEventId ?? new Dictionary<long, List<swap_flow_event>>();
}
protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null;
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
{
ClientCashCalls.Add((amount, action, valueDate));
return ClientCashCalls.Count; // 返回自增 id
}
// 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库
protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false)
{
SaveSwapDealCalls.Add((unwindData, eventType, clientCashId));
return SaveSwapDealCalls.Count; // 返回自增 eventId
}
// ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType
protected override swap_event FindSwapEvent(int tradeId, int eventType)
{
return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null;
}
// ApproveSwapTrade 查事件关联流水:从内存字典取
protected override List<swap_flow_event> FindFlowEventsByEventId(long eventId)
{
return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List<swap_flow_event>();
}
// ApplySwapTrade 的前置校验:计数,不实际执行
protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck)
{
CloseReCheckCallCount++;
}
protected override void SaveAllChanges() { SaveAllChangesCount++; }
protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行
protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作
protected override void TriggerRealtimeSwapPosition() { } // 空操作
}
#endregion
#region
private static trade CreateTrade()
{
return new trade
{
id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888,
TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5),
ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用)
TradeStatus = "确认成交", ValidState = "Valid",
Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000
};
}
/// <summary>构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用)</summary>
private static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m,
decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m,
decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m)
{
return new UnwindData
{
SwapTradeId = SwapTradeId,
SwapRealizedPnL = swapRealizedPnL,
SwapMarginRebatePnl = swapMarginRebatePnl,
SwapMarginAmount = swapMarginAmount,
SwapCloseAmount = swapRealizedPnL,
CloseMethod = closeMethod,
ClosePercent = closePercent,
CloseQty = closeQty,
CloseNotionalValue = closeNotionalValue,
PositionQty = positionQty,
ValueDate = ValueDate,
UnwindDate = UnwindDate,
StartDate = new DateTime(2026, 1, 5)
};
}
#endregion
// ================================================================
// SD_001SwapIncome 正常结息 —— 验证资金流水金额正确
// ================================================================
/// <summary>
/// [SD_001] SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000
/// ------------------------------------------------------------
/// 后端 SwapDealService.cs:1553 直接用前端传入的 SwapRealizedPnL 记账:
/// AddClientCash(td, -SwapRealizedPnL, 系统操作_互换, ValueDate)
/// 本测试锁定:资金流水金额 = -SwapRealizedPnL,事件类型 = 互换(3)。
/// </summary>
[TestMethod]
public void SD_001_SwapIncome_正常结息_资金流水金额正确()
{
var td = CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支
var service = new StubDealService(td);
var unwindData = CreateUnwindData(swapRealizedPnL: 1000m);
service.SwapIncome(unwindData);
Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)");
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL");
Assert.AreEqual(ClientCashInCashOut._互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换");
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
Assert.AreEqual((int)SwapEventTypeEnum., service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)");
Console.WriteLine($"SD_001 通过:资金流水金额={service.ClientCashCalls[0].amount},事件类型=互换 ✅");
}
// ================================================================
// SD_002SwapIncome 含预付金返息 —— 两条资金流水
// ================================================================
/// <summary>
/// [SD_002] SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200
/// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200
/// 后端 SwapDealService.cs:1556 条件:SwapMarginRebatePnl != 0 时追加预付金返息流水。
/// </summary>
[TestMethod]
public void SD_002_SwapIncome_含预付金返息_两条资金流水()
{
var td = CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
var service = new StubDealService(td);
var unwindData = CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m);
service.SwapIncome(unwindData);
Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)");
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额 -SwapRealizedPnL");
Assert.AreEqual(ClientCashInCashOut._互换, service.ClientCashCalls[0].action);
Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息 -SwapMarginRebatePnl");
Assert.AreEqual(ClientCashInCashOut._预付金返息, service.ClientCashCalls[1].action);
Console.WriteLine($"SD_002 通过:2条资金流水,互换={service.ClientCashCalls[0].amount},预付金返息={service.ClientCashCalls[1].amount} ✅");
}
// ================================================================
// SD_003SwapUnwind 全平仓 —— 持仓归零、资金流水、状态变更
// ================================================================
/// <summary>
/// [SD_003] SwapUnwind 全平仓:ClosePercent=1 → TradeStatus=已平仓、持仓扣减、资金流水正确
/// 后端 SwapDealService.cs SwapUnwind:全平时 TradeStatus=已平仓,StockEqvNotional/TradeAmount 扣减。
/// </summary>
[TestMethod]
public void SD_003_SwapUnwind_正常平仓_资金流水与持仓状态正确()
{
var td = CreateTrade();
var service = new StubDealService(td);
// 全平:ClosePercent=1, CloseQty=10000, CloseNotionalValue=1000000
var unwindData = CreateUnwindData(
swapRealizedPnL: 5000m, swapMarginAmount: 0m,
closeMethod: (int)CloseMethodEnum., closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
service.SwapUnwind(unwindData);
// 资金流水:平仓费 = -SwapRealizedPnL
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水");
Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL");
Assert.AreEqual(ClientCashInCashOut._平仓费, service.ClientCashCalls[0].action);
// 持仓状态
Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓");
// 全平仓走"已平仓"分支,不设 HasPartialUnWind(仅部分平仓才设=1
Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind(仅部分平仓设=1");
// 持仓扣减:原 StockEqvNotional=1000000 - CloseNotionalValue=1000000 = 0
Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0");
Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0");
// 事件类型
Assert.AreEqual((int)SwapEventTypeEnum., service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)");
Console.WriteLine($"SD_003 通过:TradeStatus={td.TradeStatus}StockEqvNotional={td.StockEqvNotional} ✅");
}
// ================================================================
// SD_004DealFloatPosition 含费价重算正确(后端唯二真做计算的地方)
// ================================================================
/// <summary>
/// [SD_004] DealFloatPosition 含费价重算(SwapDealService.cs:1713-1725
/// ------------------------------------------------------------
/// 平仓事件重算三个字段(规范语义,见命名文档):
/// TradingAmountFeeAvgExitDirtyFeePrice= TradingAmountAvg(ExitDirtyPrice) + TradingFeePending/CloseQty × shortRatio
/// TradingAmountNetFeeAvgExitCleanFeePrice= TradingAmountNetAvg(ExitCleanPrice) + TradingFeePending/CloseQty × shortRatio
/// TradingAmount = TradingAmountAvg × CloseQty
/// 这是后端少数真正做计算(而非透传前端值)的地方,需锁住。
///
/// 手算:ExitDirtyPrice=1.02, TradingFeePending=50, CloseQty=1000, Long(多头,shortRatio=-1)
/// ExitDirtyFeePrice = 1.02 + 50/1000 × (-1) = 1.02 - 0.05 = 0.97
/// ExitCleanFeePrice = 1.00 + 50/1000 × (-1) = 1.00 - 0.05 = 0.95
/// TradingAmount = 1.02 × 1000 = 1020
/// </summary>
[TestMethod]
public void SD_004_DealFloatPosition_含费价重算正确()
{
var td = CreateTrade();
var service = new StubDealService(td);
// 构造平仓事件(PositionType>0 触发重算)
var closeEvent = new swap_flow_event
{
EventType = (int)SwapEventTypeEnum.,
PositionType = (int)PositionTypeFlag.Long, // 多头,shortRatio=-1
// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice
TradingAmountAvg = 1.02m,
// TradingAmountNetAvg 现状名,实为"期末净价不含费",规范名 ExitCleanPrice
TradingAmountNetAvg = 1.00m,
TradingFeePending = 50m,
};
var unwindData = CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m);
unwindData.FlowEvents.Add(closeEvent);
service.SwapUnwind(unwindData);
// ExitDirtyFeePriceTradingAmountFeeAvg= 1.02 + 50/1000×(-1) = 0.97
Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m,
$"TradingAmountFeeAvg(ExitDirtyFeePrice) 应=ExitDirtyPrice(1.02)+Fee/CloseQty×(-1)=0.97,实际={closeEvent.TradingAmountFeeAvg}");
// ExitCleanFeePriceTradingAmountNetFeeAvg= 1.00 + 50/1000×(-1) = 0.95
Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m,
$"TradingAmountNetFeeAvg(ExitCleanFeePrice) 应=ExitCleanPrice(1.00)+Fee/CloseQty×(-1)=0.95,实际={closeEvent.TradingAmountNetFeeAvg}");
// TradingAmount = ExitDirtyPrice × CloseQty = 1.02 × 1000 = 1020
Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m,
$"TradingAmount 应=ExitDirtyPrice(1.02)×CloseQty(1000)=1020,实际={closeEvent.TradingAmount}");
Console.WriteLine($"SD_004 通过:ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg}ExitCleanFeePrice={closeEvent.TradingAmountNetFeeAvg}TradingAmount={closeEvent.TradingAmount} ✅");
}
// ================================================================
// SD_005ApproveSwapTrade 审核通过 —— 反序列化事件、资金流水、持仓状态
// ================================================================
/// <summary>
/// [SD_005] ApproveSwapTrade 审核通过全部平仓
/// ------------------------------------------------------------
/// 后端 SwapDealService.ApproveSwapTrade:从 swap_event.EventData 反序列化 UnwindData
/// 据此生成资金流水 + 更新持仓状态。
/// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario4,验证:
/// - SwapRealizedPnL 从事件反序列化正确(EventData JSON
/// - 资金流水金额 = -SwapRealizedPnL
/// - 全平仓 → TradeStatus=已平仓
/// </summary>
[TestMethod]
public void SD_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账()
{
var td = CreateTrade();
// 构造待审核事件:EventData 里序列化了 UnwindData(含 SwapRealizedPnL=8000
var unwindData = CreateUnwindData(swapRealizedPnL: 8000m,
closeMethod: (int)CloseMethodEnum., closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m);
var swapEvent = new swap_event
{
id = 1, SwapTradeId = SwapTradeId,
EventType = (int)SwapEventTypeEnum., Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var flowEvents = new Dictionary<long, List<swap_flow_event>>
{
[1] = new List<swap_flow_event> { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } }
};
var service = new StubDealService(td,
swapEvents: new Dictionary<int, swap_event> { [(int)SwapEventTypeEnum.] = swapEvent },
flowEventsByEventId: flowEvents);
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.);
// 资金流水:从反序列化的 SwapRealizedPnL(8000) 记账 → -8000
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水");
Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL");
// 持仓状态
Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓");
Console.WriteLine($"SD_005 通过:审核反序列化 SwapRealizedPnL=8000,资金流水={service.ClientCashCalls[0].amount}TradeStatus={td.TradeStatus} ✅");
}
// ================================================================
// SD_006ApplySwapTrade 提交审核 —— 前置校验 + 保存事件
// ================================================================
/// <summary>
/// [SD_006] ApplySwapTrade 提交审核
/// ------------------------------------------------------------
/// 后端 SwapDealService.ApplySwapTrade:调 CloseReCheckSetTrade 前置校验 + SaveSwapDeal(approve=true)。
/// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario5,验证:
/// - CloseReCheckSetTrade 被调用1次
/// - SaveSwapDeal 以 approve=true 调用(事件类型正确)
/// - SwapRealizedPnL = SwapCloseAmountApplySwapTrade 内部赋值)
/// </summary>
[TestMethod]
public void SD_006_ApplySwapTrade_提交审核_前置校验与保存事件()
{
var td = CreateTrade();
var service = new StubDealService(td);
// 前端提交时 SwapCloseAmount=6000(前端算好的总额),SwapRealizedPnL 初始可能为0
var unwindData = CreateUnwindData(swapRealizedPnL: 0m);
unwindData.SwapCloseAmount = 6000m; // 模拟前端传入的平仓总额
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.);
// 前置校验被调用
Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次");
// SaveSwapDeal 以 approve=true 调用
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
Assert.AreEqual((int)SwapEventTypeEnum., service.SaveSwapDealCalls[0].eventType, "事件类型=平仓");
// SwapRealizedPnL 应被赋值为 SwapCloseAmountApplySwapTrade 内部 cs:1631
Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m,
"SwapRealizedPnL 应=SwapCloseAmount(6000)");
Console.WriteLine($"SD_006 通过:CloseReCheck 调用{service.CloseReCheckCallCount}次,SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅");
}
}
}
@@ -0,0 +1,206 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapEodPositionService.CalculateSwapRealizedPnl 的回归测试。
/// ---------------------------------------------------------------
/// 守卫张名锐提交 6676b625 "fix(swap): 修正掉期产品保证金利息计算逻辑"。
///
/// 旧 bugeod_swap.RealizedPnL 直接 Sum(s.RealizedPnl),未对保证金腿利息做方向反向,
/// 导致"收取对手方保证金"产生的利息被错误计入我方收益(实际是我方支付给对手方的成本),
/// 框架合约已实现收益虚高。
///
/// 修复:新增 CalculateSwapRealizedPnl ——
/// 非保证金腿:interestRatio = Direction==收取 ? 1 : -1(维持数据库方向)
/// 保证金腿(初始预付金 5 / 追加预付金 6):interestRatio 反向
/// 最终:RealizedInterest × interestRatio + 其他 4 字段
///
/// 抽为 public static 纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。
/// 本测试直接锁定方向反向契约,防止后续误改回归。
/// </summary>
[TestClass]
public class SwapEodRealizedPnlCalcTest
{
// ================================================================
// 场景1:非保证金腿收取方向 → RealizedInterest × +1(维持原向)
// ================================================================
[TestMethod]
public void _收取方向_利息维持原向系数为1()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
realizedInterest: 1000m);
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
Assert.AreEqual(1000m, result, 0.0001m,
"非保证金腿收取方向:利息 ×(+1)=1000");
}
// ================================================================
// 场景2:非保证金腿支付方向 → RealizedInterest × -1(维持原向)
// ================================================================
[TestMethod]
public void _支付方向_利息维持原向系数为负1()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
realizedInterest: 1000m);
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
Assert.AreEqual(-1000m, result, 0.0001m,
"非保证金腿支付方向:利息 ×(-1)=-1000");
}
// ================================================================
// 场景3:保证金腿(初始预付金)收取方向 → 利息反向,系数 -1
// 这是 6676b625 修复的核心场景:收取对手方保证金产生的利息是我方支付成本
// ================================================================
[TestMethod]
public void _初始预付金_收取方向_利息反向系数为负1()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
realizedInterest: 1000m);
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
Assert.AreEqual(-1000m, result, 0.0001m,
"保证金腿收取方向:利息应反向 ×(-1)=-1000(修复前会错误得 +1000");
}
// ================================================================
// 场景4:保证金腿(初始预付金)支付方向 → 利息反向,系数 +1
// ================================================================
[TestMethod]
public void _初始预付金_支付方向_利息反向系数为1()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
realizedInterest: 1000m);
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
Assert.AreEqual(1000m, result, 0.0001m,
"保证金腿支付方向:利息应反向 ×(+1)=1000");
}
// ================================================================
// 场景5:追加预付金同初始预付金,同样走反向逻辑
// ================================================================
[TestMethod]
public void _追加预付金_收取方向_利息反向()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
realizedInterest: 500m);
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
Assert.AreEqual(-500m, result, 0.0001m,
"追加预付金(mode=6)与初始预付金(mode=5)同走反向逻辑");
}
// ================================================================
// 场景6:完整 5 字段汇总(MtmPnL + Dividend + Fee + Interest×ratio + InterestFee
// 保证金腿收取方向,Interest=200, 其他各 100
// 期望:100 + 100 + 100 + 200×(-1) + 100 = 200
// ================================================================
[TestMethod]
public void 5_保证金腿收取方向_利息反向后合计正确()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
realizedMtmPnL: 100m,
realizedDividend: 100m,
realizedFee: 100m,
realizedInterest: 200m,
realizedInterestFee: 100m);
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
// 100 + 100 + 100 + 200×(-1) + 100 = 200
Assert.AreEqual(200m, result, 0.0001m,
"5 字段汇总:保证金腿收取方向,Interest×(-1) 后合计=200,验证所有字段都参与计算");
}
// ================================================================
// 场景7:完整 5 字段汇总(非保证金腿收取方向)
// 非保证金腿收取方向,Interest=200, 其他各 100
// 期望:100 + 100 + 100 + 200×(+1) + 100 = 600
// ================================================================
[TestMethod]
public void 5_非保证金腿收取方向_利息原向合计正确()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
realizedMtmPnL: 100m,
realizedDividend: 100m,
realizedFee: 100m,
realizedInterest: 200m,
realizedInterestFee: 100m);
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
// 100 + 100 + 100 + 200×(+1) + 100 = 600
Assert.AreEqual(600m, result, 0.0001m,
"5 字段汇总:非保证金腿收取方向,Interest×(+1) 后合计=600");
}
// ================================================================
// 场景8RealizedInterest=0 边界 —— 方向反向无影响,结果为其他 4 字段之和
// ================================================================
[TestMethod]
public void _方向反向无影响_结果为其他4字段之和()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
realizedMtmPnL: 100m,
realizedDividend: 50m,
realizedFee: 30m,
realizedInterest: 0m,
realizedInterestFee: 20m);
var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
// 100 + 50 + 30 + 0×(-1) + 20 = 200
Assert.AreEqual(200m, result, 0.0001m,
"RealizedInterest=0 时方向反向无影响,结果为其他 4 字段之和");
}
// ================================================================
// Helper:构造 eod_swap_position(只设置参与计算的 7 个字段)
// ================================================================
private static eod_swap_position NewPosition(
int interestMode,
int interestDirection,
decimal realizedMtmPnL = 0m,
decimal realizedDividend = 0m,
decimal realizedFee = 0m,
decimal realizedInterest = 0m,
decimal realizedInterestFee = 0m)
{
return new eod_swap_position
{
InterestMode = interestMode,
InterestDirection = interestDirection,
RealizedMtmPnL = realizedMtmPnL,
RealizedDividend = realizedDividend,
RealizedFee = realizedFee,
RealizedInterest = realizedInterest,
RealizedInterestFee = realizedInterestFee
};
}
}
}
@@ -0,0 +1,67 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换结息(SwapIncome)测试
/// ============================================================================
/// 借鉴 testable 分支命名,基于当前分支 TestableSwapDealService 共享 stub。
/// SwapIncome 是写客户资金流水(ClientCashInCashOut)的核心入口之一。
/// ============================================================================
[TestClass]
public class SwapIncomeScenarioTest
{
// ================================================================
// 场景1SwapIncome 正常结息 —— 资金流水金额正确
// ================================================================
/// <summary>
/// SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000。
/// 后端 SwapDealService SwapIncome 直接用前端传入的 SwapRealizedPnL 记账。
/// </summary>
[TestMethod]
public void SI_001_SwapIncome_正常结息_资金流水金额正确()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 1000m);
service.SwapIncome(unwindData);
Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)");
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL");
Assert.AreEqual(ClientCashInCashOut._互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换");
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
Assert.AreEqual((int)SwapEventTypeEnum., service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)");
Console.WriteLine($"SI_001: 资金流水={service.ClientCashCalls[0].amount}, 事件类型=互换 ✅");
}
// ================================================================
// 场景2SwapIncome 含预付金返息 —— 两条资金流水
// ================================================================
/// <summary>
/// SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200
/// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200。
/// </summary>
[TestMethod]
public void SI_002_SwapIncome_含预付金返息_两条资金流水()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m);
service.SwapIncome(unwindData);
Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)");
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额");
Assert.AreEqual(ClientCashInCashOut._互换, service.ClientCashCalls[0].action);
Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息");
Assert.AreEqual(ClientCashInCashOut._预付金返息, service.ClientCashCalls[1].action);
Console.WriteLine($"SI_002: 互换={service.ClientCashCalls[0].amount}, 预付金返息={service.ClientCashCalls[1].amount} ✅");
}
}
}
@@ -0,0 +1,259 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapPositionCompose 日终归档端到端测试
/// ============================================================================
/// 借鉴 testable 分支 SwapPositionComposeScenarioTest,基于当前分支 seam 重写。
/// 覆盖 DealFloatPositions 的首次归档/Copy/Update/异常路径。
/// 利息腿场景(自动互换)因 CalcSwapInterests 参数适配复杂留后续。
/// ============================================================================
[TestClass]
public class SwapPositionComposeScenarioTest
{
private const int SwapTradeId = 100;
private static readonly DateTime SettleDate = new(2025, 4, 24);
private static readonly DateTime PreSettleDate = new(2025, 4, 23);
#region
/// <summary>
/// 继承 SwapEodPositionServiceoverride SwapPositionCompose 路径上的 seam。
/// 适配当前分支 seam 签名(GetUnderlyingPrice 带 out、GetCurrencyRate 返回 double 等)。
/// </summary>
private sealed class TestableSwapEodService : SwapEodPositionService
{
private readonly List<trade> _trades;
private readonly List<swap_position> _positions;
private readonly List<eod_swap_position> _eodPositions;
private readonly List<eod_swap> _eodSwaps;
private readonly List<trade_extend> _extends;
private readonly List<swap_flow_event> _flowEvents;
private readonly decimal _price;
private readonly decimal _vobp;
public List<eod_swap_position> CreatedEodPositions { get; } = new();
public List<(double amount, string action)> ClientCashCalls { get; } = new();
public TestableSwapEodService(
List<trade> trades, List<swap_position> positions,
List<eod_swap_position> eodPositions, List<eod_swap> eodSwaps,
List<trade_extend> extends, List<swap_flow_event> flowEvents,
decimal price = 100m, decimal vobp = 0m)
: base(new OptUserInfo(0, nameof(SwapPositionComposeScenarioTest), OptUserFrom.UnitTest))
{
_trades = trades; _positions = positions; _eodPositions = eodPositions;
_eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents;
_price = price; _vobp = vobp;
}
// SwapPositionCompose 路径 seam override
protected override List<trade> FindActiveSwapTrades(DateTime settleDate, IEnumerable<int> clientIds) => _trades;
protected override List<swap_position> FindAllSwapPositions(List<int> tradeIds) => _positions;
protected override List<trade_extend> FindTradeExtends(List<int> tradeIds) => _extends;
protected override List<eod_swap> FindEodSwapsByDate(DateTime valueDate) => _eodSwaps;
protected override List<swap_flow_event> FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents;
protected override List<eod_swap_position> FindEodSwapPositions(int swapTradeId, DateTime preSettleDate)
=> _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList();
protected override List<swap_position> FindSwapPositions(int swapTradeId)
=> _positions.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial).ToList();
// DealFloatPositions 路径 seam override
protected override underlying_manager GetUnderlyingData(string underlyingCode)
=> new underlying_manager { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" };
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
{ vobp = _vobp; return _price; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m;
// 持久化/事务 seam override
protected override void PersistEodSwapPosition(eod_swap_position position) { CreatedEodPositions.Add(position); }
protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { }
protected override void SaveAllChanges() { }
protected override void ExecuteInTransaction(Action action) => action();
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
{ ClientCashCalls.Add((amount, action)); return ClientCashCalls.Count; }
protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List<int> eventTypes) { }
public override void ClearSwapPositions(trade td, DateTime valueDate, List<int> eventTypes, bool delAfter) { }
protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason)
{ return new swap_event { id = 1 }; }
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,
List<swap_flow_event> closeList = null) => new List<swap_flow_event>();
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) => 1.0;
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
=> SwapPositionCompose(settleDate, preSettleDate, null);
}
#endregion
#region
private static trade CreateTrade(DateTime? startDate = null)
{
var date = startDate ?? SettleDate;
return new trade
{
id = SwapTradeId, TradeNumber = "TEST-COMPOSE-001", ClientId = 10,
TradeType = "收益互换", TradeDate = date, StartDate = date,
ExerciseDate = SettleDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
QuoteCurrency = "CNY", SettlementCurrency = "CNY", StructureType = "普通债券类收益互换",
OriginalStockEqvNotional = 100000, TradePrice = 0
};
}
private static trade_extend CreateExtend()
{
return new trade_extend
{
TradeId = SwapTradeId,
ExtendJson = @"{""NeedOpenFee"":false,""AnnualDays"":365,""SettlementRules"":0,""Direction"":1,""FlowBookMode"":0}"
};
}
private static swap_position CreateFloatPosition(long positionId, decimal qty)
{
return new swap_position
{
id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId,
PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = "220205.IB", UnderlyingInstrumentType = "TBonds",
ContractSize = 1m, CountRatio = 1m, IsInitial = true, Invalid = false,
PosiQuantity = qty, PosiNotionalValue = qty,
PosiNetPrice = 1.0050m, PosiGrossPrice = 1.0020m,
PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 0.9970m,
InterestDirection = 0
};
}
private static eod_swap_position CreateFloatEodPosition(long positionId, decimal qty, decimal grossPrice)
{
return new eod_swap_position
{
SwapTradeId = SwapTradeId, PositionId = positionId, ValueDate = PreSettleDate,
PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false,
PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m,
PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m,
UnderlyingCode = "220205.IB", ContractSize = 1m,
InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty
};
}
private static swap_flow_event CreateCloseFlowEvent(long positionId, decimal qty)
{
return new swap_flow_event
{
SwapTradeId = SwapTradeId, PositionId = positionId,
EventType = (int)SwapFlowEventTypeEnum.,
Quantity = qty, EventDate = SettleDate, UnwindDate = SettleDate,
MarkClosePnl = 500m, CloseFee = 10m, DividendIn = 5m,
TradingAmountAvg = 1.0030m, DataState = (int)SwapFlowDateStateEnum.
};
}
#endregion
// ================================================================
// 场景1:首次归档(无前日eod,交易首日)
// ================================================================
[TestMethod]
public void SPC_001_首次归档_无前日Eod_直接取初始持仓()
{
var td = CreateTrade();
var extend = CreateExtend();
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
var service = new TestableSwapEodService(
new List<trade> { td }, positions,
new List<eod_swap_position>(), new List<eod_swap>(),
new List<trade_extend> { extend }, new List<swap_flow_event>());
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
Assert.IsTrue(service.CreatedEodPositions.Count >= 1, "应创建至少1条eod");
var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1);
Assert.IsNotNull(floatEod, "应创建浮动腿持仓");
Assert.AreEqual(1000m, floatEod.PosiQuantity, "首次归档 PosiQuantity=初始持仓数量");
Console.WriteLine($"SPC_001: PosiQuantity={floatEod.PosiQuantity} ✅");
}
// ================================================================
// 场景2:有前日eod无事件 → Copy
// ================================================================
[TestMethod]
public void SPC_002_Copy分支_有前日Eod无事件_价格原样复制()
{
var td = CreateTrade();
var extend = CreateExtend();
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
var prevEod = new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m) };
var service = new TestableSwapEodService(
new List<trade> { td }, positions,
prevEod, new List<eod_swap>(),
new List<trade_extend> { extend }, new List<swap_flow_event>());
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1);
Assert.IsNotNull(floatEod);
Assert.AreEqual(1000m, floatEod.PosiQuantity, "Copy分支 PosiQuantity不变");
Assert.AreEqual(1.0020m, floatEod.PosiGrossPrice, "Copy分支 PosiGrossPrice从前日eod复制");
Console.WriteLine($"SPC_002: PosiQuantity={floatEod.PosiQuantity}, PosiGrossPrice={floatEod.PosiGrossPrice} ✅");
}
// ================================================================
// 场景3:有平仓事件 → Update(持仓扣减)
// ================================================================
[TestMethod]
public void SPC_003_Update分支_有平仓事件_持仓扣减()
{
var td = CreateTrade();
var extend = CreateExtend();
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
var prevEod = new List<eod_swap_position> { CreateFloatEodPosition(1, 1000, 1.0020m) };
var flowEvents = new List<swap_flow_event> { CreateCloseFlowEvent(1, 400) };
var service = new TestableSwapEodService(
new List<trade> { td }, positions,
prevEod, new List<eod_swap>(),
new List<trade_extend> { extend }, flowEvents);
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate);
var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1);
Assert.IsNotNull(floatEod);
Assert.AreEqual(600m, floatEod.PosiQuantity, "Update分支 PosiQuantity=1000-400=600");
Assert.AreEqual(400m, floatEod.TdCloseQty, "TdCloseQty=平仓数量400");
Console.WriteLine($"SPC_003: PosiQuantity={floatEod.PosiQuantity}, TdCloseQty={floatEod.TdCloseQty} ✅");
}
// ================================================================
// 场景4:未收盘抛异常
// ================================================================
[TestMethod]
public void SPC_004_未收盘_非交易首日无前日Eod_抛异常()
{
// 交易起始日早于收盘日(非交易首日),且无前日eod
var td = CreateTrade(startDate: SettleDate.AddDays(-10));
var extend = CreateExtend();
var positions = new List<swap_position> { CreateFloatPosition(1, 1000) };
var service = new TestableSwapEodService(
new List<trade> { td }, positions,
new List<eod_swap_position>(), new List<eod_swap>(),
new List<trade_extend> { extend }, new List<swap_flow_event>());
var ex = Assert.ThrowsException<Exception>(() =>
service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate));
Assert.IsTrue(ex.Message.Contains("未收盘"), $"异常消息应含'未收盘',实际:{ex.Message}");
Console.WriteLine($"SPC_004: 抛异常'{ex.Message}' ✅");
}
}
}
@@ -0,0 +1,112 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 诊断测试:验证「浮动腿 fpositions 仍用 origPositions(orig 100M)」对本 deal 的
/// 预付金/返回预付金结果是否产生影响。结论预期:本 deal 利息腿只有 mode 9(标的期初全价)
/// 与 mode 5(初始预付金)CalcNotionalByMode 中 posiLong/posiShort 仅在「多头/空头存续名义本金」
/// 分支被消费(L709-716),故本 deal 即便 fpositions 用 orig 100M,预付金腿结果也不受其影响。
/// 本测试仅做诊断/验证,不改动任何生产代码;用反射调用 private CalcNotionalByMode 以直接证明
/// “mode 9 / mode 5 的 closePrincipal 不依赖 posiLong/posiShort”。
/// </summary>
[TestClass]
public class SwapUnwindFloatingLegDiagnosticTdd
{
private sealed class StubSwapDealService : SwapDealService
{
public StubSwapDealService(OptUserInfo optUser) : base(optUser) { }
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{ rate = 0; return false; }
}
private const decimal OrigFix = 99_000m; // 期初预付金腿初始本金
private const decimal RealFix = 66_813.12m; // 实时预付金腿剩余本金(4 次平仓后)
private const decimal OrigLong = 100_000_000m; // 期初标的(多头)名义本金
private const decimal RealLong = 68_947_200m; // 实时标的(多头)剩余名义本金
private const decimal ClosePct = 0.1m; // 本次平仓比例 10%
private static readonly DateTime D0 = new(2026, 7, 1);
private static readonly DateTime D1 = new(2026, 7, 16);
private SwapDealService _svc;
[TestInitialize] public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindFloatingLegDiagnosticTdd), OptUserFrom.UnitTest));
// ---- GLMS 双轨持仓构造 ----
private static swap_position OrigPrepay(decimal fix = OrigFix) => new swap_position
{ id = 35798, SwapTradeId = 1993, PosiDirection = 0, InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = fix, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
interest_rest_days = 1, InterestDirection = (int)SwapDirectionEnum., InterestSwapInterval = "[]" };
private static swap_position RealPrepay(decimal fix = RealFix) => new swap_position
{ id = 35871, SwapTradeId = 1993, PositionId = 35798, PosiDirection = 0, InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = fix, IsInitial = false, Invalid = false, InterestType = (int)InterestTypeEnum.,
interest_rest_days = 1, InterestDirection = (int)SwapDirectionEnum., InterestSwapInterval = "[]" };
private static swap_position OrigBasePrice() => new swap_position
{ id = 35797, SwapTradeId = 1993, PosiDirection = 0, InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 0, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
interest_rest_days = 1, InterestSwapInterval = "[]" };
private static swap_position RealBasePrice() => new swap_position
{ id = 35870, SwapTradeId = 1993, PositionId = 35797, PosiDirection = 0, InterestMode = (int)InterestModeEnum.,
InterestPrincipalFix = 0, IsInitial = false, Invalid = false, InterestType = (int)InterestTypeEnum.,
interest_rest_days = 1, InterestSwapInterval = "[]" };
private static swap_position OrigLongLeg() => new swap_position
{ id = 35799, SwapTradeId = 1993, PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long, InterestMode = 0,
PosiNotionalValue = OrigLong, IsInitial = true, Invalid = false };
private static swap_position RealLongLeg() => new swap_position
{ id = 35872, SwapTradeId = 1993, PositionId = 35799, PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long, InterestMode = 0,
PosiNotionalValue = RealLong, IsInitial = false, Invalid = false };
private static trade MakeTrade()
{
var extend = new trade_extend { TradeId = 1993, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{ AnnualDays = 365, InterestCalcMode = "10", SettlementRules = 0 }) };
return new trade { id = 1993, TradeNumber = "GLMS-20260701-0008", ClientId = 999998, TradeType = "收益互换",
TradeDate = D0, StartDate = D0, ExerciseDate = D1, TradeStatus = "确认成交", ValidState = "Valid",
StockEqvNotional = (double)RealLong, Notional = (double)RealLong, trade_extend = extend };
}
/// <summary>用反射调用 private CalcNotionalByMode,直接证明各 mode 的 closePrincipal 是否依赖 posiLong/posiShort。</summary>
private (decimal close, decimal posi, decimal pct) CallCalcNotionalByMode(swap_position position, decimal closePct, decimal posiNotional, decimal posiLong, decimal posiShort)
{
var m = typeof(SwapDealService).GetMethod("CalcNotionalByMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
return ((decimal, decimal, decimal))m.Invoke(_svc, new object[] { position, closePct, posiNotional, posiLong, posiShort });
}
[TestMethod]
public void _mode9_标的期初全价_closePrincipal_不依赖posiLong_而用posiNotional()
{
// mode 9 分支:closePrincipal = posiNotional * closePercent
var baseP = OrigBasePrice();
var (close, posi, _) = CallCalcNotionalByMode(baseP, ClosePct, RealLong * ClosePct, OrigLong, 0m);
Console.WriteLine($"[mode9] posiNotional={RealLong * ClosePct} posiLong(orig)={OrigLong} → closePrincipal={close}");
Assert.AreEqual(RealLong * ClosePct * ClosePct, close, "mode9 应 = posiNotional(=real剩余*closePct) * closePct,与 posiLong(orig 100M) 无关");
}
[TestMethod]
public void _mode5_预付金_closePrincipal_用自身Fix_不依赖posiLong()
{
// mode 5 分支:closePrincipal = position.InterestPrincipalFix * closePercent(用 Clone 后的 real Fix
var prepay = RealPrepay(); // Fix = RealFix(66,813.12)
var (close, posi, _) = CallCalcNotionalByMode(prepay, ClosePct, RealLong * ClosePct, OrigLong, 0m);
Console.WriteLine($"[mode5] Fix(cloned real)={RealFix} posiLong(orig)={OrigLong} → closePrincipal={close}");
Assert.AreEqual(RealFix * ClosePct, close, "mode5 应 = 实时腿剩余本金(real Fix) * closePct,与 posiLong(orig 100M) 无关");
Assert.AreNotEqual(OrigFix * ClosePct, close, "务必不是期初 99,000 * closePct(证明后端修复生效)");
}
[TestMethod]
public void _若将来有_多头存续名义本金_腿_posiLong用orig才出错_本deal无此腿_故不影响()
{
// 构造一个「多头存续名义本金」腿,证明此时 posiLong 取值(orig vs real)会直接决定结果——
// 说明本 deal 没有这种腿,所以 fpositions 用 orig 100M 不影响;但普通收益互换若有此腿则会踩坑。
var longLeg = new swap_position { id = 35799, InterestMode = (int)InterestModeEnum. };
var byOrig = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, OrigLong, 0m); // 当前代码:posiLong=orig 100M
var byReal = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, RealLong, 0m); // 若修正为 real 75.6M
Console.WriteLine($"[多头存续名义本金] orig100M→close={byOrig.close} ; real75.6M→close={byReal.close}");
Assert.AreEqual(OrigLong * ClosePct, byOrig.close, "现状:多头存续名义本金用 orig 100M → 多次部分平仓后会偏大");
Assert.AreEqual(RealLong * ClosePct, byReal.close, "正确应:用 real 剩余本金 75.6M");
Assert.AreNotEqual(byOrig.close, byReal.close, "★ 潜在同类 bug:普通收益互换(含多头/空头存续名义本金腿)在多次部分平仓后,posiLong/posiShort 用 orig 会算错——本 deal 无此腿故不触发,属本轮修复范围外");
}
}
}
@@ -0,0 +1,178 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 多次部分平仓"返回预付金"默认显示仍是初始值 bug 的回归测试(根因修复后应为全绿)。
/// ---------------------------------------------------------------
/// 生产铁证 GLMS-20260701-0008SwapTradeId=1993dev DB 192.168.2.96 / glms_yltrs_ylcms):
/// 预付金腿(InterestMode=5) 双轨记录——
/// orig 35798 (IsInitial=1, PosiDirection=0, InterestPrincipalFix=99,000) ← 期初腿,恒为初始值
/// real 35871 (IsInitial=0, PositionId=35798, InterestPrincipalFix=66,813.12) ← 实时腿,已扣减 4 次平仓
/// (9,900 + 15,840 + 3,663 + 2,783.88 = 32,186.8899,000 32,186.88 = 66,813.12,与 dev 库实时腿完全勾稽)
///
/// 根因:GetUnwindInterests 的利息腿迭代源取 origPositions(IsInitial=1),其预付金腿
/// InterestPrincipalFix 恒=99,000;而"当前剩余本金"66,813.12 存在 real 腿。GetInterests 算
/// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv 都读 position.InterestPrincipalFix
/// 于是多次部分平仓后打开平仓页,"返回预付金"仍按初始 99,000 计算——完全不对。
/// 首次平仓时 orig==real,掩盖了该 bug(解释"为何只修好一次部分平仓")。
///
/// 修复:SwapDealService.ResolveInterestLegPositions —— 迭代源仍用 origPositions(保留
/// orig.id → eod_swap_position.PositionId 的日终匹配,全库 25,441 行 eod 均按 orig.id 归档,
/// 换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿(初始5/追加6) Clone 覆盖
/// InterestPrincipalFix 为实时腿剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。
///
/// 覆盖盲区说明:既有 SwapUnwindPrepayPrincipalBugTdd 的 19 个用例全部直接调 GetInterests
/// 并只喂一条 IsInitial=true 的持仓,完全绕过 GetUnwindInterests 的 orig-vs-real 选择逻辑,
/// 测不到本次 bug。本类直接单测抽出的纯函数 ResolveInterestLegPositions 以锁定该契约。
/// </summary>
[TestClass]
public class SwapUnwindPrepayOrigVsRealBugTdd
{
// 生产 GLMS-20260701-0008 精确值
private const long OrigId = 35798;
private const long RealId = 35871;
private const decimal InitialFix = 99_000m; // orig 腿初始本金
private const decimal RemainingFix = 66_813.12m; // real 腿剩余本金(已扣减 4 次平仓 9,900+15,840+3,663+2,783.88=32,186.88
private static swap_position OrigPrepay(decimal fix = InitialFix, int mode = (int)InterestModeEnum.)
=> new swap_position
{
id = OrigId,
SwapTradeId = 1993,
PosiDirection = 0, // 利息端(收/支)
InterestMode = mode,
InterestPrincipalFix = fix,
IsInitial = true,
Invalid = false
};
private static swap_position RealPrepay(long positionId = OrigId, decimal fix = RemainingFix, int mode = (int)InterestModeEnum.)
=> new swap_position
{
id = RealId,
SwapTradeId = 1993,
PositionId = positionId, // 指向对应 orig 的 id
PosiDirection = 0,
InterestMode = mode,
InterestPrincipalFix = fix,
IsInitial = false,
Invalid = false
};
[TestMethod]
public void _预付金腿本金应取实时腿剩余本金_而非原始腿初始值()
{
var origs = new List<swap_position> { OrigPrepay() };
var reals = new List<swap_position> { RealPrepay() };
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
Assert.AreEqual(1, result.Count, "应保留 1 条利息腿");
Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix,
"多次部分平仓后:预付金腿本金应=实时腿剩余本金 66,813.12,而非原始腿初始值 99,000bug 症状)");
// 必须是 Clone,不能污染原始腿(原始腿要保留 99,000 供其他路径/审计)
Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix,
"修复必须走 Clone,绝不能就地改写 origPositions 的初始本金");
}
[TestMethod]
public void _实时腿等于原始腿_返回原始腿本身_零改动()
{
var origs = new List<swap_position> { OrigPrepay(InitialFix) };
var reals = new List<swap_position> { RealPrepay(fix: InitialFix) }; // 尚未平仓,real==orig
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "首次平仓 orig==real,本金保持初始值");
Assert.AreSame(origs[0], result[0], "orig==real 时不应克隆,直接返回原始腿本身(行为与修复前一致)");
}
[TestMethod]
public void _同样取实时腿剩余本金()
{
var origs = new List<swap_position> { OrigPrepay(InitialFix, (int)InterestModeEnum.) };
var reals = new List<swap_position> { RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.) };
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix,
"追加预付金(mode=6)与初始预付金(mode=5)同源修复,同样取实时腿剩余本金");
}
[TestMethod]
public void _不受影响_始终保持原始腿本金()
{
// 标的期初全价(=9)等非预付金腿:即便 real 腿本金不同也不应被覆盖(其本金语义不同,不走此纠正)
var orig = OrigPrepay(InitialFix, (int)InterestModeEnum.);
var real = RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.);
var result = SwapDealService.ResolveInterestLegPositions(
new List<swap_position> { orig }, new List<swap_position> { real });
Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "非预付金腿本金不被实时腿覆盖");
Assert.AreSame(orig, result[0], "非预付金腿应原样返回,不克隆");
}
[TestMethod]
public void _返回原始腿()
{
// real 腿 PositionId 指向别的 orig(或根本没有实时腿)→ 找不到匹配,保持原始腿
var origs = new List<swap_position> { OrigPrepay() };
var mismatched = new List<swap_position> { RealPrepay(positionId: 99999) };
var r1 = SwapDealService.ResolveInterestLegPositions(origs, mismatched);
Assert.AreEqual(InitialFix, r1[0].InterestPrincipalFix, "无匹配实时腿:保持原始腿初始本金");
var r2 = SwapDealService.ResolveInterestLegPositions(origs, new List<swap_position>());
Assert.AreEqual(InitialFix, r2[0].InterestPrincipalFix, "实时腿为空:保持原始腿初始本金");
var r3 = SwapDealService.ResolveInterestLegPositions(origs, null);
Assert.AreEqual(InitialFix, r3[0].InterestPrincipalFix, "实时腿为 null:应容错并保持原始腿初始本金");
}
[TestMethod]
public void _过滤掉标的腿()
{
// PosiDirection>0 的标的腿不属于利息端,应被过滤(与原实现 Where(PosiDirection==0) 一致)
var underlyingLeg = new swap_position
{
id = 40000, SwapTradeId = 1993, PosiDirection = 1,
InterestMode = (int)InterestModeEnum., IsInitial = true, Invalid = false
};
var origs = new List<swap_position> { OrigPrepay(), underlyingLeg };
var reals = new List<swap_position> { RealPrepay() };
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
Assert.AreEqual(1, result.Count, "只应保留利息腿(PosiDirection==0),标的腿被过滤");
Assert.AreEqual(OrigId, result[0].id, "保留的应是预付金利息腿");
Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix, "且其本金已对齐实时剩余本金");
}
/// <summary>
/// 生产 Live Snapshot2026-07-16 11:00dev DB 192.168.2.96 / glms_yltrs_ylcms 直连核实):
/// GLMS-20260701-0008 已 4 次部分平仓。预付金腿(orig 35798 / real 35871) 实际值——
/// orig InterestPrincipalFix = 99,000(期初腿恒为初始值)
/// real InterestPrincipalFix = 66,813.12= 99,000 9,900 15,840 3,663 2,783.88
/// swap_flow_event 4 次平仓返还:9,900 / 15,840 / 3,663 / 2,783.88,合计 32,186.88。
/// 本用例把这份真实数据硬编码进来,断言修复后取实时腿剩余本金 66,813.12(非 99,000),
/// 作为该 deal 在此快照点的忠实回归;日后该 deal 再被平仓,剩余本金会变,本例仍应同步更新。
/// </summary>
[TestMethod]
public void GLMS20260701_四次部分平仓_LiveSnapshot_预付金腿应取实时腿剩余66813_12()
{
// 与生产一致的双轨数据:期初腿 99,000 / 实时腿 4 次平仓后 66,813.12
var origs = new List<swap_position> { OrigPrepay(InitialFix) };
var reals = new List<swap_position> { RealPrepay(fix: 66_813.12m) };
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
Assert.AreEqual(1, result.Count);
Assert.AreEqual(66_813.12m, result[0].InterestPrincipalFix,
"4 次部分平仓后:预付金腿本金应=实时腿剩余本金 66,813.12,而非原始腿初始值 99,000");
// 不污染原始腿
Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix, "修复必须走 Clone,不能改写 origPositions 的初始本金 99,000");
}
}
}
@@ -0,0 +1,567 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 预付金(保证金)腿 平仓"应返还本金" bug 的回归测试(根因修复后应为全绿)。
/// ---------------------------------------------------------------
/// 业务预期:平仓"应返还本金"(swap_flow_event.InterestPrincipal) 应等于该预付金腿的
/// 保证金本金(InterestPrincipalFix * closePercent),且与逐日利息计算无关;
/// 同时预付金腿的逐日利息计息基数也应基于"保证金本金"自身,而非整笔交易的名义本金。
///
/// 根因:GetUnwindInterests 对全部腿统一用 orginPv = lastEod.NotionalValue ?? stockEqvNotional(整笔交易名义本金),
/// 缺了"预付金腿用自身保证金"的分支;公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv
/// 把交易名义本金(千万~亿级)当减项扣掉,使 InterestPrincipal 与计息基数变成巨负值。
///
/// 根因修复(SwapDealService.InitSwapDealInterest):对预付金腿(初始/追加)在利息计算前把
/// orginPv 对齐为 position.InterestPrincipalFix,与日终路径(SwapEodPositionService)一致。
/// 仅作用于 InterestMode 5/6;债券本金腿(标的期初全价=9)等仍用交易名义本金,不受影响。
///
/// 设计:标的名义本金 100万、预付金(保证金)本金 10万(维度不同,放大错配);
/// 另含客户截图级 / 真实库 Trade1813 的精确复现用例。
/// </summary>
[TestClass]
public class SwapUnwindPrepayPrincipalBugTdd
{
private sealed class StubSwapDealService : SwapDealService
{
public StubSwapDealService(OptUserInfo optUser) : base(optUser) { }
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
rate = 0;
return false; // 预付金腿无浮动标的,不查库
}
}
private const decimal UnderlyingNotional = 1_000_000m; // 标的名义本金(股票维度)
private const decimal PrepayPrincipal = 100_000m; // 预付金/保证金本金(预付金维度)
private const int AnnualDays = 365;
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, 28);
private SwapDealService _svc;
[TestInitialize]
public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindPrepayPrincipalBugTdd), OptUserFrom.UnitTest));
private static trade MakeTrade(decimal notional = UnderlyingNotional)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾
SettlementRules = 0
})
};
return new trade
{
id = 1, TradeNumber = "UT-PREPAY-TDD", ClientId = 999998,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
StockEqvNotional = (double)notional, Notional = (double)notional,
trade_extend = extend
};
}
private static swap_position MakePrepayPosition(decimal fix = PrepayPrincipal, decimal rate = 0.01m)
{
return new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = rate, InterestPrincipalFix = fix,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = 1,
interest_rule = 0, FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
}
private swap_flow_event CalcUnwind(decimal closePercent, List<eod_swap_position> eodPositions)
{
eodPositions ??= new List<eod_swap_position>();
var td = MakeTrade();
var position = MakePrepayPosition();
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, new List<swap_position> { position },
UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
/// <summary>
/// 客户/真实库场景:自定义 标的名义本金(notional) 与 保证金本金(fix)。
/// orginPv 用 notional(与 GetUnwindInterests 行为一致:lastEod.NotionalValue ?? stockEqvNotional)。
/// </summary>
private swap_flow_event CalcUnwindWith(decimal closePercent, List<eod_swap_position> eodPositions, decimal notional, decimal fix, decimal rate = 0.01m)
{
eodPositions ??= new List<eod_swap_position>();
var td = MakeTrade(notional);
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,
(int)SwapEventTypeEnum.,
false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
[TestMethod]
public void _全平_应返还本金应等于保证金本金()
{
var fe = CalcUnwind(1m, null); // 无 eod 归档 → preEod.id==0
Console.WriteLine($"[TDD] 无归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})");
Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal,
"无归档全平: InterestPrincipal(应返还本金) 应=保证金本金(预付金本金),不应被利息公式改写为含 -orginPv 与 double 的怪值");
}
[TestMethod]
public void _全平_应返还本金应等于保证金本金()
{
var eod = new List<eod_swap_position>
{
new eod_swap_position
{
id = 1, SwapTradeId = 1, PositionId = 1001,
ValueDate = new DateTime(2026, 4, 27),
TdInterestPrincipal = PrepayPrincipal,
PosiNotionalValue = PrepayPrincipal,
InterestProfitSum = 0m
}
};
var fe = CalcUnwind(1m, eod);
Console.WriteLine($"[TDD] 有归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})");
Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal,
"有归档全平: 计息区间被跳过,InterestPrincipal 应保持初始正确值=保证金本金");
}
// ---- 客户截图级 / 真实库场景(验证"前后是否真 Fix"----
[TestMethod]
public void _全平_应返还本金应等于保证金本金()
{
// 生产铁证(用户提供真实交易):TradeAmount=3亿,StockEqvNotional=306,191,860.26
// StructureType=普通债券类收益互换;预付金腿 swap_position id=34009 InterestMode=5
// InterestPrincipalFix=9,185,755.81。
// swap_flow_event(该腿, mode5) 三条:
// 9202 EventId=null dir2 IP=9,185,755.81 (建仓支付预付金 ✓)
// 9489 EventId=15997 dir1 IP=-287,820,348.64 (平仓, 盘中路径 BUG ✗)
// 9492 EventId=15998 dir1 IP=9,185,755.81 (平仓, EOD正确路径 ✓)
// 同一腿出现"盘中错 / EOD对"两条平仓记录,恰好佐证修复方向(盘中 orginPv 对齐 EOD=Fix)正确。
// 根因复现:2*Fix - Notional = 2*9,185,755.81 - 306,191,860.26 = -287,820,348.64(与生产 15997 精确 0 误差)。
// 该预付金腿三条 event 的 InterestAmount 全=0(债券类预付金腿不计息),
// 故本笔生产仅 InterestPrincipal 中招、计息基数未受影响 → rate=0 贴合生产。
const decimal notional = 306_191_860.26m;
const decimal fix = 9_185_755.81m;
var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m);
Console.WriteLine($"[TDD][客户] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})");
Assert.AreEqual(fix, fe.InterestPrincipal,
"客户级: 应返还本金应=保证金本金 9,185,755.81,不应被算成 -287,820,348.64");
Assert.AreEqual(0m, fe.InterestAmount,
"客户级: 该预付金腿不计息,InterestAmount 应=0(与生产三条 event 全为 0 一致);仅 InterestPrincipal 中招");
}
[TestMethod]
public void Trade1813_全平_应返还本金应等于保证金本金()
{
// 测试库 Trade=1813 / Pos=34204Fix=35,140Notional=12,100,000
// 实际存储 InterestPrincipal=-12,029,720.00=2*35,140-12,100,000,公式精确 0 误差)。
// 同属债券类预付金腿(与生产同模式,不计息),rate=0 贴合生产,仅验证 InterestPrincipal 修复。
const decimal notional = 12_100_000m;
const decimal fix = 35_140m;
var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m);
Console.WriteLine($"[TDD][Trade1813] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})");
Assert.AreEqual(fix, fe.InterestPrincipal,
"Trade1813: 应返还本金应=保证金本金 35,140,不应被算成 -12,029,720.00");
Assert.AreEqual(0m, fe.InterestAmount,
"Trade1813: 同属债券类预付金腿不计息,InterestAmount 应=0;仅 InterestPrincipal 中招");
}
// ---- 多次部分平仓(验证最小修复是否覆盖"多次部分成交"----
[TestMethod]
public void _显示值每次返回比例份额且总计等于保证金()
{
// 模拟分 3 次平仓:0.3 / 0.5 / 1.0(剩余)。每次传入的 fix = 该次剩余保证金本金
// (真实系统中每次部分平仓后 position.InterestPrincipalFix 会被扣减,下一笔用剩余值)。
// 根因修复后:InterestPrincipal 由利息公式基于 Fix 正确得出 = fix * closePercent。
decimal total = 0;
var r1 = CalcUnwindWith(0.3m, null, 306_191_860.26m, 100_000m);
total += r1.InterestPrincipal;
var r2 = CalcUnwindWith(0.5m, null, 306_191_860.26m, 70_000m); // 剩余 7万
total += r2.InterestPrincipal;
var r3 = CalcUnwindWith(1.0m, null, 306_191_860.26m, 35_000m); // 剩余 3.5万
total += r3.InterestPrincipal;
Console.WriteLine($"[TDD][多次部分] r1={r1.InterestPrincipal} r2={r2.InterestPrincipal} r3={r3.InterestPrincipal} 合计={total}");
Assert.AreEqual(30_000m, r1.InterestPrincipal, "第1次(30%)应返还 3万");
Assert.AreEqual(35_000m, r2.InterestPrincipal, "第2次(50% of 剩余7万)应返还 3.5万");
Assert.AreEqual(35_000m, r3.InterestPrincipal, "第3次(剩余全平)应返还 3.5万");
Assert.AreEqual(100_000m, total, "多次部分平仓合计应=保证金本金 10万");
}
// ---- 盘中路径 CalcDailySimpleInterest 的 closePercent^N 指数级缩小 bug ----
// 生产铁证 GLMS-20260701-0006:预付金腿 Fix=9,180,000、interest_rest_days=7、单利、不计息。
// 平仓弹窗(swaptrade2/GetUnwindInterestList → 盘中路径 CalcDailySimpleInterest)返回:
// 100% → 9,180,000 (对) 50% → 71,718.75 (错) 10% → 0.918 (错)
// 数学关系精确成立:9,180,000×0.5^7 = 71,718.75、9,180,000×0.1^7 = 0.918。
// 根因:CalcDailySimpleInterest 非重置日 else 分支
// flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent;
// tdDynomicPrincipal = flowEvent.InterestPrincipal; // ★把"已×closePercent"的值回填
// 使下一个非重置日再乘一次 closePercent → InterestPrincipal = Fix × closePercent^NN=计息天数),
// 而正确应为 Fix × closePercent(线性,与日终 CalcDailySimpleInterestByEod:1164-1165 只乘一次一致)。
// 现有 6 个用例 interest_rest_days=1 且 UnwindDate=StartDate+1(calcDays=1),循环首尾都被 continue 跳过、
// 从不进 else,故漏掉此 bug;本组用例用 restDays=7、跨多日、带 eod 归档触发 else 累积复现之。
private const decimal ProdPrepayFix = 9_180_000m;
private static readonly DateTime ProdPosiStart = new(2026, 7, 2);
private static readonly DateTime ProdEodValueDate = new(2026, 7, 4);
private static readonly DateTime ProdUnwindDate = new(2026, 7, 13);
/// <summary>
/// 盘中路径复现:restDays=7、PosiStart→Unwind 跨 11 天、eod 归档到 07-04。
/// 与生产 GLMS-20260701-0006 完全对齐,buggy 代码产出 Fix × closePercent^7。
/// </summary>
private swap_flow_event CalcUnwindMultiDay(decimal closePercent, decimal fix = ProdPrepayFix, int restDays = 7,
decimal rate = 0m)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾(与生产一致)
SettlementRules = 0
})
};
var td = new trade
{
id = 1, TradeNumber = "UT-PREPAY-EXP", ClientId = 999998,
TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart,
ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
StockEqvNotional = (double)fix, Notional = (double)fix,
trade_extend = extend
};
var position = new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = rate, InterestPrincipalFix = fix,
PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1),
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = restDays,
interest_rule = 0, FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
var eod = new List<eod_swap_position>
{
new eod_swap_position
{
id = 7, SwapTradeId = 1, PositionId = 1001,
ValueDate = ProdEodValueDate,
TdInterestPrincipal = fix, // 生产 eod_swap_position(35774) TdInterestPrincipal=9,180,000
PosiNotionalValue = fix,
InterestProfitSum = 0m, FloatRate = 0m
}
};
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eod, new List<swap_position> { position },
fix, fix, fix, fix, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, fix, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
[TestMethod]
public void 50_7_应返还本金应线性缩放而非指数级()
{
var fe = CalcUnwindMultiDay(0.5m);
Console.WriteLine($"[TDD][盘中50%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=71,718.75, 期望=4,590,000)");
// 正确:Fix × closePercent = 9,180,000 × 0.5 = 4,590,000100%返 9,180,000 的一半)。
// buggyFix × 0.5^7 = 71,718.75(生产实测),被指数级缩小 ~64 倍。
Assert.AreEqual(4_590_000m, fe.InterestPrincipal,
"50% 平仓: 应返还本金应=Fix×0.5=4,590,000,不应被 closePercent^7 缩成 71,718.75");
}
[TestMethod]
public void 10_7_应返还本金应线性缩放而非指数级()
{
var fe = CalcUnwindMultiDay(0.1m);
Console.WriteLine($"[TDD][盘中10%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=0.918, 期望=918,000)");
// 正确:Fix × 0.1 = 918,000。buggyFix × 0.1^7 = 0.918(生产实测),缩小 100 万倍。
Assert.AreEqual(918_000m, fe.InterestPrincipal,
"10% 平仓: 应返还本金应=Fix×0.1=918,000,不应被 closePercent^7 缩成 0.918");
}
[TestMethod]
public void _盘中重置周期7天_应返还本金应等于保证金本金()
{
// closePercent=1 → 1^N=1,指数 bug 对 100% 无影响(故用户看 100% 正常),此用例锚定不回归。
var fe = CalcUnwindMultiDay(1m);
Console.WriteLine($"[TDD][盘中100%] 实测 InterestPrincipal={fe.InterestPrincipal} (期望=9,180,000)");
Assert.AreEqual(ProdPrepayFix, fe.InterestPrincipal,
"100% 平仓: 应返还本金应=Fix=9,180,000closePercent=1 时指数 bug 不显现,须保持正确)");
}
// ---- 非预付金腿(标的期初全价=9)同样验证:证明修复对所有"单利盘中"腿通用且正确 ----
// CalcDailySimpleInterest 是所有单利腿(InterestType=0)的盘中计息通用函数,非预付金专用。
// 用户关切:修复会否波及非预付金腿?结论——
// · closePercent=1(日常计息/全平)时 1^N=1=1^1,修复前后逐位恒等,零影响;
// · closePercent<1(部分平仓)时,所有单利腿此前都被同一 bug 指数级缩小,修复后统一为
// 正确的线性缩放(平仓 X% => 本金×X),这是修正而非破坏。
// 本组用非预付金腿(标的期初全价=9,orginPv 不被对齐为 Fix、走交易名义本金)独立复现并锁定。
private const decimal NonPrepayNotional = 1_000_000m;
private swap_flow_event CalcUnwindMultiDayNonPrepay(decimal closePercent, decimal notional = NonPrepayNotional,
int restDays = 7, decimal rate = 0m)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾(与生产一致)
SettlementRules = 0
})
};
var td = new trade
{
id = 1, TradeNumber = "UT-NONPREPAY-EXP", ClientId = 999998,
TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart,
ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
StockEqvNotional = (double)notional, Notional = (double)notional,
trade_extend = extend
};
var position = new swap_position
{
id = 2002, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum., // 非预付金腿(=9)orginPv 不会被对齐为 Fix
InterestRateDefault = rate, InterestPrincipalFix = 0m,
PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1),
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = restDays,
interest_rule = 0, FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
var eod = new List<eod_swap_position>
{
new eod_swap_position
{
id = 8, SwapTradeId = 1, PositionId = 2002,
ValueDate = ProdEodValueDate,
TdInterestPrincipal = notional, // 计息基数=名义本金 → dynomicPrincipal = eodTd + notional - orginPv = notional
PosiNotionalValue = notional,
InterestProfitSum = 0m, FloatRate = 0m
}
};
// 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,
(int)SwapEventTypeEnum.,
false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "非预付金腿应生成 1 条 flow_event");
return interests[0];
}
[TestMethod]
public void _部分平仓50_盘中重置周期7天_应线性缩放不受指数bug影响()
{
var fe = CalcUnwindMultiDayNonPrepay(0.5m);
Console.WriteLine($"[TDD][非预付金50%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=7,812.5, 期望=500,000)");
// 正确:N×0.5=500,000。buggyN×0.5^7=7,812.5(同一指数 bug,证明非预付金腿此前也中招)。
Assert.AreEqual(500_000m, fe.InterestPrincipal,
"非预付金腿(标的期初全价) 50% 平仓应=名义本金×0.5=500,000,不应被 closePercent^7 缩小");
}
[TestMethod]
public void _部分平仓10_盘中重置周期7天_应线性缩放不受指数bug影响()
{
var fe = CalcUnwindMultiDayNonPrepay(0.1m);
Console.WriteLine($"[TDD][非预付金10%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=0.1, 期望=100,000)");
Assert.AreEqual(100_000m, fe.InterestPrincipal,
"非预付金腿(标的期初全价) 10% 平仓应=名义本金×0.1=100,000,不应被 closePercent^7 缩小");
}
[TestMethod]
public void _全平_修复前后恒等_零影响()
{
// closePercent=1 时 1^N=1=1^1:这是"修复不波及非平仓/全平计息"的数学不变量证明。
var fe = CalcUnwindMultiDayNonPrepay(1m);
Console.WriteLine($"[TDD][非预付金100%] 实测 InterestPrincipal={fe.InterestPrincipal} (期望=1,000,000)");
Assert.AreEqual(NonPrepayNotional, fe.InterestPrincipal,
"非预付金腿 全平应=名义本金(closePercent=1 时修复前后恒等,日常计息/全平零影响)");
}
[TestMethod]
public void _计息基数也被根因修复_利息基于保证金本金()
{
// 显式带息加固用例(合成,非用户那笔生产的真实症状):
// 用户那笔生产(3亿债券类TRS)预付金腿不计息(InterestAmount 全=0),仅 InterestPrincipal 中招;
// 本例用 rate=0.01 构造"若该腿计息"的场景,验证根因修复后计息基数也基于保证金本金自身
// (而非交易名义本金)InterestAmount 为小额正、且 < fix。
const decimal notional = 306_191_860.26m;
const decimal fix = 9_185_755.81m;
var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0.01m);
Assert.AreEqual(fix, fe.InterestPrincipal, "显示值(应返还本金)已=保证金本金");
Console.WriteLine($"[TDD][计息基数] InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount}");
Assert.IsTrue(fe.InterestAmount > 0,
"根因修复后(显式带息): 预付金腿 InterestAmount 应基于保证金本金算出小额正值(约 fix*rate),不再是巨负");
Assert.IsTrue(fe.InterestAmount < fix,
"利息基数必须为保证金维度(远小于 fix),证明 orginPv 已用预付金自身 Fix,而非交易名义本金 notional");
}
// ===== 覆盖完整性补强:所有单利腿模式 + 日终路径 =====
// 调用链事实(已用代码确认):
// CalcDailySimpleInterest 的唯一真实调用链 = GetInterests(settment=false) → CalcUnwindInterest → 本函数。
// 日终(settment=true)走 CalcEodInterest → CalcDailySimpleInterestByEod(closePercent 硬编码 1m、
// 且该函数从不改写 InterestPrincipal),根本不调用本函数。故"含日终"的正确命题是:
// 日终不受本 bug 影响,且应有用例锁定这一不变量。
// 本组用同一入口驱动各 InterestMode 在 closePercent<1 + rest_days=7 多天场景,断言
// InterestPrincipal = closePrincipal(线性),捕捉任何指数级回归;并显式加日终(settment=true)用例,
// 断言日终结果恒为线性 closePrincipal(证明日终不受盘中 bug 影响,与正确的 ByEod 变体对齐)。
private swap_flow_event CalcByMode(int mode, decimal baseP, decimal closePercent, int restDays = 7, bool eodPath = false)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾(与生产一致)
SettlementRules = 0
})
};
var td = new trade
{
id = 1, TradeNumber = "UT-MODE-COV", ClientId = 999998,
TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart,
ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
StockEqvNotional = (double)baseP, Notional = (double)baseP,
trade_extend = extend
};
bool isPrepayOrFixed = mode == (int)InterestModeEnum.
|| mode == (int)InterestModeEnum.
|| mode == (int)InterestModeEnum.;
var position = new swap_position
{
id = 3003, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = mode,
InterestRateDefault = 0m,
InterestPrincipalFix = isPrepayOrFixed ? baseP : 0m,
PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1),
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = restDays,
interest_rule = 0, FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
// 使 dynomicPrincipal = posiPrincipaleod.TdInterestPrincipal = orginPv(=baseP)
// 非预付金腿 orginPv 传 baseP;预付金/固定值腿 orginPv 被内部对齐为 Fix=baseP(同样成立)。
var eodPos = new List<eod_swap_position>
{
new eod_swap_position
{
id = 30, SwapTradeId = 1, PositionId = 3003,
ValueDate = ProdEodValueDate,
TdInterestPrincipal = baseP,
PosiNotionalValue = baseP,
InterestProfitSum = 0m, FloatRate = 0m
}
};
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eodPos, new List<swap_position> { position },
baseP, baseP, baseP, baseP * closePercent, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, baseP, false, settment: eodPath, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, $"mode={mode} 应生成 1 条 flow_event");
return interests[0];
}
// ---- 追加预付金(6):与初始预付金(5)同源修复,显式覆盖避免遗漏 ----
[TestMethod]
public void _盘中_部分平仓重置周期7天_应线性缩放()
{
var fe = CalcByMode((int)InterestModeEnum., ProdPrepayFix, 0.5m);
Assert.AreEqual(4_590_000m, fe.InterestPrincipal, "追加预付金 50% 应=Fix×0.5(与初始预付金同源修复)");
var fe1 = CalcByMode((int)InterestModeEnum., ProdPrepayFix, 0.1m);
Assert.AreEqual(918_000m, fe1.InterestPrincipal, "追加预付金 10% 应=Fix×0.1");
}
// ---- 多头/空头存续名义本金(7/8):经同一 CalcDailySimpleInterest,需证明修复通用 ----
[TestMethod]
public void _盘中_部分平仓重置周期7天_应线性缩放()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum., baseP, 0.5m);
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "多头存续 50% 应=posiLong×0.5");
var fe1 = CalcByMode((int)InterestModeEnum., baseP, 0.1m);
Assert.AreEqual(200_000m, fe1.InterestPrincipal, "多头存续 10% 应=posiLong×0.1");
}
[TestMethod]
public void _盘中_部分平仓重置周期7天_应线性缩放()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum., baseP, 0.5m);
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "空头存续 50% 应=posiShort×0.5");
var fe1 = CalcByMode((int)InterestModeEnum., baseP, 0.1m);
Assert.AreEqual(200_000m, fe1.InterestPrincipal, "空头存续 10% 应=posiShort×0.1");
}
// ---- 合约名义本金规模(2)CalcNotionalByMode 默认分支(posiNotional×cp ----
[TestMethod]
public void _盘中_部分平仓重置周期7天_应线性缩放()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum., baseP, 0.5m);
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "合约名义本金规模 50% 应=posiNotional×0.5");
}
// ---- 固定值(1)CalcNotionalByMode 强制 newClosePercent=1,对 closePercent 免疫(输入 0.5 也不缩放) ----
[TestMethod]
public void _盘中_部分平仓_对平仓比例免疫_返回Fix本金()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum., baseP, 0.5m);
Assert.AreEqual(baseP, fe.InterestPrincipal, "固定值腿 newClosePercent=1InterestPrincipal 恒=Fix,不随平仓比例缩放");
}
// ---- 日终路径(settment=true):证明走 CalcDailySimpleInterestByEod,结果恒为线性 closePrincipal,不受盘中 bug 影响 ----
[TestMethod]
public void _预付金腿_部分平仓_结果应线性且不受盘中bug影响()
{
var fe = CalcByMode((int)InterestModeEnum., ProdPrepayFix, 0.5m, eodPath: true);
Console.WriteLine($"[TDD][EOD 预付金50%] InterestPrincipal={fe.InterestPrincipal} (期望={4_590_000m})");
Assert.AreEqual(4_590_000m, fe.InterestPrincipal, "日终预付金 50% 应=Fix×0.5ByEod 正确变体,closePercent 走 closePrincipal 线性)");
var fe1 = CalcByMode((int)InterestModeEnum., ProdPrepayFix, 0.1m, eodPath: true);
Assert.AreEqual(918_000m, fe1.InterestPrincipal, "日终预付金 10% 应=Fix×0.1");
}
[TestMethod]
public void _非预付金腿_部分平仓_结果应线性且不受盘中bug影响()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum., baseP, 0.5m, eodPath: true);
Console.WriteLine($"[TDD][EOD 标的期初全价50%] InterestPrincipal={fe.InterestPrincipal} (期望={1_000_000m})");
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "日终非预付金腿 50% 应=名义本金×0.5(ByEod 正确,不受影响)");
}
}
}
@@ -0,0 +1,241 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换平仓全流程测试(SwapUnwind/ApproveSwapTrade/ApplySwapTrade/DealFloatPosition
/// ============================================================================
/// 借鉴 testable 分支 SwapUnwindScenarioTest,基于当前分支 TestableSwapDealService 共享 stub。
/// 命名规范说明(见《互换价格字段命名规范决策文档》):
/// PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice
/// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice
/// ============================================================================
[TestClass]
public class SwapUnwindScenarioTest
{
// ================================================================
// 场景1SwapUnwind 全平仓 —— 持仓归零、TradeStatus=已平仓
// ================================================================
[TestMethod]
public void UW_001_SwapUnwind_全平仓_持仓归零且资金流水正确()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 5000m, swapMarginAmount: 0m,
closeMethod: (int)CloseMethodEnum., closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
service.SwapUnwind(unwindData);
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水");
Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL");
Assert.AreEqual(ClientCashInCashOut._平仓费, service.ClientCashCalls[0].action);
Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓");
Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind");
Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0");
Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0");
Assert.AreEqual((int)SwapEventTypeEnum., service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)");
Console.WriteLine($"UW_001: TradeStatus={td.TradeStatus}, StockEqvNotional={td.StockEqvNotional} ✅");
}
// ================================================================
// 场景2SwapUnwind 部分平仓 —— HasPartialUnWind=1TradeStatus 不变
// ================================================================
[TestMethod]
public void UW_002_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 3000m, swapMarginAmount: 0m,
closeMethod: (int)CloseMethodEnum., closePercent: 0.5m,
closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m);
service.SwapUnwind(unwindData);
Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1");
Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变");
Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000");
Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000");
Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水");
Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL");
Console.WriteLine($"UW_002: HasPartialUnWind={td.HasPartialUnWind}, TradeStatus={td.TradeStatus} ✅");
}
// ================================================================
// 场景3SwapUnwind 含预付金 —— 两条资金流水
// ================================================================
[TestMethod]
public void UW_003_SwapUnwind_含预付金_两条资金流水()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 5000m, swapMarginAmount: 2000m,
closeMethod: (int)CloseMethodEnum., closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
service.SwapUnwind(unwindData);
Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水");
Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费");
Assert.AreEqual(ClientCashInCashOut._平仓费, service.ClientCashCalls[0].action);
Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金");
Assert.AreEqual(ClientCashInCashOut._应付预付金, service.ClientCashCalls[1].action);
Console.WriteLine($"UW_003: 平仓费={service.ClientCashCalls[0].amount}, 应付预付金={service.ClientCashCalls[1].amount} ✅");
}
// ================================================================
// 场景4DealFloatPosition 含费价重算(后端唯二真做计算的地方)
// ================================================================
/// <summary>
/// 平仓事件重算三字段(SwapDealService DealFloatPosition):
/// TradingAmountFeeAvg(ExitDirtyFeePrice) = TradingAmountAvg(ExitDirtyPrice) + Fee/CloseQty × shortRatio
/// TradingAmountNetFeeAvg(ExitCleanFeePrice) = TradingAmountNetAvg(ExitCleanPrice) + Fee/CloseQty × shortRatio
/// TradingAmount = TradingAmountAvg × CloseQty
/// 手算:ExitDirtyPrice=1.02, Fee=50, CloseQty=1000, Long(shortRatio=-1)
/// ExitDirtyFeePrice = 1.02 + 50/1000×(-1) = 0.97
/// ExitCleanFeePrice = 1.00 + 50/1000×(-1) = 0.95
/// TradingAmount = 1.02 × 1000 = 1020
/// </summary>
[TestMethod]
public void UW_004_DealFloatPosition_含费价重算正确()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var closeEvent = new swap_flow_event
{
EventType = (int)SwapEventTypeEnum.,
PositionType = (int)PositionTypeFlag.Long,
TradingAmountAvg = 1.02m, // ExitDirtyPrice
TradingAmountNetAvg = 1.00m, // ExitCleanPrice
TradingFeePending = 50m,
};
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m);
unwindData.FlowEvents.Add(closeEvent);
service.SwapUnwind(unwindData);
Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m,
$"TradingAmountFeeAvg(ExitDirtyFeePrice)=ExitDirtyPrice+Fee/Qty×(-1)=0.97");
Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m,
$"TradingAmountNetFeeAvg(ExitCleanFeePrice)=ExitCleanPrice+Fee/Qty×(-1)=0.95");
Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m,
$"TradingAmount=ExitDirtyPrice×CloseQty=1020");
Console.WriteLine($"UW_004: ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg}, TradingAmount={closeEvent.TradingAmount} ✅");
}
// ================================================================
// 场景5ApproveSwapTrade 审核通过全平仓 —— 反序列化事件并记账
// ================================================================
[TestMethod]
public void UW_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 8000m,
closeMethod: (int)CloseMethodEnum., closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m);
var swapEvent = new swap_event
{
id = 1, SwapTradeId = SwapDealTestFactory.SwapTradeId,
EventType = (int)SwapEventTypeEnum., Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var flowEvents = new Dictionary<long, List<swap_flow_event>>
{
[1] = new List<swap_flow_event> { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } }
};
var service = new TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event> { [(int)SwapEventTypeEnum.] = swapEvent },
flowEventsByEventId: flowEvents);
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.);
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水");
Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL");
Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓");
Console.WriteLine($"UW_005: 反序列化SwapRealizedPnL=8000, 资金流水={service.ClientCashCalls[0].amount}, TradeStatus={td.TradeStatus} ✅");
}
// ================================================================
// 场景6ApplySwapTrade 提交审核 —— 前置校验与保存事件
// ================================================================
[TestMethod]
public void UW_006_ApplySwapTrade_提交审核_前置校验与保存事件()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m);
unwindData.SwapCloseAmount = 6000m;
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.);
Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次");
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
Assert.AreEqual((int)SwapEventTypeEnum., service.SaveSwapDealCalls[0].eventType, "事件类型=平仓");
Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m,
"SwapRealizedPnL 应=SwapCloseAmount(6000)");
Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅");
}
// ================================================================
// 场景7:前端传"占期初(A)"语义,后端入口转"占剩余(B)" —— 全平判定
// 原始名义本金 100M / 剩余 60M,前端传 A=0.6(平掉原始 60M = 剩余全部)
// B = A × Notional/Posi = 0.6 × 100/60 = 1.0 → 触发全平
// ================================================================
[TestMethod]
public void UW_007_SwapUnwind_占期初A转占剩余B_全平判定正确()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum., closePercent: 0.6m,
closeQty: 600000m, closeNotionalValue: 600000m, positionQty: 600000m);
unwindData.NotionalValue = 1000000m; // 期初名义本金
unwindData.PosiNotionalValue = 600000m; // 剩余名义本金
service.SwapUnwind(unwindData);
// 桩 SaveSwapDeal 收集的是转换后的 B(落库 A 还原在生产 SaveSwapDealInternal 中,桩跳过)
Assert.AreEqual(1.0m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m,
"入口 A=0.6 应转为 B=1.0(占剩余全平)");
Assert.AreEqual("已平仓", td.TradeStatus, "B==1 触发全平 TradeStatus=已平仓");
Console.WriteLine($"UW_007: A=0.6→B={service.SaveSwapDealCalls[0].data.ClosePercent}, TradeStatus={td.TradeStatus} ✅");
}
// ================================================================
// 场景8:占期初(A)转占剩余(B) —— 部分平仓
// 原始 100M / 剩余 60M,前端传 A=0.3(平掉原始 30M = 剩余的 50%)
// B = A × Notional/Posi = 0.3 × 100/60 = 0.5 → 部分平仓
// ================================================================
[TestMethod]
public void UW_008_SwapUnwind_占期初A转占剩余B_部分平仓正确()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum., closePercent: 0.3m,
closeQty: 300000m, closeNotionalValue: 300000m, positionQty: 600000m);
unwindData.NotionalValue = 1000000m; // 期初名义本金
unwindData.PosiNotionalValue = 600000m; // 剩余名义本金
service.SwapUnwind(unwindData);
Assert.AreEqual(0.5m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m,
"入口 A=0.3 应转为 B=0.5(占剩余 50%");
Assert.AreEqual(1, td.HasPartialUnWind, "B≠1 应为部分平仓,设 HasPartialUnWind=1");
Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变");
Console.WriteLine($"UW_008: A=0.3→B={service.SaveSwapDealCalls[0].data.ClosePercent}, HasPartialUnWind={td.HasPartialUnWind} ✅");
}
}
}
@@ -0,0 +1,127 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapDealService 的可测试化子类(共享 stub)。
/// 继承 SwapDealServiceoverride seam 把 DB/事务/外部服务替换为内存收集器。
/// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用,避免重复。
/// </summary>
public class TestableSwapDealService : SwapDealService
{
private readonly trade _trade;
private readonly Dictionary<int, swap_event> _swapEvents;
private readonly Dictionary<long, List<swap_flow_event>> _flowEventsByEventId;
/// <summary>捕获 AddClientCash 的每次调用(金额, 操作, 日期)</summary>
public List<(double amount, string action, DateTime date)> ClientCashCalls { get; } = new();
/// <summary>捕获 SaveSwapDeal 的每次调用(unwindData, eventType, clientCashId</summary>
public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls { get; } = new();
public int SaveAllChangesCount;
public int CloseReCheckCallCount;
public TestableSwapDealService(trade td,
Dictionary<int, swap_event> swapEvents = null,
Dictionary<long, List<swap_flow_event>> flowEventsByEventId = null)
: base(new OptUserInfo(0, nameof(TestableSwapDealService), OptUserFrom.UnitTest))
{
_trade = td;
_swapEvents = swapEvents ?? new Dictionary<int, swap_event>();
_flowEventsByEventId = flowEventsByEventId ?? new Dictionary<long, List<swap_flow_event>>();
}
protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null;
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
{
ClientCashCalls.Add((amount, action, valueDate));
return ClientCashCalls.Count; // 返回自增 id
}
// 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库
protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false)
{
SaveSwapDealCalls.Add((unwindData, eventType, clientCashId));
return SaveSwapDealCalls.Count; // 返回自增 eventId
}
// ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType
protected override swap_event FindSwapEvent(int tradeId, int eventType)
{
return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null;
}
// ApproveSwapTrade 查事件关联流水:从内存字典取
protected override List<swap_flow_event> FindFlowEventsByEventId(long eventId)
{
return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List<swap_flow_event>();
}
// ApplySwapTrade 的前置校验:计数,不实际执行
protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck)
{
CloseReCheckCallCount++;
}
protected override void SaveAllChanges() { SaveAllChangesCount++; }
protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行
protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作
protected override void TriggerRealtimeSwapPosition() { } // 空操作
}
/// <summary>
/// SwapDealService 测试的共享工厂方法(TestableSwapDealService + UnwindData 构造)。
/// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用。
/// </summary>
public static class SwapDealTestFactory
{
public const int SwapTradeId = 7700;
public static readonly DateTime ValueDate = new(2026, 6, 15);
public static readonly DateTime UnwindDate = new(2026, 6, 16);
public static trade CreateTrade()
{
return new trade
{
id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888,
TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5),
ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用)
TradeStatus = "确认成交", ValidState = "Valid",
Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000
};
}
/// <summary>构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用)</summary>
public static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m,
decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m,
decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m)
{
return new UnwindData
{
SwapTradeId = SwapTradeId,
SwapRealizedPnL = swapRealizedPnL,
SwapMarginRebatePnl = swapMarginRebatePnl,
SwapMarginAmount = swapMarginAmount,
SwapCloseAmount = swapRealizedPnL,
CloseMethod = closeMethod,
ClosePercent = closePercent,
CloseQty = closeQty,
CloseNotionalValue = closeNotionalValue,
PositionQty = positionQty,
ValueDate = ValueDate,
UnwindDate = UnwindDate,
StartDate = new DateTime(2026, 1, 5)
};
}
public static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "")
{
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
$"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}");
}
}
}
@@ -0,0 +1,133 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.Modules.TradeModule;
namespace YLErp.Modules.TradeModule
{
/// <summary>
/// TradeServiceBase.CalcSwapCloseNotionalFromEventData / CalcOptionCloseNotional 的回归测试。
/// ---------------------------------------------------------------
/// 守卫锦麟王提交 23108016 "BugFix 互换本次名义本金取错"。
///
/// 旧 bugBuildTriggerContext 了结场景统一用 trade_cash.UnwindPercentRate × 期初名义本金
/// 算本次名义本金,但收益互换的 trade_cash.UnwindPercentRate 口径与期权不同,
/// 导致互换审批触发条件用错本金,可能绕过/误触发审批阈值。
///
/// 修复:互换分支从 swap_event.EventData 反序列化取 CloseNotionalValue 绝对值;
/// 期权分支保留旧逻辑(期初名义本金 × UnwindPercentRate 绝对值)。
///
/// 抽出两个静态纯函数以支持无库单测,重点验证容错(null/空/非法 JSON)不会抛异常
/// 而是返回 0,避免静默吞异常导致名义本金为 0 进而绕过审批阈值。
/// </summary>
[TestClass]
public class TradeServiceBaseCloseNotionalCalcTest
{
// ================================================================
// 一、CalcSwapCloseNotionalFromEventData 容错与绝对值语义
// ================================================================
[TestMethod]
public void _EventData为null_返回0_不抛异常()
{
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(null);
Assert.AreEqual(0d, result, 0.0001, "null EventData 应容错返回 0");
}
[TestMethod]
public void _EventData为空字符串_返回0_不抛异常()
{
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData("");
Assert.AreEqual(0d, result, 0.0001, "空字符串 EventData 应容错返回 0");
}
[TestMethod]
public void _EventData为非法JSON_返回0_不抛异常()
{
// 旧实现 catch{} 静默吞异常,抽函数后必须保持此容错契约
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData("not-a-json");
Assert.AreEqual(0d, result, 0.0001, "非法 JSON 应被 catch 返回 0,不能抛异常");
}
[TestMethod]
public void _EventData为合法JSON_正数CloseNotionalValue_原值返回()
{
var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 500_000m });
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData);
Assert.AreEqual(500_000d, result, 0.01, "正数 CloseNotionalValue 应原值返回");
}
[TestMethod]
public void _EventData为合法JSON_负数CloseNotionalValue_取绝对值()
{
// 修复的核心契约:Math.Abs 取绝对值,防止方向反向导致名义本金变负
var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = -500_000m });
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData);
Assert.AreEqual(500_000d, result, 0.01, "负数 CloseNotionalValue 应取绝对值返回 500000");
}
[TestMethod]
public void _EventData为合法JSON_CloseNotionalValue为零_返回0()
{
var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 0m });
var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData);
Assert.AreEqual(0d, result, 0.0001, "CloseNotionalValue=0 应返回 0");
}
// ================================================================
// 二、CalcOptionCloseNotional 容错与绝对值语义
// ================================================================
[TestMethod]
public void _两者都为null_返回0()
{
var result = TradeServiceBase.CalcOptionCloseNotional(null, null);
Assert.AreEqual(0d, result, 0.0001, "两者 null 应返回 0");
}
[TestMethod]
public void _期初名义本金为null_返回0()
{
var result = TradeServiceBase.CalcOptionCloseNotional(null, 0.5d);
Assert.AreEqual(0d, result, 0.0001, "originalStockEqvNotional=null 应返回 0");
}
[TestMethod]
public void _平仓比例为null_返回0()
{
var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, null);
Assert.AreEqual(0d, result, 0.0001, "unwindPercentRate=null 应返回 0");
}
[TestMethod]
public void _两者都有值_正数相乘_返回乘积()
{
// 1,000,000 × 0.3 = 300,000
var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, 0.3d);
Assert.AreEqual(300_000d, result, 0.01, "1M × 0.3 = 300K");
}
[TestMethod]
public void _期初名义本金为负数_取绝对值后相乘()
{
// 异常但容错:-1,000,000 × 0.3 → Abs → 300,000
var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, 0.3d);
Assert.AreEqual(300_000d, result, 0.01, "期初名义本金为负数应取绝对值后相乘");
}
[TestMethod]
public void _平仓比例为负数_取绝对值后相乘()
{
// 异常但容错:1,000,000 × -0.3 → Abs → 300,000
var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, -0.3d);
Assert.AreEqual(300_000d, result, 0.01, "平仓比例为负数应取绝对值后相乘");
}
[TestMethod]
public void _两者都为负数_取绝对值后相乘()
{
// -1,000,000 × -0.3 = 300,000(先乘后取 Abs,结果一致)
var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, -0.3d);
Assert.AreEqual(300_000d, result, 0.01, "两者都为负数应取绝对值后相乘");
}
}
}
@@ -0,0 +1,51 @@
{
"Scenario": "标的种类与数据来源",
"Description": "合成 Mock:覆盖现券/贵金属/真期货/股票 的种类显示,以及债券来源(自动同步→系统/手工改过→人工)。回放守护'不再一律显示商品期货'+'路由键不变'+'债券来源=系统'。",
"Source": "synthetic",
"RecordedAt": null,
"Rows": [
{
"UnderlyingCode": "019547.IB",
"RouteKey": "CommodityFutures",
"RealInstrumentType": "CreditBonds",
"IsBond": true,
"ExpectedTypeCn": "信用债",
"ExpectedDataSource": "系统"
},
{
"UnderlyingCode": "220210.IB",
"RouteKey": "CommodityFutures",
"RealInstrumentType": "TBonds",
"IsBond": true,
"ExpectedTypeCn": "利率债",
"ExpectedDataSource": "系统"
},
{
"UnderlyingCode": "AU9999.SGE",
"RouteKey": "CommodityFutures",
"RealInstrumentType": "GoldSpot",
"IsBond": false,
"JSID": null,
"ExpectedTypeCn": "黄金现货",
"ExpectedDataSource": null
},
{
"UnderlyingCode": "IF2409",
"RouteKey": "CommodityFutures",
"RealInstrumentType": "CommodityFutures",
"IsBond": false,
"JSID": null,
"ExpectedTypeCn": "商品期货",
"ExpectedDataSource": null
},
{
"UnderlyingCode": "600000.SH",
"RouteKey": "Stock",
"RealInstrumentType": "Stock",
"IsBond": false,
"JSID": null,
"ExpectedTypeCn": "股票",
"ExpectedDataSource": null
}
]
}
+348
View File
@@ -0,0 +1,348 @@
using BaseOUDAL;
using Newtonsoft.Json;
namespace YLErp.Helpers
{
/// <summary>
/// 审批条件求值器:统一支撑「节点触发条件」与「分支网关条件」。
/// <para>需求①(节点触发)与需求③(多分支)共用同一套条件模型,避免两套条件语义。</para>
/// <para>支持混合「且/或」与「括号」的布尔表达式,如 A and (B or C)。</para>
/// </summary>
public static class ConditionEvaluator
{
/// <summary>
/// 求值条件 JSON。
/// </summary>
/// <param name="conditionJson">条件 JSON(结构见 ConditionExpressionConfig);为空/null 视为无条件,返回 false(不触发)。</param>
/// <param name="context">业务上下文(交易/发起人等)。</param>
/// <returns>是否满足条件</returns>
public static bool Evaluate(string conditionJson, ConditionContext context)
{
if (string.IsNullOrWhiteSpace(conditionJson))
{
return false;
}
ConditionExpressionConfig config;
try
{
config = JsonConvert.DeserializeObject<ConditionExpressionConfig>(conditionJson);
}
catch
{
// 容错:非法 JSON 不阻断审批主流程,视为不触发。
return false;
}
if (config == null || config.tokens == null || config.tokens.Count == 0)
{
return false;
}
// 递归下降求值(支持括号、且/或混合)。
var parser = new ConditionParser(config.tokens, context);
return parser.Parse();
}
/// <summary>
/// 从起点节点开始,向后查找第一个满足触发条件(或无触发条件)的审批节点(需求①)。
/// <para>用于交易提交进入审批流程时确定起始审批节点:若起点节点配置了触发条件且当前业务不满足,
/// 则跳过该节点继续向后找,直到找到可进入的节点;若从起点到末尾均不满足则返回 null(表示无需审批,直接通过)。</para>
/// </summary>
/// <param name="tradeProcess">流程全部节点(已按 order 排序)</param>
/// <param name="start">起点节点</param>
/// <param name="ctx">条件求值上下文</param>
/// <returns>第一个应进入审批的节点;若无需审批则返回 null</returns>
public static approvalprocess FindFirstTriggeredNode(
List<approvalprocess> tradeProcess,
approvalprocess start,
ConditionContext ctx)
{
if (start == null) return null;
var current = start;
while (current != null)
{
// 无触发条件,或满足触发条件 → 该节点需审批
if (string.IsNullOrWhiteSpace(current.triggerCondition)
|| Evaluate(current.triggerCondition, ctx))
{
return current;
}
// 不满足 → 向后取下一个主干节点(node=0)
current = tradeProcess.FirstOrDefault(x => x.order > current.order && x.node == 0);
}
return null;
}
/// <summary>求值单个条件。</summary>
internal static bool EvaluateSingle(ConditionItem cond, ConditionContext context)
{
if (cond == null || string.IsNullOrEmpty(cond.field) || string.IsNullOrEmpty(cond.op))
{
return false;
}
var leftValue = FieldResolver.ResolveValue(cond.field, context);
return OperatorCompare(leftValue, cond.op, cond.value);
}
/// <summary>比较:能转数值时按数值比,否则按字符串比。</summary>
private static bool OperatorCompare(object left, string op, object right)
{
if (TryToDouble(left, out var ld) && TryToDouble(right, out var rd))
{
return CompareNumeric(ld, op, rd);
}
var ls = left?.ToString() ?? string.Empty;
var rs = right?.ToString() ?? string.Empty;
return CompareString(ls, op, rs);
}
private static bool CompareNumeric(double left, string op, double right)
{
return NormalizeOp(op) switch
{
">" => left > right,
"<" => left < right,
">=" => left >= right,
"<=" => left <= right,
"==" => Math.Abs(left - right) < 1e-9,
"!=" => Math.Abs(left - right) >= 1e-9,
_ => false
};
}
private static bool CompareString(string left, string op, string right)
{
return NormalizeOp(op) switch
{
"==" => left == right,
"!=" => left != right,
">" => string.Compare(left, right, StringComparison.Ordinal) > 0,
"<" => string.Compare(left, right, StringComparison.Ordinal) < 0,
">=" => string.Compare(left, right, StringComparison.Ordinal) >= 0,
"<=" => string.Compare(left, right, StringComparison.Ordinal) <= 0,
"in" => right.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Any(r => string.Equals(r.Trim(), left, StringComparison.OrdinalIgnoreCase)),
_ => false
};
}
/// <summary>
/// 归一化操作符:兼容字母标识符(gt/lt/gte/lte/eq/neq)与符号(> < >= <= == != =)。
/// </summary>
private static string NormalizeOp(string op)
{
if (string.IsNullOrEmpty(op)) return op;
return op.ToLowerInvariant() switch
{
"gt" or ">" => ">",
"lt" or "<" => "<",
"gte" or ">=" => ">=",
"lte" or "<=" => "<=",
"eq" or "==" or "=" => "==",
"neq" or "!=" or "<>" => "!=",
_ => op
};
}
private static bool TryToDouble(object value, out double result)
{
result = 0;
if (value == null) return false;
return double.TryParse(value.ToString(), out result);
}
}
/// <summary>
/// 递归下降解析器:按 token 顺序求值布尔表达式,支持括号与「且/或」优先级。
/// <para>文法:Expr := Term (("and"|"or") Term)* Term := condition | "(" Expr ")"。</para>
/// <para>优先级:and 高于 or(与常规布尔代数一致);同级从左到右。</para>
/// </summary>
internal class ConditionParser
{
private readonly List<ConditionToken> _tokens;
private readonly ConditionContext _context;
private int _pos;
public ConditionParser(List<ConditionToken> tokens, ConditionContext context)
{
_tokens = tokens ?? new List<ConditionToken>();
_context = context;
_pos = 0;
}
public bool Parse()
{
if (_tokens.Count == 0) return false;
return ParseOr();
}
// 低优先级:ParseOr := ParseAnd ( "or" ParseAnd )* 左结合,遇 or 短路(为 true 直接返回后续不再求值)
private bool ParseOr()
{
var left = ParseAnd();
while (true)
{
var op = Peek();
if (op == null || op.type != "operator" || !IsOr(op.connector)) break;
_pos++; // 消费 or
var right = ParseAnd();
left = left || right;
}
return left;
}
// 高优先级:ParseAnd := ParseTerm ( "and" ParseTerm )* 左结合,遇 and 短路(为 false 直接返回)
private bool ParseAnd()
{
var left = ParseTerm();
while (true)
{
var op = Peek();
if (op == null || op.type != "operator" || IsOr(op.connector)) break;
_pos++; // 消费 and
var right = ParseTerm();
left = left && right;
}
return left;
}
// Term := condition | "(" ParseOr ")"
private bool ParseTerm()
{
var tok = Peek();
if (tok == null) return false;
if (tok.type == "lparen")
{
_pos++; // 消费 "("
var val = ParseOr();
var rp = Peek();
if (rp != null && rp.type == "rparen") _pos++; // 消费 ")"
return val;
}
if (tok.type == "condition")
{
_pos++;
return ConditionEvaluator.EvaluateSingle(tok.condition, _context);
}
return false;
}
private ConditionToken Peek() => _pos < _tokens.Count ? _tokens[_pos] : null;
private static bool IsOr(string connector)
=> string.Equals(connector, "or", StringComparison.OrdinalIgnoreCase);
}
/// <summary>条件业务上下文:求值时由调用方构造,封装可参与判断的业务字段。</summary>
public class ConditionContext
{
/// <summary>发起人 userId(用于查发起人审批组)。</summary>
public int? UserId { get; set; }
/// <summary>交易实体(期初名义本金等字段来源)。开户场景可为 null。</summary>
public trade Trade { get; set; }
/// <summary>交易流程类别:开仓(TradeProcess) / 了结(CloseProcess)。需求②用。</summary>
public string ProcessCategory { get; set; }
/// <summary>预解析的发起人审批组(避免重复查库;为空时由 FieldResolver 查)。</summary>
public int? InitGroupId { get; set; }
/// <summary>本次交易名义本金的取值(了结场景由调用方从 trade_cash.UnwindStockEqvNotional 取绝对值传入)。需求①。</summary>
public double? CurrentNotional { get; set; }
}
/// <summary>
/// 条件表达式配置(对应 conditionConfig / triggerCondition 列的 JSON 结构)。
/// <para>tokenstoken 序列,支持「且/或」混合与括号,按表达式顺序排列。</para>
/// </summary>
public class ConditionExpressionConfig
{
/// <summary>token 序列:条件 / 且或连接符 / 左右括号,按表达式顺序排列。</summary>
public List<ConditionToken> tokens { get; set; }
}
/// <summary>
/// 表达式 token:一个条件、一个连接符、或一个括号。
/// </summary>
public class ConditionToken
{
/// <summary>token 类型:condition | operator | lparen | rparen</summary>
public string type { get; set; }
/// <summary>当 type=condition 时的条件体。</summary>
public ConditionItem condition { get; set; }
/// <summary>当 type=operator 时的连接符:and | or</summary>
public string connector { get; set; }
}
/// <summary>单个条件:左值字段 + 操作符 + 右值。</summary>
public class ConditionItem
{
/// <summary>左值字段 key,见 FieldResolverinitGroup/notional/tradeType 等)。</summary>
public string field { get; set; }
/// <summary>操作符:> &lt; &gt;= &lt;= == != = in</summary>
public string op { get; set; }
/// <summary>右值</summary>
public object value { get; set; }
}
/// <summary>
/// 条件左值解析:把 field key 映射到具体业务字段值。
/// <para>触发条件字段(需求①):</para>
/// <para>- initialNotional:交易的期初名义本金(trade.OriginalStockEqvNotional,已取绝对值)</para>
/// <para>- currentNotional :本次交易名义本金(了结场景,由调用方从 trade_cash 取本次影响金额绝对值传入)</para>
/// <para>历史兼容:initGroup/tradeType/processCategory/notional 仍可解析。</para>
/// </summary>
public static class FieldResolver
{
public static object ResolveValue(string field, ConditionContext context)
{
if (string.IsNullOrEmpty(field) || context == null)
{
return null;
}
switch (field.ToLowerInvariant())
{
// 需求①:触发条件字段(仅这两个对外暴露)
case "initialnotional":
// 交易的期初名义本金
return context.Trade?.OriginalStockEqvNotional ?? 0;
case "currentnotional":
// 本次交易名义本金(了结时按本次影响金额绝对值,由调用方传入)
return context.CurrentNotional ?? 0;
// 以下为历史兼容,前端不再暴露
case "notional":
return context.Trade?.StockEqvNotional ?? 0;
case "initgroup":
if (context.InitGroupId.HasValue)
{
return context.InitGroupId.Value;
}
return context.UserId.HasValue
? UserBLL.GetApprovalProcessGroup(context.UserId.Value)
: 0;
case "tradetype":
return context.Trade?.TradeType ?? string.Empty;
case "processcategory":
return context.ProcessCategory ?? string.Empty;
default:
return null;
}
}
}
}
+6 -4
View File
@@ -89,7 +89,7 @@ namespace YLErp.Helpers
/// <summary>
/// 计算结息页(income)的盯市盈亏与汇总。
/// 对应 incomeSwapTrade.js:128-178。
/// 差异:用 CloseNotionalValue(非 CloseQty作量纲,无 longRatio,无 Math.round/10000。
/// 差异:用剩余持仓数量和合约乘数作量纲,无 longRatio,无 Math.round/10000。
/// </summary>
public static UnwindResult CalcIncome(UnwindInput input)
{
@@ -102,9 +102,9 @@ namespace YLErp.Helpers
decimal tradingFeePending = ParseOrZero(input.TradingFeePending);
decimal dividendIn = ParseOrZero(input.DividendIn);
// MarkClosePnl = CloseNotionalValue × (TradingAmountAvg × scale EntryPrice) × floatRatio
// MarkClosePnl = PositionQty × ContractSize × (TradingAmountAvg × scale EntryPrice) × floatRatio
// (无 longRatio、无 Math.round/10000
decimal markClosePnl = input.CloseNotionalValue * (input.TradingAmountAvg * scale - entryPrice) * floatRatio;
decimal markClosePnl = input.PositionQty * input.ContractSize * (input.TradingAmountAvg * scale - entryPrice) * floatRatio;
markClosePnl = StockEqvNotional(markClosePnl);
decimal floatPnlSum = decimal.Parse(
@@ -156,7 +156,9 @@ namespace YLErp.Helpers
public decimal PosiGrossPrice; // EntryDirtyPrice(期初全价不含费)
public decimal TradingAmountAvg; // 用户可改的期末标的价格(界面×multiplier形态)
public decimal CloseQty; // 平仓数量
public decimal CloseNotionalValue;// 平仓名义本金(income 用)
public decimal PositionQty; // 结息时的剩余持仓数量
public decimal ContractSize = 1m; // 合约乘数
public decimal CloseNotionalValue;// 平仓名义本金
public int PayDirection; // 1=收取,-1=支付
public int PositionType; // 1=多头,2=空头
public string TradingFee; // 交易费用(前端是字符串)
+17 -2
View File
@@ -15,12 +15,27 @@ namespace YLErp.Model
{
/// <summary>
///
/// </summary>
/// 估值日。每日估值报告的互换估值查询当前按该日期精确筛选,
/// 交易日期同时不得晚于该日期。
/// </summary>
public DateTime? ValueDate { get; set; }
/// <summary>
/// 请求携带的估值日下界。互换持仓明细、交易流水等调用方可使用该字段;
/// 当前 <c>GetSearchEodPositionList</c> 未启用该下界,仍是单日估值查询。
/// </summary>
public DateTime? ValueDateFrom { get; set; }
/// <summary>
/// 对手方筛选条件。为空时不按对手方收窄结果。
/// </summary>
public int? ClientId { get; set; }
/// <summary>
/// 簿记账户筛选条件,对应 <c>trade.AssetId</c>;为空时包含该对手方下全部簿记账户。
/// </summary>
public int? BookId { get; set; }
/// <summary>
/// 调用方传入的结构类型。互换估值查询当前固定同时覆盖普通债券类收益互换和普通收益互换。
/// </summary>
public string StructureType { get; set; }
}
/// <summary>
+1
View File
@@ -156,6 +156,7 @@ namespace YLErp.Model
public const string = "tradeMarginTemplateList";
public const string = "SettmentEodSwapPositionList";
public const string V1 = "SettmentEodSwapPositionListV1";
public const string = "compare_heitai_data";
}
@@ -140,6 +140,9 @@ namespace YLErp.Modules.DataProviderModule
{
if (item.UnderlyingInstrumentType == "Bonds")
{
// [Layer2-待统一] 债券映射口径:SettlePrice=全价(dirty_price_close)ClosePrice=净价(net_price)。
// 注意:这与 EodPriceQueryService.GetBondPrice 的映射【完全相反】(GetBondPrice: ClosePrice=全价,SettlePrice=净价)。
// 两处对"债券收盘价/结算价"的净全价定义不一致属历史遗留,请勿随意改动单侧,需业务先定调后统一(见 TryGetSettlementEodPrice 注释)。
item.SettlePrice = Convert.ToDouble(BondPriceConverter.ToStorage(item.DeciSettlePrice));
item.ClosePrice = Convert.ToDouble(BondPriceConverter.ToStorage(item.DeciClosePrice));
item.ReferencePrice = Convert.ToDouble(BondPriceConverter.ToStorage(item.DeciReferencePrice));
@@ -114,6 +114,23 @@ namespace YLErp.Modules.DataProviderModule
return (eodPrice = GetBondPrice(valueDate, underlyingCode)) != null;
}
/// <summary>
/// 统一日终结算取价(债券感知)。
/// 用于交易/期权到期结算:债券标的走中债估值表(TryGetBondEodPrice),期货/股票走原 InnerGetEodPrice。
/// 解决到期路径(tradeExpireInner / MultipleTradeExpireConfirm)漏查债券表导致"结算价未找到"的问题。
/// 注:债券 ClosePrice/SettlePrice 映射沿用 GetBondPrice 口径(ClosePrice=全价 dirty_price_closeSettlePrice=净价 net_price),
/// 与 EodPriceProvider 的映射(ClosePrice=净价,SettlePrice=全价)相反——属历史不一致(见 EodPriceProvider.Initialize 与 GetBondPrice 的注释),
/// 本方法保持与系统既有"债券现价"约定(UnderlyingCodePrice)一致,不引入新口径。
/// </summary>
public static bool TryGetSettlementEodPrice(DateTime valueDate, string underlyingCode, out EodPrice eodPrice)
{
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode);
if (um != null && ConsGlobal.InstrumentType.IsBond(um.UnderlyingInstrumentType))
{
return TryGetBondEodPrice(valueDate, underlyingCode, out eodPrice);
}
return TryGetEodPrice(valueDate, underlyingCode, out eodPrice);
}
/// <summary>
/// 尝试获取标的某日的日终价
/// </summary>
public static bool TryGetEodPrice(DateTime valueDate, int underlyingId, out EodPrice eodPrice)
@@ -137,6 +154,8 @@ namespace YLErp.Modules.DataProviderModule
if (data != null)
{
// FR007 行 ReferencePrice 已是小数口径(无论 bond-sync 自动同步还是界面手工录入,写入时均已 ÷100),
// 利息腿计算直接作为 floatRate 参与 principal*(fixedRate+floatRate)/annualDays,无需再 ÷100。
price = data.ReferencePrice ?? 0;
return true;
@@ -229,6 +248,9 @@ namespace YLErp.Modules.DataProviderModule
Vobp = bondPrice.vobp,
ValueDate = valueDate,
UnderlyingCode = underlyingCode,
// [Layer2-待统一] 债券映射口径:ClosePrice=全价(dirty_price_close)SettlePrice=净价(net_price)。
// 注意:这与 EodPriceProvider.Initialize 的映射【完全相反】(EodPriceProvider: ClosePrice=净价,SettlePrice=全价)。
// 两处对"债券收盘价/结算价"的净全价定义不一致属历史遗留,请勿随意改动单侧,需业务先定调后统一(见 TryGetSettlementEodPrice 注释)。
ClosePrice = Convert.ToDouble(BondPriceConverter.ToStorage(bondPrice.dirty_price_close)),
SettlePrice = Convert.ToDouble(BondPriceConverter.ToStorage(bondPrice.net_price)),
ReferencePrice = Convert.ToDouble(BondPriceConverter.ToStorage(bondPrice.yield))
+151 -22
View File
@@ -1,6 +1,5 @@
using BaseOUDAL;
using DocumentFormat.OpenXml.Bibliography;
using NPOI.POIFS.NIO;
using BaseOUDAL;
using YLErp.BLL;
using YLErp.Helpers;
namespace YLErp.Modules.EodModule
@@ -15,10 +14,81 @@ namespace YLErp.Modules.EodModule
}
/// <summary>
/// 解析日终价格列表的"估值日期"查询窗口。抽成 static 以便纯单测锁定行为(避免改坏)。
/// 规则:
/// - 起始日期年份 &gt; 2000(前端传了有效日期)→ 用传入值;否则回退到 今天-1年。
/// - 结束日期年份 &gt; 2000 → 用传入值+1天(闭区间转半开);否则回退到 今天+1年。
/// 注意:列表页默认把起止都设成"今天",于是窗口=[今天, 今天+1天)=仅今天 → 仅返回当天的记录
/// (即"页面始终5条"现象的真正成因,非分页/查询 bug)。要看历史须把起始日期调早。
/// </summary>
public static (DateTime start, DateTime end) ResolveValueDateWindow(DateTime reqStart, DateTime reqEnd)
{
var start = reqStart.Year > 2000 ? reqStart : DateTime.Today.AddYears(-1);
var end = reqEnd.Year > 2000 ? reqEnd.AddDays(1) : DateTime.Today.AddYears(1);
return (start, end);
}
/// <summary>
/// 校正 eod_commodity_future_price 行的 UnderlyingId,使其与 UnderlyingCode(=FutureContractId) 一致。
/// 背景:网页日终价格列表按 UnderlyingId(int) JOIN underlying_manager,而 EOD 结算(EodPriceProvider.Initialize)
/// 按 UnderlyingCode(string) JOIN。两列一旦失同步(典型如 FR007 的价格行 UnderlyingId 被错写成 511160.SH 的 id)
/// 会出现"网页能查到、结算却查不到"的错价缺失,进而 EodCheckSettlePrice 报"结算价格缺失"。
/// 这里以 UnderlyingCode 为准重新派生 UnderlyingId——该列才是上传/结算使用的自然键(FutureContractId)
/// 在入库前强制两列一致,既阻止产生新的错行,又通过告警日志把失同步暴露给运维追查上游写入来源。
/// </summary>
/// <summary>
/// 纯函数:根据 UnderlyingCode 校正决策。给定当前 UnderlyingId 与从 underlying_manager 解析到的正确 id
/// 返回应使用的 UnderlyingId。UnderlyingCode 为空或库中无对应标的(resolvedId=null)时维持原值,
/// 已一致时也维持原值,仅在不一致时返回正确 id。抽成纯函数便于无数据库单测(覆盖 GLMS-20260701 FR007 错行根因)。
/// </summary>
public static int ResolveUnderlyingIdForCode(string underlyingCode, int currentId, int? resolvedId)
{
if (string.IsNullOrWhiteSpace(underlyingCode))
{
return currentId;
}
if (resolvedId == null)
{
return currentId;
}
if (resolvedId.Value == currentId)
{
return currentId;
}
return resolvedId.Value;
}
/// <summary>
/// 校正 eod_commodity_future_price 行的 UnderlyingId,使其与 UnderlyingCode(=FutureContractId) 一致。
/// 背景:网页日终价格列表按 UnderlyingId(int) JOIN underlying_manager,而 EOD 结算(EodPriceProvider.Initialize)
/// 按 UnderlyingCode(string) JOIN。两列一旦失同步(典型如 FR007 的价格行 UnderlyingId 被错写成 511160.SH 的 id)
/// 会出现"网页能查到、结算却查不到"的错价缺失,进而 EodCheckSettlePrice 报"结算价格缺失"。
/// 这里以 UnderlyingCode 为准重新派生 UnderlyingId——该列才是上传/结算使用的自然键(FutureContractId)
/// 在入库前强制两列一致,既阻止产生新的错行,又通过告警日志把失同步暴露给运维追查上游写入来源。
/// </summary>
public static void SyncUnderlyingIdFromCode(YLContext db, eod_commodity_future_price row)
{
if (row == null || string.IsNullOrWhiteSpace(row.UnderlyingCode))
{
return;
}
var um = db.underlying_manager.FirstOrDefault(u => u.UnderlyingCode == row.UnderlyingCode);
var resolvedId = um == null ? (int?)null : um.id;
var before = row.UnderlyingId;
row.UnderlyingId = ResolveUnderlyingIdForCode(row.UnderlyingCode, row.UnderlyingId ?? 0, resolvedId);
if (row.UnderlyingId != before)
{
LogFactory.GetLogger("EodPrice").Info(
$"eod_commodity_future_price.UnderlyingId 与 UnderlyingCode 不一致,已自动校正: " +
$"FutureContractId={row.UnderlyingCode}, 原UnderlyingId={before}, 修正为={row.UnderlyingId}");
}
}
public SearchListResult<EodUnderlyingPriceDto> SearchUnderlyingList(EodCommodityFuturePriceReq req)
{
var valueDtStart = req.ValueDateStart.Year > 2000 ? req.ValueDateStart : DateTime.Today.AddYears(-1);
var valueDtEnd = req.ValueDateEnd.Year > 2000 ? req.ValueDateEnd.AddDays(1) : DateTime.Today.AddYears(1);
var (valueDtStart, valueDtEnd) = ResolveValueDateWindow(req.ValueDateStart, req.ValueDateEnd);
var predicatUn = PredicateBuilder.Create<underlying_manager>(d => d.LaunchState == "1");
var predicatEoc = PredicateBuilder.Create<eod_commodity_future_price>(source => source.ValueDate >= valueDtStart && source.ValueDate < valueDtEnd);
@@ -29,13 +99,20 @@ namespace YLErp.Modules.EodModule
{
predicatEoc = predicatEoc.And(d => d.DataSource.Contains(req.DataSource));
predicatEot = predicatEot.And(d => d.DataSource.Contains(req.DataSource));
if (req.DataSource=="系统")
// 债券来源:自动同步(中债, update_user 为空)归为"系统"、被手工改过的(update_user 非空)归为"人工"。
// 筛选须与后处理显示口径一致:按"系统"只命中 update_user 为空(中债自动同步)的债券;
// 按"人工"只命中被手工改过(update_user 非空)的债券;其他来源值视为无效→无命中。
if (req.DataSource == EodPriceBase.)
{
predicatEob= predicatEob.And(d => d.JSID!=null);
predicatEob = predicatEob.And(d => d.update_user != null);
}
else if (req.DataSource == EodPriceBase.)
{
predicatEob = predicatEob.And(d => d.update_user == null);
}
else
{
predicatEob = predicatEob.And(d => d.JSID==null);
predicatEob = predicatEob.And(d => false);
}
}
@@ -57,6 +134,10 @@ namespace YLErp.Modules.EodModule
IsBond=false,
id = source.id,
DataSource = source.DataSource,
// EF Core Concat 要求各分支投影成员集合完全一致:
// 债券分支设了 UpdateUser,故期货/股票分支也必须显式设(置 null),否则翻译期抛
// "The given key 'UpdateUser/DataSource' was not present in the dictionary"。
UpdateUser = (long?)null,
LaunchState = un.LaunchState,
MarketName = un.MarketName,
UnderlyingId = un.id,
@@ -65,6 +146,7 @@ namespace YLErp.Modules.EodModule
UnderlyingState = un.UnderlyingState,
UnderlyingType = un.UnderlyingType,
UnderlyingInstrumentType = "CommodityFutures",
RealInstrumentType = un.UnderlyingInstrumentType,
ValueDate = source.ValueDate,
SettlePrice = source.SettlePrice,
ClosePrice = source.ClosePrice,
@@ -73,8 +155,7 @@ namespace YLErp.Modules.EodModule
SourceTime = source.SourceTime,
DeciClosePrice=0,
DeciSettlePrice = 0,
DeciReferencePrice=0,
JSID = null
DeciReferencePrice=0
};
var query2 = from un in queryUn
@@ -84,6 +165,7 @@ namespace YLErp.Modules.EodModule
IsBond = false,
id = stockClose.id,
DataSource = stockClose.DataSource,
UpdateUser = (long?)null, // 对齐 Concat 投影成员,见 query1 注释
LaunchState = un.LaunchState,
MarketName = un.MarketName,
UnderlyingId = un.id,
@@ -92,6 +174,7 @@ namespace YLErp.Modules.EodModule
UnderlyingState = un.UnderlyingState,
UnderlyingType = un.UnderlyingType,
UnderlyingInstrumentType = "Stock",
RealInstrumentType = un.UnderlyingInstrumentType,
ValueDate = stockClose.ValueDate,
SettlePrice = stockClose.ClosePrice,
ClosePrice = stockClose.ClosePrice,
@@ -100,8 +183,7 @@ namespace YLErp.Modules.EodModule
SourceTime = stockClose.SourceTime,
DeciClosePrice = 0,
DeciSettlePrice = 0,
DeciReferencePrice = 0,
JSID=null
DeciReferencePrice = 0
};
var query3 = from un in queryUn
join bondClose in DbContext.china_bond_valuation.Where(predicatEob) on un.UnderlyingCode equals bondClose.bond_id
@@ -109,7 +191,10 @@ namespace YLErp.Modules.EodModule
{
IsBond = true,
id = bondClose.id,
DataSource="人工",
UpdateUser = bondClose.update_user,
// 债券 DataSource 在后处理统一置为"人工"/"系统"(见下方 foreach);
// 此处仍须显式设 null 以对齐 Concat 各分支投影成员集合(见 query1 注释)。
DataSource = null,
LaunchState = un.LaunchState,
MarketName = un.MarketName,
UnderlyingId = un.id,
@@ -118,6 +203,7 @@ namespace YLErp.Modules.EodModule
UnderlyingState = un.UnderlyingState,
UnderlyingType = un.UnderlyingType,
UnderlyingInstrumentType = un.UnderlyingInstrumentType,
RealInstrumentType = un.UnderlyingInstrumentType,
ValueDate = bondClose.valuation_date,
SettlePrice=0,
DeciSettlePrice =bondClose.net_price,
@@ -126,8 +212,7 @@ namespace YLErp.Modules.EodModule
UpdateTime = bondClose.update_time,
ReferencePrice=0,
DeciReferencePrice = bondClose.yield,
SourceTime="",
JSID=bondClose.JSID
SourceTime=""
};
var unionQuery = query1.Concat(query2);
var finalQuery = unionQuery.Concat(query3);
@@ -136,19 +221,18 @@ namespace YLErp.Modules.EodModule
req.sidx = "ValueDate";
req.sord = "desc";
}
var result= finalQuery.ToSearchList(req);
var result = finalQuery.ToSearchList(req);
foreach (var item in result.rows)
{
if (item.IsBond)
{
// 债券来源:被手工改过的(update_user 非空)→"人工";其余(中债自动同步)→"系统"。
item.DataSource = ResolveBondDisplaySource(item.UpdateUser);
item.SourceTime = item.UpdateTime.HasValue? item.UpdateTime.Value.ToString("yyyy-MM-dd HH:mm:ss"):"";
item.SettlePrice=Convert.ToDouble(item.DeciSettlePrice);
item.ClosePrice = Convert.ToDouble(item.DeciClosePrice);
item.ReferencePrice = Convert.ToDouble(item.DeciReferencePrice);
if (item.JSID.HasValue)
{
item.DataSource = "系统";
}
}
}
return result;
@@ -189,6 +273,9 @@ namespace YLErp.Modules.EodModule
dbmodel.DataSource = EodPriceBase.;
// 入库前强制 UnderlyingId 与 UnderlyingCode(FutureContractId) 一致,避免网页/结算两套 JOIN 失同步。
SyncUnderlyingIdFromCode(DbContext, dbmodel);
DbContext.SaveChanges();
return dbmodel;
@@ -215,11 +302,42 @@ namespace YLErp.Modules.EodModule
}
UpdateChanges(dbmodel, req);
}
// 记录手工编辑人:写入登录用户ID到已有列(create_user/update_user)
// 不新增字段。聚源同步路径(SettlementPriceImportService)不写这两列,故 NULL 即"自动同步"。
StampBondOperator(dbmodel, UserId, req.id == 0);
dbmodel.update_time = DateTime.Now;
DbContext.SaveChanges();
return dbmodel;
}
/// <summary>
/// 标记债券估值(china_bond_valuation)的操作人。
/// 该表已有 create_user/update_user 两列(bigint),但聚源同步路径不写入,
/// 因此:NULL = 聚源/中债自动同步;有值 = 被人手工编辑(记录登录用户ID)。
/// 抽出为纯静态函数,供 SaveBondPrice 与单元测试共用。
/// </summary>
/// <param name="model">债券估值实体</param>
/// <param name="userId">当前登录用户ID</param>
/// <param name="isNew">是否为新增(true 时同时写 create_user)</param>
public static void StampBondOperator(ChinaBondValuation model, int userId, bool isNew)
{
model.update_user = userId;
if (isNew)
{
model.create_user = userId;
}
}
/// <summary>
/// 债券来源列该显示什么:被手工改过的(update_user 有值)→"人工";其余(中债自动同步)→"系统"。
/// 抽为纯静态函数,便于无库单元测试。
/// </summary>
public static string ResolveBondDisplaySource(long? updateUser)
{
return updateUser.HasValue ? EodPriceBase. : EodPriceBase.;
}
/// <summary>
/// 保存日终股票价格
/// </summary>
@@ -313,7 +431,13 @@ namespace YLErp.Modules.EodModule
public string UnderlyingInstrumentType { get; set; }
public string UnderlyingInstrumentTypeCn => ConsGlobal.InstrumentType.GetDesc(UnderlyingInstrumentType);
/// <summary>
/// 真实标的种类(取自 underlying_manager),仅供列表"标的种类"列显示。
/// UnderlyingInstrumentType 仍作为"存储表路由键"使用,二者解耦,避免改动历史路由逻辑。
/// </summary>
public string RealInstrumentType { get; set; }
public string UnderlyingInstrumentTypeCn => ConsGlobal.InstrumentType.GetDesc(RealInstrumentType ?? UnderlyingInstrumentType);
public string UnderlyingState { get; set; }
@@ -337,6 +461,11 @@ namespace YLErp.Modules.EodModule
public bool IsBond { get; set; }
public long? JSID { get; set; }
/// <summary>
/// 手工改过估值时的操作人IDchina_bond_valuation.update_user)。
/// NULL = 中债自动同步;有值 = 被人手工改过(来源列显示"人工")。
/// 仅债券行可能非空,用于列表来源列区分"人工"/"系统"。
/// </summary>
public long? UpdateUser { get; set; }
}
}
@@ -143,7 +143,7 @@ namespace YLErp.Modules.EodModule.SettlementModule
dto.CommodityPrice.OptDate = DateTime.Now;
dto.CommodityPrice.ClosePrice = closePrice;
dto.CommodityPrice.SettlePrice = settlePrice;
dto.CommodityPrice.DataSource = "系统";
dto.CommodityPrice.DataSource = EodPriceBase.;
}
}
else
@@ -156,9 +156,12 @@ namespace YLErp.Modules.EodModule.SettlementModule
UnderlyingId = dto.RelUnderlyingId,
ClosePrice = closePrice,
SettlePrice = settlePrice,
DataSource = "系统"
DataSource = EodPriceBase.
};
// 入库前强制 UnderlyingId 与 UnderlyingCode(FutureContractId) 一致,避免网页/结算两套 JOIN 失同步。
EodPriceService.SyncUnderlyingIdFromCode(DbContext, dto.CommodityPrice);
DbContext.eod_commodity_future_price.Add(dto.CommodityPrice);
}
@@ -207,7 +210,7 @@ namespace YLErp.Modules.EodModule.SettlementModule
OptDate = DateTime.Now,
ClosePrice = closePrice,
SettlePrice = closePrice,
DataSource = "系统",
DataSource = EodPriceBase.,
};
DbContext.eod_stock_price.Add(dto.StockPrice);
@@ -128,6 +128,10 @@ namespace YLErp.Modules.EodModule
if (item.ReferencePrice.HasValue)
eodPrice.yield = Convert.ToDecimal(item.ReferencePrice);
eodPrice.update_time = DateTime.Now;
// 手工上传也是登录用户的人工动作,戳操作人到已有列(create_user/update_user)
// 使列表来源列显示上传人姓名(与 SaveBondPrice 口径一致);聚源/中债自动同步(外部ETL)不写这两列。
// eodPrice.id==0 表示本次新增(尚未落库),非0为命中已有行的更新。
EodPriceService.StampBondOperator(eodPrice, UserId, eodPrice.id == 0);
result.SuccessCount++;
}
else if (!underlying.CalcTypeIsStock())
@@ -154,6 +158,8 @@ namespace YLErp.Modules.EodModule
eodPrice.ValueDate = item.date;
eodPrice.UnderlyingCode = underlying.UnderlyingCode;
eodPrice.UnderlyingId = underlying.id;
// 入库前强制 UnderlyingId 与 UnderlyingCode(FutureContractId) 一致,避免网页/结算两套 JOIN 失同步。
EodPriceService.SyncUnderlyingIdFromCode(DbContext, eodPrice);
if (item.closePrice.HasValue)
eodPrice.ClosePrice = item.closePrice ?? 0;
if (item.settlePrice.HasValue)
@@ -28,6 +28,13 @@
/// </summary>
public string MarginDetail { get; set; }
public int ClientId { get; set; }
/// <summary>
/// 每日估值报告页面选择的簿记账户。为空时按客户维度生成全量报告;
/// 有值时仅筛选互换估值页的交易所属账户。
/// </summary>
public int? BookId { get; set; }
public DateTime From { get; set; }
public DateTime To { get; set; }
public double PayableMargin { get; set; }
@@ -1,9 +1,11 @@
using BaseOUDAL;
using Newtonsoft.Json;
using OfficeOpenXml;
using OfficeOpenXml.Style;
using Org.BouncyCastle.Ocsp;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
@@ -14,6 +16,7 @@ using YLErp.BLL;
using YLErp.BLL.EodSettlement;
using YLErp.Configuration;
using YLErp.Core.Helpers;
using YLErp.DBModels;
using YLErp.Enums;
using YLErp.Helpers;
using YLErp.Model;
@@ -60,7 +63,16 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule
#endregion
if (emailData.SendContent.Contains("互换估值"))
{
var eodReq = new ClientSwapPositionRequest { ClientId = emailData.ClientId, ValueDate = emailData.To, ValueDateFrom = emailData.From,page=1, rows=10000,StructureType= "普通债券类收益互换" };
var eodReq = new ClientSwapPositionRequest
{
ClientId = emailData.ClientId,
BookId = emailData.BookId,
ValueDate = emailData.To,
ValueDateFrom = emailData.From,
page = 1,
rows = 10000,
StructureType = "普通债券类收益互换"
};
report.EodSwapPositions = swapEodPositionService.SearchEodPositionList(eodReq).rows.ToList();
}
if (emailData.SendContent.Contains("互换持仓明细"))
@@ -134,7 +146,7 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule
});
if (fileTypes.Contains("pdf"))
{
var filepath = GenerateFileEntry(report, "pdf");
var filepath = GenerateFileEntry(report, "pdf", applyFrontendColumnConfig: false);
if (!string.IsNullOrEmpty(filepath))
{
attachFiles.Add(filepath);
@@ -147,7 +159,7 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule
}
else
{
var filepath = GenerateFileEntry(report, "excel");
var filepath = GenerateFileEntry(report, "excel", applyFrontendColumnConfig: false);
if (!string.IsNullOrEmpty(filepath))
{
attachFiles.Add(filepath);
@@ -190,15 +202,15 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule
/// </summary>
/// <param name="report"></param>
/// <param name="type"></param>
/// <param name="html"></param>
/// <param name="applyFrontendColumnConfig">是否按当前用户的前端列配置隐藏并重排互换估值列</param>
/// <returns></returns>
public string GenerateFileEntry(ClientDingShiReport_ShanXi report, string type)
public string GenerateFileEntry(ClientDingShiReport_ShanXi report, string type, bool applyFrontendColumnConfig = true)
{
var filepath = GenerateReportExcel(report, type.ToLower() == "pdf");
var filepath = GenerateReportExcel(report, type.ToLower() == "pdf", applyFrontendColumnConfig);
return filepath;
}
public string GenerateReportExcel(ClientDingShiReport_ShanXi report,bool needToPdf)
public string GenerateReportExcel(ClientDingShiReport_ShanXi report, bool needToPdf, bool applyFrontendColumnConfig = true)
{
var tempFolder = OtcAppContext.MapPath("~/App_Docs/Temp/结算报告");
tempFolder = MosPathHelper.Combine(tempFolder, "");
@@ -208,7 +220,7 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule
Directory.CreateDirectory(targetPath);
}
var clientName = report.client.Name;
var fileName = report.ReportFrom == DateTime.MinValue ? $"证券_估值表_{report.ReportEnd:yyyyMMdd}_{clientName}" : $"证券_估值表_{report.ReportFrom:yyyyMMdd}_{report.ReportEnd:yyyyMMdd}_{clientName}";
var fileName = $"{clientName}_每日估值报告_{report.ReportEnd:yyyyMMdd}";
var targetFileName = Path.Combine(targetPath, $"{fileName}.xlsx");
var excelDeclareModel = new ExcelDeclareModel()
@@ -238,14 +250,22 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule
{
modelDict.Add("互换估值", new
{
// 明细列表供模板渲染;以下 *Sum 字段用于“互换估值”页签合计行。
EodSwapPositions = report.EodSwapPositions,
// 持仓规模、期间收益和利息/分红类金额合计。
PosiNotionalValueSum= report.EodSwapPositions.Sum(x => x.position.PosiNotionalValue),
PosiQuantitySum= report.EodSwapPositions.Sum(x => x.position.PosiQuantity),
PeriodAmountSum= report.EodSwapPositions.Sum(x => x.PeriodAmount),
PeriodAmountSum= report.EodSwapPositions.Sum(x => x.PeriodAmount) ?? 0m,
DividendAmountSum = report.EodSwapPositions.Sum(x => x.DividendAmount) ?? 0m,
InterestAmountSum= report.EodSwapPositions.Sum(x => x.InterestAmount),
PosiFeePendingSum = report.EodSwapPositions.Sum(x => x.position.PosiFeePending),
PosiProfitSum= report.EodSwapPositions.Sum(x => x.position.PosiProfitSum),
// 保证金相关收益和保证金占用金额合计。
MarginInterestAmountSum = report.EodSwapPositions.Sum(x => x.MarginInterestAmount),
OpenMarginAmountSum = report.EodSwapPositions.Sum(x => x.OpenMarginAmount),
AdditionalMarginAmountSum = report.EodSwapPositions.Sum(x => x.AdditionalMarginAmount),
// 净结算金额为日终估值口径;TRS价值在净结算金额基础上叠加期初/追加保证金。
NetSettmentAmountSum= report.EodSwapPositions.Sum(x => x.NetSettmentAmount),
TrsValueSum = report.EodSwapPositions.Sum(x => x.TrsValue),
});
}
if (report.SwapPositions != null)
@@ -287,7 +307,14 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule
string sourceFileName = Path.Combine(sourcePath, $"结算报告模板.xlsx");
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
var pdffile = ExcelTemplate.GeneratePDFFromExeclTemplate(sourcePath, sourceFileName, modelDict, targetPath, targetFileName
, shouldDeleteSheet: true, needToPdf: false);
, shouldDeleteSheet: true, needToPdf: false, callback: sheets =>
{
FormatSwapValuationDisplayCells(sheets, report.EodSwapPositions);
if (applyFrontendColumnConfig)
{
ApplySwapValuationColumnConfig(sheets);
}
});
if (needToPdf)
{
var targetPdfFileName = FileHelper.ReplaceExtension(targetFileName, ".pdf");
@@ -297,6 +324,200 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule
return Path.Combine(targetPath, targetFileName);
}
/// <summary>
/// 部分 Office 版本会将可选小数格式(例如 <c>#,##0.##</c>)错误显示为 <c>20,000.</c>。
/// 互换估值中的这些列是展示字段,不参与 Excel 公式计算,因此在模板替换完成后写为已格式化文本,
/// 以遵守各字段的去尾零或固定小数位展示口径,且避免留下孤立的小数点。
/// </summary>
private static void FormatSwapValuationDisplayCells(IEnumerable<ExcelWorksheet> sheets, IEnumerable<EodSwapPositionResponse> positions)
{
var worksheet = sheets.FirstOrDefault(x => x.Name == "互换估值");
var positionList = positions?.ToList() ?? new List<EodSwapPositionResponse>();
if (worksheet == null || !positionList.Any())
{
return;
}
const int dataStartRow = 2;
for (var index = 0; index < positionList.Count; index++)
{
var row = dataStartRow + index;
var item = positionList[index];
SetTrimmedExcelText(worksheet.Cells[row, 8], item.position.PosiNotionalValue, 2);
SetTrimmedExcelText(worksheet.Cells[row, 9], item.position.PosiQuantity, 9);
SetTrimmedExcelText(worksheet.Cells[row, 10], item.PeriodAmount, 2);
SetTrimmedExcelText(worksheet.Cells[row, 11], item.DividendAmount, 2);
SetTrimmedExcelText(worksheet.Cells[row, 13], item.InitYtm, 4, percent: true);
// 浮动利率(绝对)和预付金利率要求固定保留四位小数,不去尾零。
// 模板首列为空白占位列,渲染后的工作表会移除该列;最终文件中两列分别为 P、T。
SetFixedExcelText(worksheet.Cells[row, 16], item.FloatRateAbs, 4, percent: true);
SetFixedExcelText(worksheet.Cells[row, 20], item.OpenMarginRate, 4, percent: true);
}
var totalRow = dataStartRow + positionList.Count;
SetTrimmedExcelText(worksheet.Cells[totalRow, 8], positionList.Sum(x => x.position.PosiNotionalValue), 2);
SetTrimmedExcelText(worksheet.Cells[totalRow, 10], positionList.Sum(x => x.PeriodAmount) ?? 0m, 2);
SetTrimmedExcelText(worksheet.Cells[totalRow, 11], positionList.Sum(x => x.DividendAmount) ?? 0m, 2);
}
private static void SetTrimmedExcelText(ExcelRange cell, decimal? value, int decimalPlaces, bool percent = false)
{
if (!value.HasValue)
{
cell.Value = null;
return;
}
var displayValue = percent ? value.Value * 100m : value.Value;
var format = "#,##0." + new string('#', decimalPlaces);
var text = displayValue.ToString(format, CultureInfo.InvariantCulture).TrimEnd('.');
cell.Value = percent ? text + "%" : text;
cell.Style.Numberformat.Format = "@";
}
private static void SetFixedExcelText(ExcelRange cell, decimal? value, int decimalPlaces, bool percent = false)
{
if (!value.HasValue)
{
cell.Value = null;
return;
}
var displayValue = percent ? value.Value * 100m : value.Value;
var text = displayValue.ToString("F" + decimalPlaces, CultureInfo.InvariantCulture);
cell.Value = percent ? text + "%" : text;
cell.Style.Numberformat.Format = "@";
}
/// <summary>
/// 每日估值报告的互换估值导出使用当前用户已保存的列配置,
/// 同时同步业务字段的显示状态和列顺序。
/// </summary>
private void ApplySwapValuationColumnConfig(IEnumerable<ExcelWorksheet> sheets)
{
var worksheet = sheets.FirstOrDefault(x => x.Name == "互换估值");
if (worksheet == null)
{
return;
}
var config = configcolumnBLL.GetData(UserId, configcolumn_data.V1);
if (string.IsNullOrWhiteSpace(config?.data))
{
return;
}
List<columnmodel> columns;
try
{
columns = JsonConvert.DeserializeObject<List<columnmodel>>(config.data);
}
catch (JsonException)
{
// 列配置损坏时保留完整的模板字段,不能导致每日估值报告导出失败。
return;
}
if (columns == null || !columns.Any())
{
return;
}
var columnIndexByName = new Dictionary<string, int>
{
["ConfrimNo"] = 1,
["TradeNumber"] = 2,
["position.PosiStartDate"] = 3,
["MaturitySettlementDate"] = 4,
["position.ValueDate"] = 5,
["position.UnderlyingCode"] = 6,
["InterestRate"] = 7,
["position.PosiNotionalValue"] = 8,
["position.PosiQuantity"] = 9,
["PeriodAmount"] = 10,
["DividendAmount"] = 11,
["position.PosiGrossPrice"] = 12,
["InitYtm"] = 13,
["position.UnderlyingPrice"] = 14,
["DayCount"] = 15,
["FloatRateAbs"] = 16,
["InterestAmount"] = 17,
["position.PosiProfitSum"] = 18,
["position.PosiFeePending"] = 19,
["OpenMarginRate"] = 20,
["MarginInterestAmount"] = 21,
["OpenMarginAmount"] = 22,
["AdditionalMarginAmount"] = 23,
["NetSettmentAmount"] = 24,
["TrsValue"] = 25
};
// 前端 _.uniqBy 保留首次出现的配置项,导出按相同规则过滤重复字段,
// 并保留该列表的原始顺序作为 Excel 列顺序。
var visibleColumnIndexes = new List<int>();
var configuredNames = new HashSet<string>();
foreach (var column in columns)
{
if (column == null || string.IsNullOrEmpty(column.name) || !configuredNames.Add(column.name))
{
continue;
}
if (!column.hidden && columnIndexByName.TryGetValue(column.name, out var index))
{
visibleColumnIndexes.Add(index);
}
}
// 与前端一致:旧配置缺少的新增字段默认隐藏;若没有任何业务列可见,
// 前端会回退展示全部默认列,导出保持模板默认顺序。
if (!visibleColumnIndexes.Any())
{
return;
}
ReorderSwapValuationColumns(worksheet, visibleColumnIndexes);
}
/// <summary>
/// 使用临时工作表保存可见列,再按目标顺序复制回原工作表。
/// 原工作表始终保持在模板的 25 列范围内,避免 EPPlus 扩列时触发 ColumnMax 冲突。
/// </summary>
private static void ReorderSwapValuationColumns(ExcelWorksheet worksheet, IReadOnlyList<int> visibleSourceIndexes)
{
var lastRow = worksheet.Dimension.End.Row;
var originalColumnCount = worksheet.Dimension.End.Column;
var workbook = worksheet.Workbook;
var bufferName = "__swap_cols_" + Guid.NewGuid().ToString("N").Substring(0, 12);
var buffer = workbook.Worksheets.Add(bufferName);
try
{
for (var targetIndex = 0; targetIndex < visibleSourceIndexes.Count; targetIndex++)
{
var sourceColumn = visibleSourceIndexes[targetIndex];
var bufferColumn = targetIndex + 1;
worksheet.Cells[1, sourceColumn, lastRow, sourceColumn]
.Copy(buffer.Cells[1, bufferColumn, lastRow, bufferColumn]);
}
for (var targetIndex = 0; targetIndex < visibleSourceIndexes.Count; targetIndex++)
{
var targetColumn = targetIndex + 1;
buffer.Cells[1, targetColumn, lastRow, targetColumn]
.Copy(worksheet.Cells[1, targetColumn, lastRow, targetColumn]);
}
var columnsToDelete = originalColumnCount - visibleSourceIndexes.Count;
if (columnsToDelete > 0)
{
worksheet.DeleteColumn(visibleSourceIndexes.Count + 1, columnsToDelete);
}
}
finally
{
workbook.Worksheets.Delete(buffer);
}
}
/// <summary>
/// 财务状况
/// </summary>
+174 -25
View File
@@ -113,6 +113,11 @@ namespace YLErp.Modules.SwapModule
new TradeUnwindService(this).CloseReCheck_SetTrade(swapTradeId, isSwap, needCheck);
}
protected virtual DateTime GetMaxIncomeValueDate(trade td)
{
return td.ExerciseDate.Value.AddDays(-1);
}
#endregion
public SwapDealService(OptUserInfo optUser) : base(optUser)
@@ -158,6 +163,8 @@ namespace YLErp.Modules.SwapModule
PosiGrossPrice = floatLeg.PosiGrossPrice, // EntryDirtyPrice
TradingAmountAvg = floatLeg.TradingAmountAvg, // ExitDirtyPrice(界面×multiplier形态)
CloseQty = unwindData.CloseQty,
PositionQty = unwindData.PositionQty,
ContractSize = floatLeg.ContractSize,
CloseNotionalValue = unwindData.CloseNotionalValue,
PayDirection = floatLeg.PayDirection,
PositionType = floatLeg.PositionType,
@@ -259,7 +266,10 @@ namespace YLErp.Modules.SwapModule
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
unwindData.CloseMethod = (int)CloseMethodEnum.;
unwindData.ClosePercent = 1;
// 占期初(original)语义(A):默认"平掉剩余全部持仓" = 剩余名义本金/期初名义本金。
// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);部分平仓后自动变为剩余比例(如已平10%则默认90%)。
// 与互换/提前终止 InitIncome(L447) 保持一致。
unwindData.ClosePercent = CalcDefaultInitClosePercent(unwindData.NotionalValue, unwindData.PosiNotionalValue);
unwindData.CloseNotionalValue = unwindData.PosiNotionalValue;
unwindData.CloseQty = unwindData.PositionQty;
if (position != null)
@@ -394,7 +404,8 @@ namespace YLErp.Modules.SwapModule
var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode);
List<int> eventTypes = new List<int>() { (int)SwapFlowEventTypeEnum., (int)SwapFlowEventTypeEnum. };
var dealDate = valuedateBLL.ValueDate < td.ExerciseDate.Value ? valuedateBLL.ValueDate : td.ExerciseDate.Value;
var maxIncomeValueDate = GetMaxIncomeValueDate(td);
var dealDate = valuedateBLL.ValueDate.Date > maxIncomeValueDate.Date ? maxIncomeValueDate : valuedateBLL.ValueDate;
// 收益结算不检查收盘限制
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
td.trade_extend = tradeExtend;
@@ -425,7 +436,7 @@ namespace YLErp.Modules.SwapModule
unwindData.UnwindDate = dealDate;
floatEvent.UnwindDate = unwindData.UnwindDate;
floatEvent.EventDate = dealDate;
unwindData.PayDate = QdpCalendarHelper.GetNonHoliday(unwindData.UnwindDate.Value.AddDays(td.trade_extend.ExtendObj.SettlementRules));
unwindData.PayDate = valuedateBLL.ValueDate;
floatEvent.PayDate = unwindData.PayDate;
floatEvent.SwapTradeId = tradeId;
floatEvent.SwapTradeNo = td.TradeNumber;
@@ -436,7 +447,7 @@ namespace YLErp.Modules.SwapModule
unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity);
unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional);
unwindData.PositionQty = Convert.ToDecimal(td.TradeAmount);
unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount);
unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
unwindData.ClosePercent = unwindData.PosiNotionalValue / unwindData.NotionalValue;
unwindData.CloseNotionalValue = unwindData.PosiNotionalValue;
@@ -464,6 +475,7 @@ namespace YLErp.Modules.SwapModule
}
unwindData.FlowEvents.Add(floatEvent);
}
unwindData.MaxIncomeValueDate = maxIncomeValueDate;
return unwindData;
}
/// <summary>
@@ -493,7 +505,10 @@ namespace YLErp.Modules.SwapModule
var allpositions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid).ToList();
var origPositions = allpositions.Where(x => x.IsInitial).ToList();
var realPostitions = allpositions.Where(x => !x.IsInitial).ToList();
var positions = origPositions.Where(x => x.PosiDirection == 0).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();
@@ -514,6 +529,40 @@ namespace YLErp.Modules.SwapModule
return interests;
}
/// <summary>
/// 解析利息腿(PosiDirection==0)持仓,供 GetUnwindInterests 使用。抽为纯函数以便无库单测。
/// <para>根因(多次部分平仓预付金返还错误):预付金腿(初始/追加)的"当前剩余本金"存于实时持仓
/// realPositions.InterestPrincipalFix,每次平仓由 UpdateInitalPosition 递减;而原始腿
/// origPositions(IsInitial=1)的 InterestPrincipalFix 恒为初始值。GetInterests 算
/// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv(InitSwapDealInterest) 时都读
/// position.InterestPrincipalFix,若沿用原始腿,会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。</para>
/// <para>修复:迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配,
/// 全库实测 eod 均按 orig.id 归档;若换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿
/// Clone 覆盖其本金值为实时腿的剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。
/// 首次平仓时 orig==real 行为不变;仅在发生过部分平仓后 real≠orig 时用实时腿本金纠正。</para>
/// </summary>
/// <param name="origPositions">原始腿(IsInitial=1)全集</param>
/// <param name="realPositions">实时腿(IsInitial=0)全集,其 PositionId 指向对应 orig 的 id</param>
/// <returns>利息腿(PosiDirection==0)列表:预付金腿本金已对齐实时剩余本金,其余保持原始腿</returns>
public static List<swap_position> ResolveInterestLegPositions(List<swap_position> origPositions, List<swap_position> realPositions)
{
realPositions ??= new List<swap_position>();
return origPositions.Where(x => x.PosiDirection == 0).Select(p =>
{
if (p.InterestMode == (int)InterestModeEnum. || p.InterestMode == (int)InterestModeEnum.)
{
var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id);
if (realLeg != null && realLeg.InterestPrincipalFix != p.InterestPrincipalFix)
{
var clone = p.Clone();
clone.InterestPrincipalFix = realLeg.InterestPrincipalFix;
return clone;
}
}
return p;
}).ToList();
}
/// <summary>
/// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。
/// 数据源为事件级 swap_flow_event.InterestAmount(互换/自动互换 完成态事件,互换当时即落库,不依赖日终归档)。
@@ -682,6 +731,42 @@ namespace YLErp.Modules.SwapModule
return (closePrincipal, posiPrincipal, newClosePercent);
}
/// <summary>
/// 平仓比例口径转换(解决"显示占期初 / 计算占剩余"双语义问题)。
/// 前端与事件列表展示用"占期初(original)"语义(A);后端 CalcNotionalByMode / 费用递减 /
/// 全平判定均按"占剩余(remaining)"语义(B)消费。
/// A → BB = A × 期初名义本金(NotionalValue) / 剩余名义本金(PosiNotionalValue),并 cap 到 1。
/// B → A:A = B × 剩余名义本金 / 期初名义本金。
/// 分母为 0(无持仓等异常场景)时原样返回,避免除零。
/// </summary>
public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue)
{
if (posiNotionalValue <= 0) return originalClosePercent;
var remaining = originalClosePercent * notionalValue / posiNotionalValue;
return remaining > 1 ? 1 : remaining;
}
/// <summary>
/// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。
/// </summary>
public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue)
{
if (notionalValue <= 0) return remainingClosePercent;
return remainingClosePercent * posiNotionalValue / notionalValue;
}
/// <summary>
/// 计算 InitUnwind 默认占期初(A)平仓比例 = "平掉剩余全部持仓"对应的占期初比例。
/// 即:ClosePercent(A) = PosiNotionalValue / NotionalValue。
/// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);
/// 部分平仓后自动变为剩余比例(如已平 30% 则默认 0.7)。
/// 与互换/提前终止 InitIncome 保持一致。抽出为纯函数以支持无库单测。
/// </summary>
public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue)
{
return notionalValue > 0 ? posiNotionalValue / notionalValue : 1;
}
/// <summary>
/// 获取固定利率
/// </summary>
@@ -860,6 +945,19 @@ namespace YLErp.Modules.SwapModule
interest.DataState = (int)SwapFlowDateStateEnum.;
interest.ClientId = td.ClientId;
interest.UnwindDate = endDate;
// 根因修复:预付金(保证金)腿的计息基数维度应为"保证金本金"自身,而非整笔交易的名义本金(orginPv)。
// 否则公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv
// 会把交易名义本金(千万~亿级)当减项扣掉,使"应返还本金"(InterestPrincipal)与计息基数变成巨负值。
// 此处将预付金腿的 orginPv 对齐为其自身保证金(InterestPrincipalFix)
// 与日终路径(SwapEodPositionService 对预付金腿 orginPv=InterestPrincipalFix)保持一致。
// 仅作用于初始预付金(5)/追加预付金(6);其它计息模式(含债券本金腿 标的期初全价=9)仍用交易名义本金,不受影响。
if (position.InterestMode == (int)InterestModeEnum.
|| position.InterestMode == (int)InterestModeEnum.)
{
orginPv = position.InterestPrincipalFix;
}
if (swap)
{
interest.InterestAmount = 0;
@@ -994,30 +1092,28 @@ namespace YLErp.Modules.SwapModule
if (!calcLast && accrueDate == endDate) continue; // 到期日不算尾
if (accrueDate > preEodPosition.ValueDate)
{
if (i % interestPeriod == 0)
// 重置日重新获取该段浮动利率;非重置日沿用上一段利率。
// 两分支唯一差异即"是否重取利率",本金口径(只缩放一次)完全一致,
// 合并后消除复制粘贴导致的 closePercent^N 类 bug(原非重置日分支多了一行
// tdDynomicPrincipal = flowEvent.InterestPrincipal 使本金累积乘 closePercent^N)。
if (i % interestPeriod == 0 && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
{
// 获取新的浮动利率
if (!string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0));
if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1))
{
var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0));
if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1))
{
if (floatRate1 != 0) floatRate = floatRate1;
}
else
{
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格");
}
if (floatRate1 != 0) floatRate = floatRate1;
}
else
{
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格");
}
flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent;
TdInterestPrincipal = tdDynomicPrincipal;
}
else
{
flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent;
tdDynomicPrincipal = flowEvent.InterestPrincipal;
TdInterestPrincipal = tdDynomicPrincipal;
}
// 显示本金 = 计息基数 × closePercent(只缩放一次,与日终 ByEod 口径一致);
// 计息基数(tdDynomicPrincipal)逐日恒定、不缩放(单利特征)。
flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent;
TdInterestPrincipal = tdDynomicPrincipal;
flowEvent.FloatRate = Convert.ToDecimal(floatRate);
var interest1 = flowEvent.InterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate));
var tdinterest1 = TdInterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate));
@@ -1171,6 +1267,9 @@ namespace YLErp.Modules.SwapModule
}
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易
// 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。
// 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
bool cofirm = false;
ExecuteInTransaction(() =>
{
@@ -1645,6 +1744,8 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
NormalizeIncomeUnwindDate(unwindData);
ValidateIncomeValueDate(unwindData, td);
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
ValidateFrontendPnL(unwindData, isIncome: true); // 只读校验告警,不阻断交易
ExecuteInTransaction(() =>
@@ -1683,6 +1784,11 @@ namespace YLErp.Modules.SwapModule
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
if (eventType == (int)SwapEventTypeEnum.)
{
NormalizeIncomeUnwindDate(swapEvent.unwindData);
ValidateIncomeValueDate(swapEvent.unwindData, td);
}
var flowList = FindFlowEventsByEventId(swapEvent.id);
string action = eventType == (int)SwapEventTypeEnum. ? ClientCashInCashOut._互换 : ClientCashInCashOut._平仓费;
int clientCashId = AddClientCash(td, Convert.ToDouble(-swapEvent.unwindData.SwapRealizedPnL), action, swapEvent.unwindData.ValueDate);
@@ -1726,15 +1832,52 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
if (eventType == (int)SwapEventTypeEnum.)
{
NormalizeIncomeUnwindDate(unwindData);
ValidateIncomeValueDate(unwindData, td);
}
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
// 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。
// 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。
// 与 SwapUnwind(L1270) 保持一致——缺少此转换会导致 SaveSwapDealInternal 的 B→A 还原出错
// (例如第二次部分平仓 50%(A) → 错误还原为 0.325 而非 0.50)。
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
string action = eventType == (int)SwapEventTypeEnum. ? ClientCashInCashOut._互换 : ClientCashInCashOut._平仓费;
ExecuteInTransaction(() =>
{
CloseReCheckSetTrade(unwindData.SwapTradeId, eventType == (int)SwapEventTypeEnum., true);
SaveSwapDeal(unwindData, eventType, 0, action, true);
SaveAllChanges();
// 需求①:若触发条件判定无需审批(CloseReCheck_SetTrade 已将 ProcessOrderId 设为审批通过),
// 在 swap_event 记录创建完成后再执行审批通过流程。
td = FindTrade(unwindData.SwapTradeId);
if (td.ProcessOrderId == ProcessTradeLog.)
{
new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel
{
tradeId = td.id,
status = "pass",
comments = "触发条件未满足,自动跳过审批",
notNeedOperationHistory = false
});
}
});
}
private void ValidateIncomeValueDate(UnwindData unwindData, trade td)
{
var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date;
if (unwindData.ValueDate.Date > maxIncomeValueDate)
{
throw new ServiceException($"手动互换结算日期不能晚于当前交易结束日期T-1{maxIncomeValueDate:yyyy-MM-dd}");
}
}
private void NormalizeIncomeUnwindDate(UnwindData unwindData)
{
unwindData.UnwindDate = unwindData.ValueDate;
}
/// <summary>
/// 保存平仓/互换事件
/// </summary>
@@ -1749,7 +1892,13 @@ namespace YLErp.Modules.SwapModule
}
var flowList = new List<swap_flow_event>(unwindData.FlowEvents);
unwindData.FlowEvents.Clear();
// 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。
// 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。
var storedClosePercent = ToOriginalClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
var incomingClosePercent = unwindData.ClosePercent;
unwindData.ClosePercent = storedClosePercent;
string data = JsonConvert.SerializeObject(unwindData);
unwindData.ClosePercent = incomingClosePercent;
var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, eventType, data, clientCashId, true, eventResason);//将平仓、互换总额存入事件
foreach (var item in flowList)
{
@@ -1,6 +1,7 @@
using BaseOUDAL;
using Newtonsoft.Json;
using NPOI.POIFS.Properties;
using System;
using System.Linq.Expressions;
using YLErp.DBModels;
using YLErp.DBModels.Consts;
@@ -155,6 +156,36 @@ namespace YLErp.Modules.SwapModule
return UnderlyingCodePrice(code, settleDate, out vobp);
}
/// <summary>
/// 获取用于互换浮动腿盯市的标的价格。
///
/// 普通债券类收益互换的新录入页面将全价按小数保存,例如页面录入 20% 后
/// PosiGrossPrice 为 0.2;而历史交易中仍可能存在直接保存为 20 的展示态价格。
/// 中债估值正常经 EodPriceQueryService 转换后应为小数价格,但手工维护的历史
/// 行情可能仍以展示态进入该服务,例如 2000 经一次转换后得到 20。若将 20
/// 与 0.2 直接相减,会把 20% 的价格差误算成 1,980,000 的浮动损益。
///
/// 因此仅当交易期初价已经是小数口径、且当前债券价明显仍处于展示态时,再做
/// 一次展示态到存储态转换。期初价本身是历史展示态口径的存量交易保持原价格,
/// 避免修改日终估值链路后改变其既有损益。
/// </summary>
private decimal GetSwapValuationPrice(string code, decimal posiGrossPrice, DateTime settleDate, out decimal vobp)
{
var price = GetUnderlyingPrice(code, settleDate, out vobp);
var underlying = GetUnderlyingData(code);
var usesStoragePrice = Math.Abs(posiGrossPrice) < 2m;
var usesDisplayPrice = Math.Abs(price) >= 10m;
if (underlying?.IsBond() == true && usesStoragePrice && usesDisplayPrice)
{
var normalizedPrice = BondPriceConverter.ToStorage(price);
Log.Error($"互换债券日终价格按展示态返回,已转换为存储态: UnderlyingCode={code}, ValueDate={settleDate:yyyy-MM-dd}, PosiGrossPrice={posiGrossPrice}, SourcePrice={price}, NormalizedPrice={normalizedPrice}");
return normalizedPrice;
}
return price;
}
/// <summary>获取标的缓存数据(生产: DataCacheProvider;测试: 返回内存对象)</summary>
protected virtual underlying_manager GetUnderlyingData(string underlyingCode)
{
@@ -167,6 +198,51 @@ namespace YLErp.Modules.SwapModule
return new BondPaymentService(UserInfo).CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
}
// ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)----
/// <summary>查找收盘所需的活跃互换交易(生产: DbContext.trade.Where;测试: 内存列表)</summary>
protected virtual List<trade> FindActiveSwapTrades(DateTime settleDate, IEnumerable<int> clientIds)
{
var tradePredicate = PredicateBuilder.Create<trade>(n => n.ValidState != ConsGlobal.InValid
&& n.TradeType == "收益互换"
&& n.TradeDate <= settleDate
&& n.ExerciseDate >= settleDate
&& (n.TradeStatus == ConsTrade. || n.UnWindDate >= settleDate)
);
if (clientIds != null && clientIds.Any())
{
tradePredicate = tradePredicate.And(x => clientIds.Contains(x.ClientId));
}
return DbContext.trade.Where(tradePredicate).ToList();
}
/// <summary>查找交易的所有持仓(含初始+实际,生产: DbContext.swap_position;测试: 内存列表)</summary>
protected virtual List<swap_position> FindAllSwapPositions(List<int> tradeIds)
{
return DbContext.swap_position.Where(t => tradeIds.Contains(t.SwapTradeId) && !t.Invalid).ToList();
}
/// <summary>批量查找交易扩展(生产: DbContext.trade_extend;测试: 内存列表)</summary>
protected virtual List<trade_extend> FindTradeExtends(List<int> tradeIds)
{
return DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList();
}
/// <summary>查找指定日期的日终汇总(生产: DbContext.eod_swap;测试: 内存列表)</summary>
protected virtual List<eod_swap> FindEodSwapsByDate(DateTime valueDate)
{
return DbContext.eod_swap.Where(x => x.ValueDate == valueDate).ToList();
}
/// <summary>查找交易在指定日期的完成流水事件(生产: DbContext.swap_flow_event;测试: 内存列表)</summary>
protected virtual List<swap_flow_event> FindFlowEvents(int swapTradeId, DateTime settleDate)
{
Expression<Func<swap_flow_event, bool>> eventExpression = x => x.SwapTradeId == swapTradeId
&& x.DataState == (int)SwapFlowDateStateEnum.
&& x.EventDate == settleDate;
return DbContext.swap_flow_event.Where(eventExpression).ToList();
}
#endregion
/// <summary>
@@ -205,28 +281,17 @@ namespace YLErp.Modules.SwapModule
{
var dateStr = settleDate.ToString("yyyy-MM-dd");
Log.Info("SwapPositionCompose:" + "settleDate:" + settleDate + " preSettleDate:" + preSettleDate + " ClientIds:" + JsonHelper.Serialize(ClientIds));
var tradePredicate = PredicateBuilder.Create<trade>(n => n.ValidState != ConsGlobal.InValid
&& n.TradeType == "收益互换"
&& n.TradeDate <= settleDate
&& n.ExerciseDate >= settleDate
&& (n.TradeStatus == ConsTrade. || n.UnWindDate >= settleDate)
);
if (ClientIds != null && ClientIds.Any())
{
tradePredicate = tradePredicate.And(x => ClientIds.Contains(x.ClientId));
}
var tradeQueryList = DbContext.trade.Where(tradePredicate).ToList();
var tradeQueryList = FindActiveSwapTrades(settleDate, ClientIds);
var tradeIds = tradeQueryList.Select(s => s.id).ToList();
var allTradePositionList = DbContext.swap_position.Where(t => tradeIds.Contains(t.SwapTradeId) && !t.Invalid).ToList();
var allTradePositionList = FindAllSwapPositions(tradeIds);
var tradePositionList = allTradePositionList.Where(t => t.IsInitial).ToList();
var tradeRealPositionList = allTradePositionList.Where(t => !t.IsInitial).ToList();
var tradeExtendList = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList();
var eodSwapList = DbContext.eod_swap.Where(x => x.ValueDate == preSettleDate).ToList();
var tradeExtendList = FindTradeExtends(tradeIds);
var eodSwapList = FindEodSwapsByDate(preSettleDate);
List<int> eventTyps = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
foreach (var td in tradeQueryList)
{
var trans = DbContext.Database.BeginTransaction();
try
ExecuteInTransaction(() =>
{
List<int> removeEventTyps = new List<int>() { (int)SwapEventTypeEnum. };
bool longShort = td.StructureType == ClientMarginTypeEnum..ToString();
@@ -243,7 +308,7 @@ namespace YLErp.Modules.SwapModule
{
throw new Exception($"交易{td.TradeNumber}在上一交易日【{preSettleDate:yyyy-MM-dd}】未收盘");
}
var allEodPositions = DbContext.eod_swap_position.Where(x => x.ValueDate >= preSettleDate && x.SwapTradeId == td.id && !x.Invalid);
var allEodPositions = FindEodSwapPositions(td.id, preSettleDate);
var eodPositions = allEodPositions.Where(x => x.ValueDate == preSettleDate).ToList();//上一日终持仓信息
@@ -256,18 +321,7 @@ namespace YLErp.Modules.SwapModule
{
throw new Exception($"交易【{td.TradeNumber}】到期扔有持仓信息");
}
var flowEvents = new List<swap_flow_event>();
Expression<Func<swap_flow_event, bool>> eventExpression = x => x.SwapTradeId == td.id && x.DataState == (int)SwapFlowDateStateEnum.;
eventExpression = eventExpression.And(x => x.EventDate == settleDate);
//if (settleDate == td.TradeDate)
//{
// eventExpression = eventExpression.And(x => x.EventDate == settleDate);
//}
//else
//{
// eventExpression = eventExpression.And(x => x.UnwindDate == settleDate);
//}
flowEvents = DbContext.swap_flow_event.Where(eventExpression).ToList();
var flowEvents = FindFlowEvents(td.id, settleDate);
var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期
List<swap_flow_event> autoInterests = new List<swap_flow_event>();//自动互换利息腿信息
//处理浮动腿
@@ -296,18 +350,8 @@ namespace YLErp.Modules.SwapModule
td.TradeStatus = "已到期";
td.UnWindDate = settleDate;
}
DbContext.SaveChanges();
trans.Commit();
}
catch (Exception ex)
{
trans.Rollback();
throw new Exception(ex.Message, ex);
}
finally
{
trans.Dispose();
}
SaveAllChanges();
});
}
}
@@ -1145,7 +1189,8 @@ namespace YLErp.Modules.SwapModule
{
ratio = -ratio;
}
var lastInterestIncomeSum = eodPayPosition.InterestIncomeSum;
// 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。
var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m;
eodPayPosition = new eod_swap_position();
eodPayPosition.ClientId = td.ClientId;
eodPayPosition.SwapTradeId = td.id;
@@ -1465,7 +1510,7 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.RealizedFee = closeFee;
newEodPayPosition.RealizedMtmPnL = newEodPayPosition.TdCloseMtmPnl;
newEodPayPosition.RealizedDividend = newEodPayPosition.TdCloseDividend;
newEodPayPosition.RealizedPnl = newEodPayPosition.TdCloseMtmPnl;
SetFloatingRealizedPnl(newEodPayPosition);
newEodPayPosition.PosiStatus = payQty == 0 ? 1 : 0;
UpdateDbOption(newEodPayPosition);
@@ -1511,7 +1556,7 @@ namespace YLErp.Modules.SwapModule
int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1;
int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum. ? 1 : -1;
curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0;
var price = GetUnderlyingPrice(eod.UnderlyingCode, dealDate, out decimal vobp);
var price = GetSwapValuationPrice(eod.UnderlyingCode, eod.PosiGrossPrice, dealDate, out decimal vobp);
curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp);
decimal tax = um.ValueAddedTax ?? 0;
if (valueDate > td.StartDate.Value && curretEod.PosiQuantity > 0)
@@ -1540,7 +1585,7 @@ namespace YLErp.Modules.SwapModule
curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl;
curretEod.RealizedDividend = eod.RealizedDividend + curretEod.TdCloseDividend;
curretEod.RealizedFee = eod.RealizedFee + curretEod.TdCloseFee;
curretEod.RealizedPnl = eod.RealizedPnl + curretEod.TdCloseMtmPnl;
SetFloatingRealizedPnl(curretEod);
var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, td.StartDate.Value
, seekPreday: true, currencyRateType: curretEod.PosiDirection == (int)SwapDirectionEnum. ? CurrencyRateType.Buy : CurrencyRateType.Sell);
curretEod.TdCurrency = Convert.ToDecimal(currencyRate);
@@ -1550,10 +1595,22 @@ namespace YLErp.Modules.SwapModule
curretEod.Invalid = false;
if (curretEod.id == 0)
{
DbContext.eod_swap_position.Add(curretEod);
PersistEodSwapPosition(curretEod);
}
return curretEod;
}
/// <summary>
/// 浮动腿累计已实现盈亏由盯市、分红和费用三个已实现组成项汇总。
/// 各组成项已经按本方视角落库,此处不再额外转换方向。
/// </summary>
private static void SetFloatingRealizedPnl(eod_swap_position position)
{
position.RealizedPnl = position.RealizedMtmPnL
+ position.RealizedDividend
+ position.RealizedFee;
}
/// <summary>
/// 更新虚拟交易费用
/// </summary>
@@ -1591,7 +1648,7 @@ namespace YLErp.Modules.SwapModule
var dealDate = curretEod.ValueDate;
int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1;
int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum. ? 1 : -1;
var price = GetUnderlyingPrice(eod.UnderlyingCode, dealDate, out decimal vobp);
var price = GetSwapValuationPrice(eod.UnderlyingCode, eod.PosiGrossPrice, 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);
@@ -1614,7 +1671,6 @@ namespace YLErp.Modules.SwapModule
// 当日浮动端平仓盈亏·分红(仅来自平仓事件 和 互换 中已实现的分红)
curretEod.TdCloseDividend = unwindEvents.Sum(e => e.DividendIn);
curretEod.RealizedFee = eod.RealizedFee + curretEod.TdCloseFee;
curretEod.RealizedPnl = eod.RealizedPnl + curretEod.TdCloseMtmPnl;
curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0;
var closeQty = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.).ToList().Sum(s => s.Quantity);
@@ -1630,7 +1686,7 @@ namespace YLErp.Modules.SwapModule
{
curretEod.PosiDividendSum = 0;
}
curretEod.RealizedPnl += curretEod.TdCloseDividend;
SetFloatingRealizedPnl(curretEod);
curretEod.SwapPositionValue -= curretEod.TdCloseDividend;
curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending;
@@ -1647,7 +1703,7 @@ namespace YLErp.Modules.SwapModule
curretEod.Invalid = false;
if (curretEod.id == 0)
{
DbContext.eod_swap_position.Add(curretEod);
PersistEodSwapPosition(curretEod);
}
return curretEod;
}
@@ -1778,7 +1834,7 @@ namespace YLErp.Modules.SwapModule
curretEod.ContractSize = position.ContractSize;
curretEod.CountRatio = position.CountRatio;
curretEod.PosiTradingFee = position.PosiTradingFee;
curretEod.UnderlyingPrice = GetUnderlyingPrice(position.UnderlyingCode, dealDate, out decimal vobp);
curretEod.UnderlyingPrice = GetSwapValuationPrice(position.UnderlyingCode, position.PosiGrossPrice, dealDate, out decimal vobp);
SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, position);
curretEod.dv01 = Dv01Helper.CalcDv01(curretEod.UnderlyingCode, curretEod.PosiQuantity, curretEod.PosiDirection, curretEod.PositionType, vobp);
//if (settleDate == td.TradeDate)
@@ -1808,7 +1864,7 @@ namespace YLErp.Modules.SwapModule
curretEod.RealizedMtmPnL = curretEod.TdCloseMtmPnl;
curretEod.RealizedDividend = curretEod.TdCloseDividend;
curretEod.RealizedFee = curretEod.TdCloseFee;
curretEod.RealizedPnl = curretEod.TdCloseMtmPnl;
SetFloatingRealizedPnl(curretEod);
curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0;
if (curretEod.PosiStatus == 1)
{
@@ -1821,7 +1877,7 @@ namespace YLErp.Modules.SwapModule
curretEod.TdCurrency = Convert.ToDecimal(currencyRate);
UpdateDbOption(curretEod);
curretEod.Invalid = false;
DbContext.eod_swap_position.Add(curretEod);
PersistEodSwapPosition(curretEod);
return curretEod;
}
/// <summary>
@@ -1885,12 +1941,16 @@ namespace YLErp.Modules.SwapModule
eod_Swap = new eod_swap();
}
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 interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿
var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿
// 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模,
// 不能直接用多空腿相加,否则会把对冲方向误当成合约规模变化。
eod_Swap.NotionalValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue);
eod_Swap.NotionalValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue);
eod_Swap.NotionalValue = eod_Swap.NotionalValueLong + eod_Swap.NotionalValueShort;
eod_Swap.NotionalValueShort = -Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue));
eod_Swap.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional);
eod_Swap.SwapTradeId = td.id;
eod_Swap.SwapTradeNo = td.TradeNumber;
eod_Swap.ClientId = td.ClientId;
@@ -1902,6 +1962,8 @@ namespace YLErp.Modules.SwapModule
eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum);
eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0);
decimal interestPnL = 0;
// 利息腿按我方视角归集。保证金腿的利息现金流方向与普通利息腿相反,
// 因此保证金腿需要额外反转符号,确保 InterestPnL 表示我方的合约利率端收益。
interestPositions.ForEach(x =>
{
decimal ratio = x.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1;//收取为正,支付为负
@@ -1913,7 +1975,10 @@ namespace YLErp.Modules.SwapModule
});
eod_Swap.InterestPnL = interestPnL;
eod_Swap.PostionValue = eodSwapPositions.Sum(s => s.SwapPositionValue);
eod_Swap.RealizedPnL = eodSwapPositions.Sum(s => s.RealizedPnl);
// 保证金腿的利息现金流方向与保证金本金方向相反。
// 不能直接汇总 RealizedPnl,否则“收取客户保证金”的腿会把应支付给客户的
// 利息作为收益相加。逐腿按利息方向转换后再生成框架合约已实现收益。
eod_Swap.RealizedPnL = eodSwapPositions.Sum(CalculateSwapRealizedPnl);
eod_Swap.TdRealizedPnL = eod_Swap.RealizedPnL - (preEodSwap?.RealizedPnL ?? 0);
eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty);
var initMargin = Convert.ToDecimal(tradeSpan?.InitialMargin ?? 0);
@@ -1956,12 +2021,13 @@ namespace YLErp.Modules.SwapModule
eod_Swap.ValueDate = settleDate;
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 interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿
var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿
eod_Swap.NotionalValue = Convert.ToDecimal(td.StockEqvNotional);
eod_Swap.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional);
eod_Swap.NotionalValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue);
eod_Swap.NotionalValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue);
eod_Swap.NotionalValueShort = -Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue));
eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue);
eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue);
eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum);
@@ -1985,7 +2051,7 @@ namespace YLErp.Modules.SwapModule
eod_Swap.TdRealizedPnL += x.TdCloseMtmPnl + x.TdCloseDividend + x.TdCloseFee + x.TdCloseInterest * ratio + x.TdCloseInterestFee;
});
eod_Swap.PostionValue = eodSwapPositions.Sum(s => s.SwapPositionValue);
eod_Swap.RealizedPnL = eodSwapPositions.Sum(s => s.RealizedMtmPnL + s.RealizedDividend + s.RealizedFee + s.RealizedInterest + s.RealizedInterestFee);
eod_Swap.RealizedPnL = eodSwapPositions.Sum(CalculateSwapRealizedPnl);
eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty);
var tradeInitMarginObj = DbContext.trade_initial_margin.FirstOrDefault(x => x.TradeId == td.id);
var initMarginList = interestPositions.Where(x => x.InterestMode == (int)InterestModeEnum. && x.HappenDate == settleDate).ToList();
@@ -1998,6 +2064,28 @@ namespace YLErp.Modules.SwapModule
DbContext.SaveChanges();
}
/// <summary>
/// 汇总单条日终腿的我方已实现收益。
/// 浮动腿及普通利息腿维持数据库记录的方向;初始/追加预付金腿的利息
/// 则与保证金本金方向相反。这样“收取对手方保证金”产生的利息会作为
/// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。
/// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。
/// </summary>
public static decimal CalculateSwapRealizedPnl(eod_swap_position position)
{
var interestRatio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;
if (ConsTrade.InterestMarginModels.Contains(position.InterestMode))
{
interestRatio = -interestRatio;
}
return position.RealizedMtmPnL
+ position.RealizedDividend
+ position.RealizedFee
+ position.RealizedInterest * interestRatio
+ position.RealizedInterestFee;
}
/// <summary>
/// 获取多空组合 平仓详细
/// </summary>
@@ -2149,10 +2237,88 @@ namespace YLErp.Modules.SwapModule
}
DbContext.SetDebugLog();
var retListResult = query.ToSearchList(req);
var tradeIds = retListResult.rows.Select(x => x.position.SwapTradeId).Distinct().ToList();
var valueDates = retListResult.rows.Select(x => x.position.ValueDate).Distinct().ToList();
var tradeNotionals = DbContext.trade
.Where(x => tradeIds.Contains(x.id))
.Select(x => new { x.id, x.OriginalStockEqvNotional, x.StockEqvNotional })
.ToDictionary(x => x.id);
var eodPositionDetails = DbContext.eod_swap_position
.Where(x => tradeIds.Contains(x.SwapTradeId) && valueDates.Contains(x.ValueDate) && !x.Invalid)
.ToList();
var tradeExtends = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList();
var underlyingDataSource = DataCacheProvider.GetUnderlyingDataSource();
var varietyDataSource = DataCacheProvider.GetVarietyDataSource();
foreach (var item in retListResult.rows)
{
item.position.NotionalValueShort = -Math.Abs(item.position.NotionalValueShort);
if (tradeNotionals.TryGetValue(item.position.SwapTradeId, out var tradeNotional))
{
item.position.NotionalValue = Convert.ToDecimal(tradeNotional.OriginalStockEqvNotional ?? tradeNotional.StockEqvNotional);
}
var client = DataCacheProvider.GetClientDataSource().GetData(item.ClientId);
item.SwapTradeTypeStr = client?.SwapTradeTypeStr;
var details = eodPositionDetails
.Where(x => x.SwapTradeId == item.position.SwapTradeId && x.ValueDate == item.position.ValueDate)
.ToList();
var floatingLegs = details.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();
var marginLegs = details.Where(x => marginTypes.Contains(x.InterestMode)).ToList();
var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId);
var dividendPayDate = tradeExtend?.ExtendObj?.DividendPayDate ?? 1;
item.UnderlyingType = string.Join(",", floatingLegs
.Select(x =>
{
var underlying = underlyingDataSource.GetData(x.UnderlyingCode);
return varietyDataSource.GetData(underlying?.UnderlyingTypeId ?? 0)?.AssetType
?? underlying?.UnderlyingInstrumentTypeCn
?? underlying?.UnderlyingType;
})
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct());
item.PeriodAmount = floatingLegs.Sum(x => x.RealizedDividend + x.PosiDividendSum);
// PosiProfitSum = 标的盯市收益 + 未结交易费用 + 待实现付息/分红。
// 风险页的“合约浮动端待实现收益”需要保留未结交易费用,
// 但期间付息/分红由 PeriodAmount 单列展示并参与对应估值口径,
// 因此仅扣除 PosiDividendSum,不能直接使用 PosiMtmPnL。
item.FloatingUnrealizedPnl = floatingLegs.Sum(x => x.PosiProfitSum - x.PosiDividendSum);
item.InterestPaymentMethod = dividendPayDate == 0 ? "到期轧差" : "派息日支付";
if (dividendPayDate == 0)
{
item.MaturityNettingValuation = item.FloatingUnrealizedPnl + item.position.InterestPnL + item.PeriodAmount;
}
else
{
item.PeriodPaymentValuation = item.FloatingUnrealizedPnl + item.position.InterestPnL;
}
// eod_swap 的保证金本金来自 trade_span;缺少 span 数据时会被保存为 0。
// 本风险页改按日终保证金腿展示,且该页面保证金本金采用原始本金的 1/10 口径。
// 利息仍使用原始本金累积值,不能同步缩放,否则会破坏保证金利息金额。
item.position.InitMarginGain = marginLegs
.Where(x => x.InterestMode == (int)InterestModeEnum.
&& x.InterestDirection == (int)SwapDirectionEnum.)
.Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m;
item.position.InitMarginLoss = marginLegs
.Where(x => x.InterestMode == (int)InterestModeEnum.
&& x.InterestDirection == (int)SwapDirectionEnum.)
.Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m;
item.position.PostionMarginGain = marginLegs
.Where(x => x.InterestMode == (int)InterestModeEnum.
&& x.InterestDirection == (int)SwapDirectionEnum.)
.Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m;
item.position.PostionMarginLoss = marginLegs
.Where(x => x.InterestMode == (int)InterestModeEnum.
&& x.InterestDirection == (int)SwapDirectionEnum.)
.Sum(x => Math.Abs(x.InterestPrincipalFix)) / 10m;
// 保证金本金方向与我方的利息现金流方向相反:原始“收取”保证金
// 表示我方占用客户资金,应向客户支付利息;支付金额按负数展示。
item.MarginInterestGain = marginLegs
.Where(x => x.InterestDirection == (int)SwapDirectionEnum.)
.Sum(x => Math.Abs(x.InterestIncomeSum));
item.MarginInterestLoss = marginLegs
.Where(x => x.InterestDirection == (int)SwapDirectionEnum.)
.Sum(x => -Math.Abs(x.InterestIncomeSum));
}
var dv01 = query.Sum(O => O.position.dv01??0);
@@ -2287,6 +2453,7 @@ namespace YLErp.Modules.SwapModule
private SearchListResult<EodSwapPositionResponse> GetSearchEodPositionList(ClientSwapPositionRequest req)
{
// 每日估值报告以有数量的浮动腿为主记录;利息腿和保证金腿仅作为同交易、同估值日的辅助数据参与汇总。
var predicate = PredicateBuilder.Create<eod_swap_position>(n => !n.Invalid && n.PosiQuantity > 0);
var interestPredicate = PredicateBuilder.Create<eod_swap_position>(n => !n.Invalid && n.InterestDirection > 0);
var tradePredicate = PredicateBuilder.Create<trade>(n => n.ValidState != "InValid");
@@ -2297,10 +2464,15 @@ namespace YLErp.Modules.SwapModule
{
predicate = predicate.And(x => x.ClientId == req.ClientId);
}
if (req.ValueDateFrom != null)
if (req.BookId > 0)
{
predicate = predicate.And(x => x.ValueDate >= req.ValueDateFrom);
tradePredicate = tradePredicate.And(x => x.AssetId == req.BookId.Value);
}
// ValueDateFrom 保留在请求模型中,但当前互换估值查询按 ValueDate 单日取数。
// if (req.ValueDateFrom != null)
// {
// predicate = predicate.And(x => x.ValueDate >= req.ValueDateFrom);
// }
if (req.ValueDate != null)
{
predicate = predicate.And(x => x.ValueDate == req.ValueDate);
@@ -2330,53 +2502,132 @@ namespace YLErp.Modules.SwapModule
}
var retListResult = query.ToSearchList(req);
var tradeIds = retListResult.rows.Select(s => s.position.SwapTradeId).ToList();
if (!tradeIds.Any())
{
return retListResult;
}
interestPredicate = interestPredicate.And(x => tradeIds.Contains(x.SwapTradeId));
var valueDates = retListResult.rows.Select(s => s.position.ValueDate).Distinct().ToList();
interestPredicate = interestPredicate.And(x => valueDates.Contains(x.ValueDate));
// 主查询分页后再取同交易、同估值日的全部辅助腿,避免利息/保证金归集跨估值日串数据。
var eodPositions = DbContext.eod_swap_position.Where(interestPredicate).ToList();
var positions = DbContext.swap_position.Where(x => tradeIds.Contains(x.SwapTradeId) && x.InterestMode == (int)InterestModeEnum. && x.IsInitial && !x.Invalid).ToList();
var marginPositions = DbContext.swap_position
.Where(x => tradeIds.Contains(x.SwapTradeId) && marginTypes.Contains(x.InterestMode) && x.IsInitial && !x.Invalid)
.ToList();
var tradeExtends = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList();
Dictionary<string, bool> tradeDic = new Dictionary<string, bool>();
foreach (var item in retListResult.rows)
{
var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId);
var eventDate = item.position.ValueDate;
if (tradeExtend != null)
{
eventDate = QdpCalendarHelper.GetNonHoliday(eventDate.AddDays(tradeExtend.ExtendObj.SettlementRules));
}
item.DayCount = Math.Max(0, (eventDate - item.position.PosiStartDate).Days + 1);
// 到期结算日按合同到期日展示;实际期限按自然日且包含起始日,二者均不使用结算规则偏移。
item.MaturitySettlementDate = item.position.PosiMatuirityDate;
item.DayCount = Math.Max(0, (item.position.ValueDate - item.position.PosiStartDate).Days + 1);
//item.position.PosiProfitSum += item.position.VTradingFee-item.position.PosiFeePending;
SetClientEodPosition(item.position);
//item.position.PosiProfitSum += item.TradingFee;
var posiProfitSum = item.position.PosiProfitSum;
//item.position.PosiProfitSum 不需要加交易费用
item.position.PosiProfitSum = item.position.PosiProfitSum - item.position.PosiFeePending - item.position.PosiDividendSum;
item.NetSettmentAmount = item.position.PosiProfitSum + item.position.PosiDividendSum + item.position.PosiFeePending;
item.PeriodAmount = item.position.PosiDividendSum;
var margins = positions.Where(x => x.SwapTradeId == item.position.SwapTradeId);
// PosiProfitSum 原值包含交易费用和期间付息/分红。先拆出这两部分,
// 使“浮动收益金额”仅反映标的盯市收益,后续净额公式再按支付方式决定是否加回期间金额。
var pendingDividend = item.position.PosiDividendSum;
item.position.PosiProfitSum = item.position.PosiProfitSum - item.position.PosiFeePending - pendingDividend;
// 现券仅展示期间付息和期初成交收益率;ETF(标的主数据类型 Fund)仅展示期间分红。
// 其余标的的三列均不适用,返回 null 使页面和 Excel 模板保持空白,而不是展示 0。
var isCashBond = ConsGlobal.InstrumentType.IsBond(item.position.UnderlyingInstrumentType);
var isEtf = ConsGlobal.InstrumentType.Fund.Equals(
item.position.UnderlyingInstrumentType,
StringComparison.OrdinalIgnoreCase);
if (isCashBond)
{
item.PeriodAmount = pendingDividend;
item.DividendAmount = null;
// 期初标的成交收益率是债券现券成交口径,非现券不展示该交易录入值。
}
else if (isEtf)
{
item.PeriodAmount = null;
item.DividendAmount = pendingDividend;
}
else
{
item.PeriodAmount = null;
item.DividendAmount = null;
}
if (!isCashBond)
{
item.InitYtm = null;
}
// 预付金本金和利率来自交易腿,并以发生日判断在估值日是否已生效;
// 预付金利息则来自当日日终腿,以获得截至估值日的 InterestIncomeSum。
var tradeMargins = marginPositions
.Where(x => x.SwapTradeId == item.position.SwapTradeId
&& (!x.HappenDate.HasValue || x.HappenDate.Value <= item.position.ValueDate))
.ToList();
var interests = eodPositions.Where(x => x.SwapTradeId == item.position.SwapTradeId && x.ValueDate == item.position.ValueDate);
var eodMargins = interests.Where(x => marginTypes.Contains(x.InterestMode));
var eodInterests = interests.Where(x => !marginTypes.Contains(x.InterestMode));
var eodMargins = interests.Where(x => marginTypes.Contains(x.InterestMode)).ToList();
var eodInterests = interests.Where(x => !marginTypes.Contains(x.InterestMode)).ToList();
var initialMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.).ToList();
var additionalMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.).ToList();
var floatRateInterest = eodInterests.Where(x => !string.IsNullOrEmpty(x.FloatRateUnderlyingCode)).FirstOrDefault();
item.position.FloatRateUnderlyingCode = floatRateInterest?.FloatRateUnderlyingCode;
item.position.FloatRate = floatRateInterest?.FloatRate ?? 0;
item.OpenMarginAmount = margins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1));
item.OpenMarginRate = margins.Sum(s => s.InterestRateDefault * (s.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1));
item.MarginInterestAmount = eodMargins.Sum(s => s.InterestIncomeSum * (s.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1));
item.OpenMarginAmount = initialMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1));
item.OpenMarginRate = CalculateWeightedMarginRate(tradeMargins);
item.AdditionalMarginAmount = additionalMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1));
item.MarginInterestAmount = CalculateWeightedMarginInterest(eodMargins);
item.InterestAmount = eodInterests.Sum(s => s.InterestIncomeSum * (s.InterestDirection == (int)SwapDirectionEnum. ? -1 : 1));
item.InterestRate = eodInterests.Sum(s => s.InterestRateDefault);
item.NetSettmentAmount += item.InterestAmount + item.MarginInterestAmount + eodMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1));
// 到期轧差才把期间付息/分红并入净额结算;派息日支付已在现金流层独立结算,不能重复计入估值。
var nettingDividend = (tradeExtend?.ExtendObj?.DividendPayDate ?? 1) == 0 ? pendingDividend : 0m;
item.NetSettmentAmount = item.InterestAmount
+ item.position.PosiProfitSum
+ item.position.PosiFeePending
+ item.MarginInterestAmount
+ nettingDividend;
item.NetSettmentAmount = Math.Round(item.NetSettmentAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
item.TrsValue = Math.Round(item.NetSettmentAmount + item.OpenMarginAmount + item.AdditionalMarginAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
if (item.position.PosiNotionalValue != 0 && item.position.PosiNetPrice != 0)
{
item.FloatRateAbs = item.position.PosiNotionalValue == 0 ? 0 : item.InterestAmount / item.position.PosiNotionalValue;
}
SetPosiPrice(item.position);
// 交易录入的债券类收益互换价格以小数保存,展示时转为百分比价格;
// 普通收益互换录入的是数量/原始数值,不做乘 100 转换。
SetPosiPrice(item.position, item.StructureType == "普通债券类收益互换");
}
return retListResult;
}
/// <summary>
/// 设置客户视角
/// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权,
/// 不按收付方向轧差,避免相反方向本金抵消后放大利率。
/// </summary>
private static decimal CalculateWeightedMarginRate(IEnumerable<swap_position> margins)
{
var marginList = margins.ToList();
var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix));
return totalWeight == 0
? 0
: marginList.Sum(x => x.InterestRateDefault * Math.Abs(x.InterestPrincipalFix)) / totalWeight;
}
/// <summary>
/// 计算预付金利息。先按收取为正、支付为负转换为我方视角,
/// 再按日终本金绝对值加权平均;本金合计为零时返回零。
/// </summary>
private static decimal CalculateWeightedMarginInterest(IEnumerable<eod_swap_position> margins)
{
var marginList = margins.ToList();
var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix));
return totalWeight == 0
? 0
: marginList.Sum(x => x.InterestIncomeSum
* (x.InterestDirection == (int)SwapDirectionEnum. ? 1 : -1)
* Math.Abs(x.InterestPrincipalFix)) / totalWeight;
}
/// <summary>
/// 将数据库中以公司/交易簿记方向保存的日终字段转换为客户视角。
/// 该转换必须在拆分浮动收益、费用和期间付息/分红之前完成,
/// 否则页面、Excel 和净额结算金额会出现相反符号。
/// </summary>
/// <param name="position"></param>
private void SetClientEodPosition(eod_swap_position position)
@@ -2399,10 +2650,10 @@ namespace YLErp.Modules.SwapModule
position.SwapPositionValue = -position.SwapPositionValue;
position.PosiDividendSum = -position.PosiDividendSum;
}
private void SetPosiPrice(eod_swap_position position)
private void SetPosiPrice(eod_swap_position position, bool? useBondPriceScale = null)
{
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(position.UnderlyingCode);
if (um != null && um.IsBond())
if (useBondPriceScale ?? (um != null && um.IsBond()))
{
position.PosiNetPrice *= 100;
position.UnderlyingPrice *= 100;
@@ -110,7 +110,7 @@ namespace YLErp.Modules.SwapModule
frdata.ValueDate = dateTime;
frdata.UnderlyingCode = "FR007";
frdata.UnderlyingId = newestdata.UnderlyingId;
frdata.DataSource = "人工";
frdata.DataSource = EodPriceBase.;
DbContext.Add(frdata);
}
frdata.ClosePrice = Math.Round(price, 4);
@@ -411,7 +411,7 @@ namespace YLErp.Modules.SwapModule
/// 合成持仓/日终归档 清除互换持仓所有信息
/// </summary>
/// <param name="tradeId"></param>
public void ClearSwapPositions(trade td, DateTime valueDate, List<int> eventTypes, bool delAfter)
public virtual void ClearSwapPositions(trade td, DateTime valueDate, List<int> eventTypes, bool delAfter)
{
var swapEvents = DbContext.swap_event.Where(x => x.SwapTradeId == td.id && x.ValueDate >= valueDate && eventTypes.Contains(x.EventType));
var eventIds = swapEvents.Select(s => s.id).ToList();
@@ -6,7 +6,9 @@ using System.Linq;
using System.Linq.Expressions;
using YLErp.BLL.Eod;
using YLErp.DBModels;
using YLErp.Helpers;
using YLErp.Model.Enum;
using YLErp.Modules.TradeModule;
namespace YLErp.Modules.SystemModule
{
@@ -43,7 +45,9 @@ namespace YLErp.Modules.SystemModule
approvalGroupId = item.approvalGroupId,
node = item.node,
parentNode = item.parentNode,
approvalCondition = item.approvalCondition
approvalCondition = item.approvalCondition,
conditionConfig = item.conditionConfig,
triggerCondition = item.triggerCondition
}).ToList();
DbContext.approvalprocess.AddRange(list);
@@ -81,6 +85,21 @@ namespace YLErp.Modules.SystemModule
}
}
}
else if (type == ProcessCategoryConst.Close) // 需求②:了结/平仓/行权审批流程
{
if (data != null && data.Count > 0)
{
ChangeTradeProcess(data, delList);
}
else
{
var trade = DbContext.trade.Where(x => x.ValidState == "Valid" && (x.TradeStatus == ConsTrade. || x.TradeStatus == ConsTrade. || x.TradeStatus == ConsTrade.)).ToList();
if (trade != null && trade.Count > 0)
{
throw new ServiceException("有交易在审批中,不能删除审批流程!");
}
}
}
else if (type == "CreditProcess")
{
if (data != null && data.Count == 0)
@@ -578,7 +597,8 @@ namespace YLErp.Modules.SystemModule
{
var roles = UserBLL.GetRolesByUserId(userId).Select(o => o.Id);
var groupId = UserBLL.GetApprovalProcessGroup(userId);
var tradeProcess = TradeProcess();
// 需求②:按交易状态推断开仓/了结流程。
var tradeProcess = TradeProcessByCategory(trade);
bool approvalBranch = tradeProcess.Any(x => x.approvalGroupId != 0);//审批流程有分支情况
trade.ProcessOrderBranch = 0;
if (trade.ProcessOrderId <= 0)
@@ -612,6 +632,16 @@ namespace YLErp.Modules.SystemModule
trade.ProcessOrderId = ProcessTradeLog.;
}
}
// 需求①:开仓交易首次进入审批时,应用触发条件——跳过起始就不满足触发条件的节点。
// 若所有节点均不满足 → 直接审批通过,无需审核。
if (tradeProcess.Count > 0)
{
ApplyTriggerOnStart(tradeProcess, trade, userId);
if (trade.ProcessOrderId == ProcessTradeLog.)
{
return 0;
}
}
// 如果是审批组
var orderIdCount = tradeProcess.Where(x => x.order == trade.ProcessOrderId);//判断是否有分支
int processOrderNode = orderIdCount.Count() > 1 ? trade.ProcessOrderBranch : 0;
@@ -658,6 +688,47 @@ namespace YLErp.Modules.SystemModule
return -2;
}
/// <summary>
/// 需求①:开仓交易首次进入审批流程时,从起始节点应用触发条件。
/// <para>跳过起始就不满足触发条件的节点;若所有节点均不满足 → 直接审批通过。</para>
/// </summary>
private void ApplyTriggerOnStart(List<approvalprocess> tradeProcess, trade trade, int userId)
{
// 取当前起点节点(主干 node=0)
var start = tradeProcess.FirstOrDefault(x => x.order == trade.ProcessOrderId && x.node == 0);
if (start == null)
{
start = tradeProcess.FirstOrDefault(x => x.order >= trade.ProcessOrderId && x.node == 0);
}
if (start == null) return;
// 构建求值上下文(开仓场景无 trade_cashCurrentNotional 不设)
var ctx = new ConditionContext
{
UserId = userId,
Trade = trade,
ProcessCategory = ProcessCategoryConst.Resolve(trade),
InitGroupId = UserBLL.GetApprovalProcessGroup(userId)
};
var target = ConditionEvaluator.FindFirstTriggeredNode(tradeProcess, start, ctx);
if (target == null)
{
// 所有节点都不满足触发条件 → 直接审批通过
trade.ProcessOrderId = ProcessTradeLog.;
trade.CheckTradeUpdate = Convert.ToInt32(TradeCheckEnum.StatusOfNew);
trade.ProcessOptDate = DateTime.Now;
trade.ProcessStatus = ProcessTradeStatus..ToString();
return;
}
if (target.order != trade.ProcessOrderId)
{
trade.ProcessOrderId = target.order;
}
}
/// <summary>
/// 获取所有交易审批节点
/// </summary>
@@ -668,6 +739,21 @@ namespace YLErp.Modules.SystemModule
return tradeOrders;
}
/// <summary>
/// 按交易推断流程类别(开仓/了结)获取审批节点(需求②)。
/// <para>了结类优先取 CloseProcess;未配置则回退 TradeProcess。</para>
/// </summary>
public List<approvalprocess> TradeProcessByCategory(trade td)
{
var category = ProcessCategoryConst.Resolve(td);
var orders = DbContext.approvalprocess.Where(t => t.processType == category).OrderBy(o => o.order).ToList();
if (category == ProcessCategoryConst.Close && orders.Count == 0)
{
return TradeProcess();
}
return orders;
}
}
/// <summary>
@@ -686,6 +772,12 @@ namespace YLErp.Modules.SystemModule
public int node { get; set; }
public int parentNode { get; set; }
public int approvalCondition { get; set; }
/// <summary>分支网关条件(JSON),需求①③共用。为空则回退旧 approvalGroupId/approvalCondition 二元判断。</summary>
public string conditionConfig { get; set; }
/// <summary>节点触发条件(JSON),需求①:满足才进入该审批节点,不满足则跳过。</summary>
public string triggerCondition { get; set; }
}
/// <summary>
/// 审批流程修改节点
@@ -224,7 +224,7 @@ namespace YLErp.Modules.TradeModule.DealModule
#endregion
//审批流程
//审批流程(需求②:按交易状态查对应流程)
if (!req.SkipWorkflow && HasTradeProcess())
{
var isUnwind = tradeCash.Action.Contains("平仓");
@@ -235,14 +235,33 @@ namespace YLErp.Modules.TradeModule.DealModule
dbTrade.CheckStatus = Convert.ToInt32(TradeCheckEnum.StatusOfOld);
//检查是否有交易审批流程
if (valuedateBLL.SystemDate.CloseReApprove == 1)
//检查是否有交易审批流程(需求②:按交易状态查对应流程,而非只查开仓流程)
var needApproval = valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(dbTrade) > 0;
if (needApproval)
{
// 如果有审批
InitTradeProcessOrder(dbTrade, UserId);
// 需求①:进入审批前先判断触发条件——若所有节点都不满足触发条件,跳过审批
needApproval = NeedApprovalByTrigger(dbTrade);
}
AddTradeOperationHistoryAndSetParentTradeInfo(false, dbTrade, optType: isUnwind ? "平仓审核提交" : "行权审核提交", comments: req.ImportFlag);
if (needApproval)
{
// 需要审批:进入审批流程等待人工审批
InitTradeProcessOrder(dbTrade, UserId);
AddTradeOperationHistoryAndSetParentTradeInfo(false, dbTrade, optType: isUnwind ? "平仓审核提交" : "行权审核提交", comments: req.ImportFlag);
}
else
{
// 不需要审批:设 ProcessOrderId=审批通过,再调 UpdateTradeProcessLog(pass) 执行审批通过流程
dbTrade.ProcessOrderId = ProcessTradeLog.;
DbContext.SaveChanges();
new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel
{
tradeId = dbTrade.id,
status = "pass",
comments = "触发条件未满足,自动跳过审批",
notNeedOperationHistory = false
});
}
}
else
{
@@ -1,4 +1,5 @@
using YLErp.BLL;
using YLErp.Helpers;
using YLErp.Model;
using YLErp.Model.Enum;
using YLErp.Modules.TradeDalModule;
@@ -115,25 +116,40 @@ namespace YLErp.Modules.TradeModule.DealModule
td.OptId = UserId;
td.OptName = UserName;
td.OptDate = OptDate;
if (isFromExercise)
{
td.TradeStatus = ConsTrade.;
}
else
{
td.TradeStatus = ConsTrade.;
}
td.CheckStatus = Convert.ToInt32(TradeCheckEnum.StatusOfOld);
//检查是否有交易审批流程
if (valuedateBLL.SystemDate.CloseReApprove == 1 && hasTradeProcess)
// 先设状态,使流程类别能正确推断为 CloseProcess(需求②按交易状态推断开仓/了结)
td.TradeStatus = isFromExercise ? ConsTrade. : ConsTrade.;
//检查是否有交易审批流程(需求②:平仓/行权按交易状态查对应流程,而非只查开仓流程)
var needApproval = valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(td) > 0;
if (needApproval)
{
// 如果有审批
InitTradeProcessOrder(td, UserId);
// 需求①:进入审批前先判断触发条件——若所有节点都不满足触发条件,跳过审批
needApproval = NeedApprovalByTrigger(td);
}
AddTradeOperationHistoryAndSetParentTradeInfo(false, td, isFromExercise ? "部分行权审核提交" : "平仓审核提交");
if (needApproval)
{
// 需要审批:保持"平仓待复核/行权待复核",进入审批流程等待人工审批
InitTradeProcessOrder(td, UserId);
AddTradeOperationHistoryAndSetParentTradeInfo(false, td, isFromExercise ? "部分行权审核提交" : "平仓审核提交");
}
else
{
// 不需要审批:设 ProcessOrderId=审批通过,再调 UpdateTradeProcessLog(pass) 执行审批通过流程
// SetTradeOpen 会完成平仓、改状态、写日志,与人工审批通过完全一致)
td.ProcessOrderId = ProcessTradeLog.;
DbContext.SaveChanges();
new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel
{
tradeId = td.id,
status = "pass",
comments = "触发条件未满足,自动跳过审批",
notNeedOperationHistory = false
});
}
}
}
@@ -1,4 +1,5 @@
using YLErp.BLL;
using YLErp.Helpers;
using YLErp.Model;
using YLErp.Model.Enum;
@@ -105,12 +106,37 @@ namespace YLErp.Modules.TradeModule.DealModule
{
var td = DbContext.trade.Find(tradeId);
td.OptDate = DateTime.Now;
td.TradeStatus = ConsTrade.;
td.CheckStatus = Convert.ToInt32(TradeCheckEnum.StatusOfOld);
if (valuedateBLL.SystemDate.CloseReApprove == 1 && hasTradeProcess)
// 先设状态,使流程类别能正确推断为 CloseProcess
td.TradeStatus = ConsTrade.;
// 需求②:行权按交易状态查对应流程
var needApproval = valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(td) > 0;
if (needApproval)
{
//如果有审批
InitTradeProcessOrder(td,UserId);
// 需求①:进入审批前先判断触发条件——若所有节点都不满足触发条件,跳过审批
needApproval = NeedApprovalByTrigger(td);
}
if (needApproval)
{
// 需要审批:保持"行权待复核",进入审批流程等待人工审批
InitTradeProcessOrder(td, UserId);
}
else
{
// 不需要审批:设 ProcessOrderId=审批通过,再调 UpdateTradeProcessLog(pass) 执行审批通过流程
td.ProcessOrderId = ProcessTradeLog.;
DbContext.SaveChanges();
new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel
{
tradeId = td.id,
status = "pass",
comments = "触发条件未满足,自动跳过审批",
notNeedOperationHistory = false
});
return;
}
DbContext.SaveChanges();
@@ -140,7 +140,9 @@ namespace YLErp.Modules.TradeModule.DealModule
#region
var finalPrice = EodPriceQueryService.TryGetEodPrice(exerciseDate, td.UnderlyingCode, out var eodPrice)
// 债券标的需走中债估值表取价,原 TryGetEodPrice 只查期货/股票两表会漏掉债券,导致"结算价未找到"。
// 统一改用债券感知的 TryGetSettlementEodPrice(见 EodPriceQueryService)。
var finalPrice = EodPriceQueryService.TryGetSettlementEodPrice(exerciseDate, td.UnderlyingCode, out var eodPrice)
? eodPrice.GetPrice(td.SettlementType) : 0;
if (finalPrice <= 0)
@@ -280,8 +282,6 @@ namespace YLErp.Modules.TradeModule.DealModule
trade_cash tradeCash = null;
//日终价格
var underlyingIds = tradeUnwindTrades.Select(t => t.UnderlyingId).ToList();
var EodPriceProvider = new EodPriceProvider(valueDate);
//批量结算的全是现金流交易就不用结算价
if (!EodPriceQueryService.CheckDbExists(valueDate) && tradeQuery.Any(t => t.TradeType != "现金流交易"))
{
@@ -308,8 +308,8 @@ namespace YLErp.Modules.TradeModule.DealModule
var CountRatio = 1;
if (t.TradeType != "现金流交易")
{
//结算价
if (EodPriceProvider.TryGetEodPrice(t.UnderlyingCode, out var eodPrice))
//结算价(债券感知统一取价:债券走中债估值,期货/股票走原路径,见 EodPriceQueryService.TryGetSettlementEodPrice
if (EodPriceQueryService.TryGetSettlementEodPrice(valueDate, t.UnderlyingCode, out var eodPrice))
{
settlePrice = eodPrice.GetPrice(t.SettlementType);
}
@@ -1,6 +1,7 @@
using BaseOUDAL;
using YLErp.BLL;
using YLErp.BLL.Eod;
using YLErp.Helpers;
using YLErp.Model;
using YLErp.Model.Enum;
using YLErp.Modules.ClientModule;
@@ -140,7 +141,8 @@ namespace YLErp.Modules.TradeModule.DealModule
return result;
}
//TODO 如果是审批组
var tradeProessQuery = TradeProcess();
// 需求②:按交易状态推断开仓/了结流程。了结类(平仓/行权/互换待复核)走 CloseProcess。
var tradeProessQuery = TradeProcessByCategory(td);
var count = tradeProessQuery.Count();
if (count == 0 || td.ProcessOrderId == ProcessTradeLog.)//投资规模校验已经将数据设置为已通过
{
@@ -166,6 +168,8 @@ namespace YLErp.Modules.TradeModule.DealModule
//{
// td.ProcessOrderBranch = 0;
//}
// 节点触发条件(需求①):下一节点配置了 triggerCondition 时,满足才进入审批,不满足则跳过。
nextOrder = AdvanceThroughTriggerNodes(tradeProessQuery, nextOrder, BuildTriggerContext(td, UserId));
if (nextOrder==null)
{
@@ -225,7 +229,8 @@ namespace YLErp.Modules.TradeModule.DealModule
var childrenTrades = DbContext.trade.Where(x => childrenTradeIds.Contains(x.id)).ToList();
var result = new TradeOpenResult(td);
// 如果是审批组
var tradeProessQuery = TradeProcess();
// 需求②:分组了结按交易状态推断开仓/了结流程。
var tradeProessQuery = TradeProcessByCategory(td);
var count = tradeProessQuery.Count();
if (count == 0)
{
@@ -246,6 +251,8 @@ namespace YLErp.Modules.TradeModule.DealModule
{
nextOrder = tradeProessQuery.FirstOrDefault(x => x.order > td.ProcessOrderId && x.node == td.ProcessOrderBranch && x.approvalGroupId == 0);
}
// 节点触发条件(需求①):分组了结推进同样支持触发条件。
nextOrder = AdvanceThroughTriggerNodes(tradeProessQuery, nextOrder, BuildTriggerContext(td, UserId));
if (nextOrder == null)
{
SetParentTradeOpen(req, td, childrenTrades, parentTradeCash);
@@ -294,8 +294,8 @@ namespace YLErp.Modules.TradeModule.DealModule
}
var result = new TradeUnwindResultModel(td);
//存在审批流程时,走审批成功流程
if (valuedateBLL.SystemDate.CloseReApprove == 1 && HasTradeProcess())
//存在审批流程时,走审批成功流程(需求②:按交易状态查对应流程)
if (valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(td) > 0)
{
result.ApprovalProcess = true;
@@ -462,8 +462,8 @@ namespace YLErp.Modules.TradeModule.DealModule
throw new ServiceException("平仓费不一致!复核不通过");
}
}
//存在审批流程时,走审批成功流程
if (valuedateBLL.SystemDate.CloseReApprove == 1 && HasTradeProcess())
//存在审批流程时,走审批成功流程(需求②:按交易状态查对应流程)
if (valuedateBLL.SystemDate.CloseReApprove == 1 && TradeProcessCountByCategory(td) > 0)
{
var reqModel = new TradeOpenReqModel
{
@@ -1848,14 +1848,28 @@ namespace YLErp.Modules.TradeModule.DealModule
td.CheckStatus = Convert.ToInt32(TradeCheckEnum.StatusOfOld);
//检查是否有交易审批流程
if ((valuedateBLL.SystemDate.CloseReApprove == 1 || isSwap) && hasTradeProcess)
//检查是否有交易审批流程(需求②:按交易状态查对应流程,而非只查开仓流程)
var needApproval = (valuedateBLL.SystemDate.CloseReApprove == 1 || isSwap) && TradeProcessCountByCategory(td) > 0;
if (needApproval)
{
//如果有审批
InitTradeProcessOrder(td, UserId);
// 需求①:进入审批前先判断触发条件——若所有节点都不满足触发条件,跳过审批
needApproval = NeedApprovalByTrigger(td);
}
AddTradeOperationHistoryAndSetParentTradeInfo(false, td, isSwap ? "互换审核提交" : "平仓审核提交");
if (needApproval)
{
// 需要审批:进入审批流程等待人工审批
InitTradeProcessOrder(td, UserId);
AddTradeOperationHistoryAndSetParentTradeInfo(false, td, isSwap ? "互换审核提交" : "平仓审核提交");
}
else
{
// 不需要审批:只标记 ProcessOrderId=审批通过。
// 互换的 swap_event 记录要等调用方 ApplySwapTrade 的 SaveSwapDeal 执行后才创建,
// 所以互换不在这里调 UpdateTradeProcessLog(否则 FindSwapEvent 找不到记录),
// 由 ApplySwapTrade 在 SaveSwapDeal 之后判断并触发审批通过流程。
td.ProcessOrderId = ProcessTradeLog.;
}
}
}
@@ -0,0 +1,42 @@
namespace YLErp.Modules.TradeModule.QueryModule
{
public sealed class TradeConfirmationDocumentQueryItem
{
public trade_contract_document Document { get; set; }
public trade_contract_r Relation { get; set; }
}
public static class TradeConfirmationDocumentQuery
{
public static IQueryable<TradeConfirmationDocumentQueryItem> Create(
IQueryable<trade_contract_document> documents,
IQueryable<trade_contract_r> relations,
IQueryable<trade> trades,
DateTime? tradeDateStart,
DateTime? tradeDateEnd)
{
if (tradeDateStart.HasValue)
{
trades = trades.Where(t => t.TradeDate >= tradeDateStart.Value);
}
if (tradeDateEnd.HasValue)
{
var tradeDateEndExclusive = tradeDateEnd.Value.Date.AddDays(1);
trades = trades.Where(t => t.TradeDate < tradeDateEndExclusive);
}
return from document in documents
join relation in relations.Where(r => r.IsValid)
on document.Code equals relation.ContractCode
join trade in trades
on relation.TradeId equals trade.id
select new TradeConfirmationDocumentQueryItem
{
Document = document,
Relation = relation
};
}
}
}
+26 -7
View File
@@ -276,8 +276,25 @@ namespace YLErp.BLL
var logger = LogFactory.GetLogger<tradeBLL>();
var posDict = swapPositions.GroupBy(sp => sp.SwapTradeId).ToDictionary(g => g.Key, g => g.First().PosiNetPrice);
var umProvider = DataCacheProvider.GetUnderlyingDataSource();
// 需求②:平仓/行权/互换交易,审批角色应取 CloseProcess 流程的节点角色,而非 TradeProcess
var closeProcessRoles = db.approvalprocess
.Where(a => a.processType == "CloseProcess")
.Select(a => new { a.order, a.roleId })
.ToList()
.ToDictionary(a => a.order, a => a.roleId);
foreach (var tradeLinq in retListResult.rows)
{
// 了结类交易:用 CloseProcess 的角色覆盖
if (tradeLinq.TradeStatus == "平仓待复核" || tradeLinq.TradeStatus == "行权待复核" || tradeLinq.TradeStatus == "互换待复核"
&& closeProcessRoles.Count > 0)
{
if (closeProcessRoles.TryGetValue(tradeLinq.ProcessOrderId, out var closeRoleId))
{
tradeLinq.ProcessRoleId = closeRoleId;
}
}
if (tradeLinq.ProcessRoleId != null && listRoles.TryGetValue(tradeLinq.ProcessRoleId.Value, out var name))
{
tradeLinq.ProcessRoleName = name;
@@ -3081,6 +3098,8 @@ namespace YLErp.BLL
//如果是审批组
var approvalprocessQuery = db.approvalprocess.Where(a => a.processType == "TradeProcess");
var tradeOpenProcessOrder = approvalprocessQuery.Count();
// 需求②:了结流程节点数(用于平仓/行权/互换交易展示正确的审批进度)
var closeProcessOrder = db.approvalprocess.Count(a => a.processType == "CloseProcess");
var branch = approvalprocessQuery.FirstOrDefault(x => x.approvalGroupId != 0);//审批流程有分支情况
var firstBranch = approvalprocessQuery.Where(x => (x.node == 1 && x.approvalGroupId == 0) || x.node == 0);//分支一总流程
var secondBranch = approvalprocessQuery.Where(x => (x.node == 2 && x.approvalGroupId == 0) || x.node == 0);//分支二总流程
@@ -3288,7 +3307,7 @@ namespace YLErp.BLL
IQueryable<TradeLinq> query = null;
if (branch == null)
{
query = GetTradeLinqQuery(predicate, approvalprocessQuery, tradeOpenProcessOrder, 0);
query = GetTradeLinqQuery(predicate, approvalprocessQuery, tradeOpenProcessOrder, 0, closeProcessOrder);
}
else
{
@@ -3304,8 +3323,8 @@ namespace YLErp.BLL
{
nodeArr.Add(approvalConditionSecend.node);
}
query = GetTradeLinqQuery(predicate, firstBranch, firstCount, 1, branchIndex, nodeArr);
var secondQuery = GetTradeLinqQuery(predicate, secondBranch, secondCount, 2, branchIndex, nodeArr);
query = GetTradeLinqQuery(predicate, firstBranch, firstCount, 1, branchIndex, nodeArr, closeProcessOrder);
var secondQuery = GetTradeLinqQuery(predicate, secondBranch, secondCount, 2, branchIndex, nodeArr, closeProcessOrder);
query = query.Union(secondQuery);
}
@@ -3319,7 +3338,7 @@ namespace YLErp.BLL
query = query.OrderByDescending(s => s.OptDate);
return query;
}
private IQueryable<TradeLinq> GetTradeLinqQuery(Expression<Func<trade, bool>> predicate, IQueryable<approvalprocess> approvalprocessQuery, int tradeOpenProcessOrder, int branchOrder)
private IQueryable<TradeLinq> GetTradeLinqQuery(Expression<Func<trade, bool>> predicate, IQueryable<approvalprocess> approvalprocessQuery, int tradeOpenProcessOrder, int branchOrder, int closeProcessOrder = 0)
{
var query = from source in db.trade.Where(predicate)
join process in approvalprocessQuery on source.ProcessOrderId equals process.order into pro
@@ -3371,7 +3390,7 @@ namespace YLErp.BLL
OptId = source.OptId,
OptName = source.OptName,
OptDate = source.OptDate,
ProcessStatus = source.ProcessStatus == "审批中" ? source.ProcessStatus + " 流程" + (branchOrder != 0 && source.ProcessOrderId > branchOrder ? source.ProcessOrderId - 2 : source.ProcessOrderId - 1) + "/" + tradeOpenProcessOrder : source.ProcessStatus,
ProcessStatus = source.ProcessStatus == "审批中" ? source.ProcessStatus + " 流程" + (branchOrder != 0 && source.ProcessOrderId > branchOrder ? source.ProcessOrderId - 2 : source.ProcessOrderId - 1) + "/" + (source.TradeStatus == "平仓待复核" || source.TradeStatus == "行权待复核" || source.TradeStatus == "互换待复核" ? (closeProcessOrder > 0 ? closeProcessOrder : tradeOpenProcessOrder) : tradeOpenProcessOrder) : source.ProcessStatus,
ProcessOrderId = source.ProcessOrderId,
ProcessOrderBranch = source.ProcessOrderBranch,
ProcessRoleId = proce.roleId,
@@ -3393,7 +3412,7 @@ namespace YLErp.BLL
};
return query;
}
private IQueryable<TradeLinq> GetTradeLinqQuery(Expression<Func<trade, bool>> predicate, IQueryable<approvalprocess> approvalprocessQuery, int tradeOpenProcessOrder, int node, int branchOrder, List<int?> nodeArr)
private IQueryable<TradeLinq> GetTradeLinqQuery(Expression<Func<trade, bool>> predicate, IQueryable<approvalprocess> approvalprocessQuery, int tradeOpenProcessOrder, int node, int branchOrder, List<int?> nodeArr, int closeProcessOrder = 0)
{
var tradeStatus = new string[] { "平仓待复核", "行权待复核", "互换待复核" };
var query = from source in db.trade.Where(predicate)
@@ -3447,7 +3466,7 @@ namespace YLErp.BLL
OptId = source.OptId,
OptName = source.OptName,
OptDate = source.OptDate,
ProcessStatus = source.ProcessStatus == "审批中" ? source.ProcessStatus + " 流程" + (branchOrder != 0 && source.ProcessOrderId > branchOrder ? source.ProcessOrderId - 2 : source.ProcessOrderId - 1) + "/" + tradeOpenProcessOrder : source.ProcessStatus,
ProcessStatus = source.ProcessStatus == "审批中" ? source.ProcessStatus + " 流程" + (branchOrder != 0 && source.ProcessOrderId > branchOrder ? source.ProcessOrderId - 2 : source.ProcessOrderId - 1) + "/" + (source.TradeStatus == "平仓待复核" || source.TradeStatus == "行权待复核" || source.TradeStatus == "互换待复核" ? (closeProcessOrder > 0 ? closeProcessOrder : tradeOpenProcessOrder) : tradeOpenProcessOrder) : source.ProcessStatus,
ProcessOrderId = source.ProcessOrderId,
ProcessOrderBranch = source.ProcessOrderBranch,
ProcessRoleId = proce.roleId,
@@ -1,7 +1,8 @@
using BaseOUDAL;
using BaseOUDAL;
using YLErp.BLL;
using YLErp.DBModels.Consts;
using YLErp.DBModels.Enums;
using YLErp.Helpers;
namespace YLErp.Modules.TradeModule
{
@@ -46,23 +47,50 @@ namespace YLErp.Modules.TradeModule
return _tradeProcessCount.Value;
}
/// <summary>
/// 获取所有交易审批节点
/// 获取所有交易审批节点(开仓流程 TradeProcess)。
/// </summary>
/// <returns></returns>
public List<approvalprocess> TradeProcess()
{
var tradeOrders = DbContext.approvalprocess.Where(t => t.processType == "TradeProcess").OrderBy(o => o.order).ToList();
return tradeOrders;
}
/// <summary>
/// 按交易推断流程类别(开仓/了结)获取审批节点(需求②)。
/// <para>了结类(平仓待复核/行权待复核/互换待复核)取 CloseProcess;开仓类取 TradeProcess。</para>
/// <para>未配置对应流程时返回空列表(由调用方决定是否直接通过)。</para>
/// </summary>
public List<approvalprocess> TradeProcessByCategory(trade td)
{
var category = ResolveProcessCategory(td);
return DbContext.approvalprocess.Where(t => t.processType == category).OrderBy(o => o.order).ToList();
}
/// <summary>
/// 按类别统计审批节点数(需求②)。
/// </summary>
public int TradeProcessCountByCategory(trade td)
{
return TradeProcessByCategory(td).Count;
}
/// <summary>
/// 初始化交易 审批点
/// </summary>
/// <param name="td"></param>
public void InitTradeProcessOrder(trade td,int userId)
{
var tradeProcess = TradeProcessByCategory(td);
// 该交易类别未配置审批流程 → 直接审批通过,无需审核
if (tradeProcess == null || tradeProcess.Count == 0)
{
td.ProcessOrderId = ProcessTradeLog.;
td.ProcessStatus = ProcessTradeStatus..ToString();
td.ProcessOptDate = DateTime.Now;
return;
}
td.ProcessOrderId = ProcessTradeLog.;
td.ProcessStatus = ProcessTradeStatus..ToString();
var tradeProcess = TradeProcess();
var groupId = UserBLL.GetApprovalProcessGroup(userId);
bool approvalBranch = tradeProcess.Any(x => x.approvalGroupId != 0);//审批流程有分支情况
if (td.ProcessOrderId == 1)
@@ -88,6 +116,169 @@ namespace YLErp.Modules.TradeModule
{
td.ProcessOrderId = 2;
}
// 需求①:进入审批流程时即应用触发条件——从起始节点开始,跳过所有不满足触发条件的节点。
// 若所有节点均不满足 → 直接审批通过(无需任何人审核)。
ApplyTriggerFromStart(tradeProcess, td, userId);
}
/// <summary>
/// 从当前 ProcessOrderId 起点开始,按触发条件跳过无需审批的节点(需求①)。
/// <para>场景:交易提交进入审批流程时,若节点1、2配置的触发条件均不满足,则直接跳到节点3;
/// 若全部节点都不满足,则直接审批通过。</para>
/// </summary>
private void ApplyTriggerFromStart(List<approvalprocess> tradeProcess, trade td, int userId)
{
if (tradeProcess == null || tradeProcess.Count == 0) return;
// 取当前起点节点(主干 node=0)
var current = tradeProcess.FirstOrDefault(x => x.order == td.ProcessOrderId && x.node == 0);
// 若起点不在主干(如分支调整后 ProcessOrderId=2),取该 order 的主干节点
if (current == null)
{
current = tradeProcess.FirstOrDefault(x => x.order >= td.ProcessOrderId && x.node == 0);
}
if (current == null) return;
var ctx = BuildTriggerContext(td, userId);
var target = ConditionEvaluator.FindFirstTriggeredNode(tradeProcess, current, ctx);
if (target == null)
{
// 所有节点都不满足触发条件 → 直接审批通过,无需审核
td.ProcessOrderId = ProcessTradeLog.;
td.ProcessStatus = ProcessTradeStatus..ToString();
td.ProcessOptDate = DateTime.Now;
return;
}
// target 即为第一个满足触发条件、需实际审批的节点
if (target.order != td.ProcessOrderId)
{
td.ProcessOrderId = target.order;
}
}
/// <summary>
/// 节点触发条件(需求①):在已确定下一审批节点 nextOrder 后,若该节点配置了 triggerCondition
/// 则只有「满足触发条件」才进入该节点审批;不满足则跳过该节点,继续向后寻找,直到找到可进入的节点或抵达流程末尾。
/// <para>语义:triggerCondition 为空 → 无条件进入审批;非空 → 满足才进入,不满足则跳过。</para>
/// <para>非侵入式:原有分支推进逻辑不变,仅在其结果之上叠加触发判断循环。</para>
/// </summary>
/// <param name="tradeProcess">当前流程的全部节点(已按 order 排序)</param>
/// <param name="nextOrder">原逻辑计算出的下一节点(可能为 null)</param>
/// <param name="ctx">条件求值业务上下文(已含 trade、本次交易名义本金等)</param>
/// <returns>最终应推进到的节点;若应结束流程则返回 null</returns>
protected static approvalprocess AdvanceThroughTriggerNodes(
List<approvalprocess> tradeProcess,
approvalprocess nextOrder,
ConditionContext ctx)
{
// 不满足触发条件的节点需跳过:循环向后找第一个可进入的节点。
while (nextOrder != null && !string.IsNullOrWhiteSpace(nextOrder.triggerCondition))
{
if (ConditionEvaluator.Evaluate(nextOrder.triggerCondition, ctx))
{
break; // 满足触发条件 → 进入该节点审批,停止跳过。
}
// 不满足触发条件 → 跳过该节点,向后取下一个主干节点(node=0),继续判断。
nextOrder = tradeProcess.FirstOrDefault(x => x.order > nextOrder.order && x.node == 0);
}
return nextOrder;
}
/// <summary>
/// 构建触发条件求值上下文:从 trade 及其关联的 trade_cash 取本次交易名义本金(了结场景)。
/// <para>本次交易名义本金 = 本次了结操作的 trade_cash.UnwindStockEqvNotional 绝对值。</para>
/// </summary>
protected ConditionContext BuildTriggerContext(trade td, int userId)
{
var ctx = new ConditionContext
{
UserId = userId,
Trade = td,
ProcessCategory = ResolveProcessCategory(td),
InitGroupId = UserBLL.GetApprovalProcessGroup(userId)
};
// 了结场景:取本次平仓名义本金
if (td != null && ctx.ProcessCategory == ProcessCategoryConst.Close)
{
if (td.TradeType == "收益互换")
{
// 互换:从 swap_event.EventData(JSON) 反序列化取 CloseNotionalValue
var swapEvent = DbContext.swap_event
.Where(x => x.SwapTradeId == td.id && !x.Invalid
&& (x.EventType == (int)SwapEventTypeEnum. || x.EventType == (int)SwapEventTypeEnum.))
.OrderByDescending(x => x.id)
.FirstOrDefault();
ctx.CurrentNotional = CalcSwapCloseNotionalFromEventData(swapEvent?.EventData);
}
else
{
// 期权:当次平仓名义本金 = 期初名义本金 × 平仓比例(UnwindPercentRate),取绝对值
var tc = DbContext.trade_cash
.Where(t => t.TradeId == td.id && t.ValidState == ConsGlobal.InValid && !t.IsDeleted)
.OrderByDescending(t => t.id)
.FirstOrDefault();
ctx.CurrentNotional = CalcOptionCloseNotional(td.OriginalStockEqvNotional, tc?.UnwindPercentRate);
}
}
return ctx;
}
/// <summary>
/// 互换:从 swap_event.EventData(JSON) 反序列化取 CloseNotionalValue 绝对值。
/// 容错:EventData 为 null/空/非法 JSON / unwindData=null 时返回 0(不影响审批阈值判断)。
/// 抽为静态纯函数以支持无库单测。
/// </summary>
public static double CalcSwapCloseNotionalFromEventData(string eventData)
{
if (string.IsNullOrEmpty(eventData)) return 0;
try
{
var unwindData = Newtonsoft.Json.JsonConvert.DeserializeObject<UnwindData>(eventData);
return unwindData != null ? Math.Abs((double)unwindData.CloseNotionalValue) : 0;
}
catch
{
return 0;
}
}
/// <summary>
/// 期权:当次平仓名义本金 = 期初名义本金 × 平仓比例(UnwindPercentRate),取绝对值。
/// 容错:任一参数为 null 时返回 0。抽为静态纯函数以支持无库单测。
/// </summary>
public static double CalcOptionCloseNotional(double? originalStockEqvNotional, double? unwindPercentRate)
{
if (!originalStockEqvNotional.HasValue || !unwindPercentRate.HasValue) return 0;
return Math.Abs(originalStockEqvNotional.Value * unwindPercentRate.Value);
}
/// <summary>
/// 需求①:判断按触发条件是否需要审批。
/// <para>取该交易类别的审批流程,若所有节点配置的触发条件都不满足当前业务,则无需审批(返回 false)。</para>
/// <para>若无审批流程或节点无触发条件,返回 true(需要审批)。</para>
/// </summary>
protected bool NeedApprovalByTrigger(trade td)
{
var tradeProcess = TradeProcessByCategory(td);
if (tradeProcess == null || tradeProcess.Count == 0) return true;
var start = tradeProcess.FirstOrDefault(x => x.node == 0);
if (start == null) start = tradeProcess.OrderBy(x => x.order).First();
var ctx = BuildTriggerContext(td, UserId);
var target = ConditionEvaluator.FindFirstTriggeredNode(tradeProcess, start, ctx);
return target != null;
}
/// <summary>
/// 按交易状态推断流程类别:开仓(TradeProcess) / 了结(CloseProcess)。需求②。
/// <para>委托给 ProcessCategoryConst.Resolve,供全局复用。</para>
/// </summary>
protected static string ResolveProcessCategory(trade td)
{
return ProcessCategoryConst.Resolve(td);
}
/// <summary>
/// 添加交易操作日志
@@ -521,4 +712,31 @@ namespace YLErp.Modules.TradeModule
return rateCalcModeValue;
}
}
/// <summary>
/// 交易流程类别常量(需求②):对应 approvalprocess.processType 的取值。
/// </summary>
public static class ProcessCategoryConst
{
/// <summary>开仓审批流程</summary>
public const string Open = "TradeProcess";
/// <summary>了结/平仓/行权审批流程</summary>
public const string Close = "CloseProcess";
/// <summary>
/// 按交易状态推断流程类别:处于平仓/行权/互换待复核的交易视为「了结」类操作。
/// </summary>
public static string Resolve(trade td)
{
if (td == null) return Open;
if (td.TradeStatus == ConsTrade.
|| td.TradeStatus == ConsTrade.
|| td.TradeStatus == ConsTrade.)
{
return Close;
}
return Open;
}
}
}
@@ -1,4 +1,6 @@
using Org.BouncyCastle.Ocsp;
using Newtonsoft.Json;
using YLErp.Helpers;
using YLErp.DBModels.Enums;
using YLErp.Model.Enum;
using YLErp.Modules.AppModule;
@@ -43,7 +45,7 @@ namespace YLErp.Web.Controllers
[HttpPost]
public ActionResult AddProcess(string type, List<ApprovalProcessAddRequest> data)
{
if (type == "TradeProcess" && data != null && data.Count > 0)
if ((type == "TradeProcess" || type == "CloseProcess") && data != null && data.Count > 0)
{
foreach (var item in data)
{
@@ -54,6 +56,25 @@ namespace YLErp.Web.Controllers
}
}
// 校验节点触发条件 JSON 格式,避免非法数据入库
if (data != null)
{
foreach (var item in data)
{
if (!string.IsNullOrWhiteSpace(item.triggerCondition))
{
try
{
JsonConvert.DeserializeObject<ConditionExpressionConfig>(item.triggerCondition);
}
catch
{
return JsonError("触发条件格式非法,请检查括号与条件是否完整");
}
}
}
}
new ApprovalProcessService(CurUser).AddProcess(type, data);
return JsonSuccess("设置成功");
}
@@ -66,10 +87,13 @@ namespace YLErp.Web.Controllers
var tradeProcess = list.Where(s => s.processType == "TradeProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList();
// 需求②:了结/平仓/行权审批流程
var closeProcess = list.Where(s => s.processType == "CloseProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList();
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, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess });
return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CloseProcess = closeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess });
}
+142 -7
View File
@@ -1,4 +1,5 @@
using YLErp.DBModels;
using YLErp.Core;
using YLErp.DBModels;
using YLErp.Modules.EodModule;
namespace YLErp.Web.Controllers
@@ -54,9 +55,13 @@ namespace YLErp.Web.Controllers
public ActionResult EodFuturePriceEdit(string enid)
{
if (string.IsNullOrEmpty(enid) || enid == "0")
bool isNew = string.IsNullOrEmpty(enid) || enid == "0";
ViewBag.IsNew = isNew;
if (isNew)
{
return View(new eod_commodity_future_price());
// 新建:手工输入标的代码,失焦时调 LookupUnderlyingForEod 校验并带出市场/UnderlyingId。
// 默认估值日期=今天:避免未填日期时落库为 0001-01-01、落在列表默认窗口(今天)之外而查不出。
return View(new eod_commodity_future_price { ValueDate = DateTime.Today });
}
var intid = DecryptInt(enid);
var dbmodel = yldb.eod_commodity_future_price.Find(intid);
@@ -70,9 +75,12 @@ namespace YLErp.Web.Controllers
public ActionResult EodStockPriceEdit(string enid)
{
if (string.IsNullOrEmpty(enid) || enid == "0")
bool isNew = string.IsNullOrEmpty(enid) || enid == "0";
ViewBag.IsNew = isNew;
if (isNew)
{
return View(new eod_stock_price());
// 新建:手工输入标的代码,失焦时调 LookupUnderlyingForEod 校验并带出市场。
return View(new eod_stock_price { ValueDate = DateTime.Today });
}
var intid = DecryptInt(enid);
var dbmodel = yldb.eod_stock_price.Find(intid);
@@ -85,9 +93,13 @@ namespace YLErp.Web.Controllers
}
public ActionResult EodBondPriceEdit(string enid)
{
if (string.IsNullOrEmpty(enid) || enid == "0")
bool isNew = string.IsNullOrEmpty(enid) || enid == "0";
ViewBag.IsNew = isNew;
if (isNew)
{
return View(new ChinaBondValuation());
// 新建:手工输入债券代码(bond_id),失焦时调 LookupUnderlyingForEod 校验并带出市场。
// 默认估值日期=今天:避免未填日期时落库为 0001-01-01、落在列表默认窗口(今天)之外而查不出。
return View(new ChinaBondValuation { valuation_date = DateTime.Today });
}
var intid = DecryptLong(enid);
var dbmodel = yldb.china_bond_valuation.Find(intid);
@@ -98,6 +110,129 @@ namespace YLErp.Web.Controllers
ViewData["市场"] = DataCacheProvider.GetUnderlyingDataSource().GetData(dbmodel.bond_id)?.MarketName;
return View(dbmodel);
}
/// <summary>
/// 新建日终价格时,按用户手工输入的标的代码精确查一条标的,带出名称/市场/Id。
/// 只返回已上市(LaunchState=="1")且类型匹配的标的——与列表查询 inner join 的过滤对齐,
/// 从源头杜绝"新增能存但查不出"的幽灵记录。前端在输入框失焦时调用,不依赖任何下拉/补全插件。
/// </summary>
/// <param name="code">标的代码(用户手工输入)</param>
/// <param name="kind">bond | future | stock,决定允许的标的类型集合</param>
[HttpPost]
public JsonResult LookupUnderlyingForEod(string code, string kind)
{
if (string.IsNullOrWhiteSpace(code))
{
return JsonError("请输入标的代码");
}
code = code.Trim();
string[] types = kind switch
{
"future" => new[]
{
ConsGlobal.InstrumentType.CommodityFutures,
ConsGlobal.InstrumentType.GoldFutures,
ConsGlobal.InstrumentType.TBFutures,
ConsGlobal.InstrumentType.OtherFutures,
ConsGlobal.InstrumentType.AbroadFutures,
},
"stock" => new[]
{
ConsGlobal.InstrumentType.Stock,
ConsGlobal.InstrumentType.StockIndex,
ConsGlobal.InstrumentType.StockIF,
ConsGlobal.InstrumentType.HKStock,
ConsGlobal.InstrumentType.HKStockIndex,
ConsGlobal.InstrumentType.NewOtcStock,
ConsGlobal.InstrumentType.AbroadStock,
ConsGlobal.InstrumentType.AbroadStockIndex,
},
_ => new[]
{
ConsGlobal.InstrumentType.Bonds,
ConsGlobal.InstrumentType.TBonds,
ConsGlobal.InstrumentType.CreditBonds,
ConsGlobal.InstrumentType.OtherBonds,
},
};
var set = new HashSet<string>(types);
var u = yldb.underlying_manager
.Where(n => n.UnderlyingCode == code && n.LaunchState == "1" && set.Contains(n.UnderlyingInstrumentType))
.Select(n => new { n.id, n.UnderlyingCode, n.UnderlyingName, n.MarketName })
.FirstOrDefault();
if (u == null)
{
return JsonError("未找到该标的(不存在、未上市或类型不匹配),无法录入");
}
return JsonSuccess("", new
{
Id = u.id,
Code = u.UnderlyingCode,
Name = u.UnderlyingName,
Market = u.MarketName,
});
}
/// <summary>
/// 新建日终价格时,按手工输入的片段做服务端模糊联想(代码或名称包含匹配),只回前 20 条。
/// 与 LookupUnderlyingForEod 同样只返回已上市(LaunchState=="1")且类型匹配的标的。
/// 用原生下拉渲染(不依赖 jQuery UI,bundle 未打包),避免几十万标的全量渲染卡死。
/// </summary>
[HttpPost]
public JsonResult SuggestUnderlyingForEod(string q, string kind)
{
if (string.IsNullOrWhiteSpace(q))
{
return JsonSuccess("", new List<object>());
}
q = q.Trim();
string[] types = kind switch
{
"future" => new[]
{
ConsGlobal.InstrumentType.CommodityFutures,
ConsGlobal.InstrumentType.GoldFutures,
ConsGlobal.InstrumentType.TBFutures,
ConsGlobal.InstrumentType.OtherFutures,
ConsGlobal.InstrumentType.AbroadFutures,
},
"stock" => new[]
{
ConsGlobal.InstrumentType.Stock,
ConsGlobal.InstrumentType.StockIndex,
ConsGlobal.InstrumentType.StockIF,
ConsGlobal.InstrumentType.HKStock,
ConsGlobal.InstrumentType.HKStockIndex,
ConsGlobal.InstrumentType.NewOtcStock,
ConsGlobal.InstrumentType.AbroadStock,
ConsGlobal.InstrumentType.AbroadStockIndex,
},
_ => new[]
{
ConsGlobal.InstrumentType.Bonds,
ConsGlobal.InstrumentType.TBonds,
ConsGlobal.InstrumentType.CreditBonds,
ConsGlobal.InstrumentType.OtherBonds,
},
};
var set = new HashSet<string>(types);
var list = yldb.underlying_manager
.Where(n => n.LaunchState == "1" && set.Contains(n.UnderlyingInstrumentType)
&& (n.UnderlyingCode.Contains(q) || n.UnderlyingName.Contains(q)))
.OrderBy(n => n.UnderlyingCode)
.Take(20)
.Select(n => new { n.id, Code = n.UnderlyingCode, Name = n.UnderlyingName, Market = n.MarketName })
.ToList();
return JsonSuccess("", list);
}
[HttpPost]
public JsonResult EodFuturePriceEditJson(eod_commodity_future_price req)
{
+5 -2
View File
@@ -283,9 +283,12 @@ namespace YLErp.Web.Controllers
/// <param name="tradeId"></param>
/// <param name="closePercent"></param>
/// <returns></returns>
public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType)
public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType, decimal notionalValue = 0, decimal posiNotionalValue = 0)
{
var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, closePercent, eventType);
// 前端按"占期初(original)"语义传 closePercent(A);后端 GetUnwindInterests 按"占剩余(remaining)"语义(B)计算。
// 多空互换前端不传 notionalValue/posiNotionalValue(默认 0),则跳过转换保持原行为。
var convertedClosePercent = SwapDealService.ToRemainingClosePercent(closePercent, notionalValue, posiNotionalValue);
var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, convertedClosePercent, eventType);
foreach (var interest in interests)
{
interest.TdInterestAmount=Math.Round(interest.TdInterestAmount, ConsGlobal.MoneyRound,MidpointRounding.AwayFromZero);
@@ -296,5 +296,13 @@ namespace YLErp.Web.Controllers
CalendarBLL.ResetCalendarForQdp();
return JsonSuccess("删除成功");
}
[HttpPost]
public JsonResult Reset()
{
CalendarBLL.IsListOld = true;
CalendarBLL.ResetCalendarForQdp();
return JsonSuccess("重置成功");
}
}
}
+10 -7
View File
@@ -6370,14 +6370,17 @@ namespace YLErp.Web.Controllers
return ShowError("请选择文档类型!");
}
if (req.TradeDateStart == null) { req.TradeDateStart = DateTime.MinValue; }
if (req.TradeDateEnd == null) { req.TradeDateEnd = DateTime.MaxValue; }
var db_trade_contract_r = yldb.trade_contract_r.AsQueryable();
var documentQuery = TradeConfirmationDocumentQuery.Create(
yldb.trade_contract_document,
yldb.trade_contract_r,
yldb.trade,
req.TradeDateStart,
req.TradeDateEnd);
var query = from doc in yldb.trade_contract_document
join r in db_trade_contract_r
on doc.Code equals r.ContractCode
where doc.Type == ContractTypeEnum.Trade && doc.ValueDate >= req.TradeDateStart && doc.ValueDate <= req.TradeDateEnd && r.IsValid
var query = from item in documentQuery
let doc = item.Document
let r = item.Relation
where doc.Type == ContractTypeEnum.Trade
select new
{
doc.ClientId,
+1 -1
View File
@@ -148,7 +148,7 @@ namespace YLErp.Web.Controllers
{
valueDate = QdpCalendarHelper.GetNonHolidayDefore(valueDate);
}
if (!EodPriceQueryService.TryGetEodPrice(valueDate, td.UnderlyingCode, out _))
if (!EodPriceQueryService.TryGetSettlementEodPrice(valueDate, td.UnderlyingCode, out _))
{
return JsonError($"交易日{valueDate:yyyy-MM-dd}的结算价或收盘价未找到!");
}
@@ -52,7 +52,7 @@
</div>
</div>
</div>
<template v-model="openItems">
<template>
<template v-for="(item,index) in openItems">
<!--第一个节点非分支 开始-->
<div v-show="item.node==0&&item.parentNode==0">
@@ -84,7 +84,7 @@
<div class="branch-wrap">
<div class="branch-box-wrap">
<div class="branch-box">
<span class="add-branch" title="添加条件">添加条件</span>
<span class="add-branch" title="添加条件" v-on:click="addCondition(item)">添加条件</span>
<div class="col-box" v-for="child in openFilterBranch">
<div class="condition-node">
<div class="condition-node-box">
@@ -182,7 +182,7 @@
</div>
</div>
</div>
<template v-model="clientItems">
<template>
<template v-for="(item,index) in clientItems">
<!--第一个节点非分支 开始-->
<div v-show="item.node==0&&item.parentNode==0">
@@ -214,7 +214,7 @@
<div class="branch-wrap">
<div class="branch-box-wrap">
<div class="branch-box">
<span class="add-branch" title="添加条件">添加条件</span>
<span class="add-branch" title="添加条件" v-on:click="addCondition(item)">添加条件</span>
<div class="col-box" v-for="child in clientFilterBranch">
<div class="condition-node">
<div class="condition-node-box">
@@ -298,7 +298,7 @@
</div>
<div v-show="isTrade">
<div style="margin: 10px auto">交易流程</div>
<div style="margin: 10px auto">交易新增与修改流程</div>
<div>
<div class="node-wrap">
<div class="end-node">
@@ -312,12 +312,12 @@
</div>
</div>
</div>
<template v-model="tradeItems">
<template>
<template v-for="(item,index) in tradeItems">
<!--第一个节点非分支 开始-->
<div v-show="item.node==0&&item.parentNode==0">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width:282px;">
<div class="node-wrap-box start-node " style="width:340px;">
<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>
@@ -334,6 +334,33 @@
<span>审批规则</span>
<input :id="'ruleType'+item.Index+item.node" v-model="item.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
<span>审批条件</span>
<div class="trigger-actions">
<button class="trigger-action-btn" v-on:click="addTriggerCondition(item)">+ 条件</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(item,'left')">+ (</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(item,'right')">+ )</button>
</div>
</div>
<div class="trigger-token-list">
<div v-for="(tok,ti) in (item._trigger && item._trigger.tokens) || []" class="trigger-token-row">
<span v-if="tok.type=='operator'" class="trigger-connector" v-on:click="toggleTriggerConnector(item,ti)">{{tok.connector=='or'?'或':'且'}}</span>
<span v-if="tok.type=='lparen'" class="trigger-paren">(&nbsp;&nbsp;<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i></span>
<span v-if="tok.type=='rparen'" class="trigger-paren">)<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i></span>
<span v-if="tok.type=='condition'" class="trigger-condition-row">
<select v-model="tok.condition.field">
<option v-for="opt in availableConditionFields" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<select v-model="tok.condition.op">
<option v-for="opt in conditionOps" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<input v-model="tok.condition.value" type="text" placeholder="阈值" />
<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i>
</span>
</div>
</div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
@@ -348,7 +375,7 @@
<div class="branch-wrap">
<div class="branch-box-wrap">
<div class="branch-box">
<span class="add-branch" title="添加条件">添加条件</span>
<span class="add-branch" title="添加条件" v-on:click="addCondition(item)">添加条件</span>
<div class="col-box" v-for="child in filterBranch">
<div class="condition-node">
<div class="condition-node-box">
@@ -382,7 +409,7 @@
<!--第一个节点非分支 开始-->
<div v-for="childSecond in filterChild(child.node)">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width:282px;">
<div class="node-wrap-box start-node " style="width:340px;">
<div class="title" style="background: rgb(255, 148, 62);">
<span class="userEdit">审核节点</span>
<i class="glyphicon glyphicon-remove btnRemove" v-on:click="delProcess(childSecond)"></i>
@@ -399,6 +426,33 @@
<span>审批规则</span>
<input :id="'ruleType'+childSecond.Index+childSecond.node" v-model="childSecond.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
<span>审批条件</span>
<div class="trigger-actions">
<button class="trigger-action-btn" v-on:click="addTriggerCondition(childSecond)">+ 条件</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(childSecond,'left')">+ (</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(childSecond,'right')">+ )</button>
</div>
</div>
<div class="trigger-token-list">
<div v-for="(tok,ti) in (childSecond._trigger && childSecond._trigger.tokens) || []" class="trigger-token-row">
<span v-if="tok.type=='operator'" class="trigger-connector" v-on:click="toggleTriggerConnector(childSecond,ti)">{{tok.connector=='or'?'或':'且'}}</span>
<span v-if="tok.type=='lparen'" class="trigger-paren">(&nbsp;&nbsp;<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i></span>
<span v-if="tok.type=='rparen'" class="trigger-paren">)<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i></span>
<span v-if="tok.type=='condition'" class="trigger-condition-row">
<select v-model="tok.condition.field">
<option v-for="opt in availableConditionFields" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<select v-model="tok.condition.op">
<option v-for="opt in conditionOps" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<input v-model="tok.condition.value" type="text" placeholder="阈值" />
<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i>
</span>
</div>
</div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
@@ -435,6 +489,199 @@
</div>
</div>
<!-- 需求②:交易了结流程(结构同交易流程,绑定 closeItems -->
<div v-show="isClose">
<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="closeShowProcess(0,false,$event)">+</button>
</div>
</div>
</div>
<template>
<template v-for="(item,index) in closeItems">
<!--第一个节点非分支 开始-->
<div v-show="item.node==0&&item.parentNode==0">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width:340px;">
<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" v-on:change="closeSelectChangeType(item.Index-1,item.SelectValue)" style="width:200px;height:20px;">
<option v-for="option in roleOptions" v-bind:value="option.Value">
{{option.Text}}
</option>
</select>
</div>
<div>
<span>审批规则</span>
<input :id="'closeRuleType'+item.Index+item.node" v-model="item.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
<span>审批条件</span>
<div class="trigger-actions">
<button class="trigger-action-btn" v-on:click="addTriggerCondition(item)">+ 条件</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(item,'left')">+ (</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(item,'right')">+ )</button>
</div>
</div>
<div class="trigger-token-list">
<div v-for="(tok,ti) in (item._trigger && item._trigger.tokens) || []" class="trigger-token-row">
<span v-if="tok.type=='operator'" class="trigger-connector" v-on:click="toggleTriggerConnector(item,ti)">{{tok.connector=='or'?'或':'且'}}</span>
<span v-if="tok.type=='lparen'" class="trigger-paren">(&nbsp;&nbsp;<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i></span>
<span v-if="tok.type=='rparen'" class="trigger-paren">)<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i></span>
<span v-if="tok.type=='condition'" class="trigger-condition-row">
<select v-model="tok.condition.field">
<option v-for="opt in availableConditionFields" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<select v-model="tok.condition.op">
<option v-for="opt in conditionOps" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<input v-model="tok.condition.value" type="text" placeholder="阈值" />
<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i>
</span>
</div>
</div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
<button class="addNodeClick" v-on:click="closeShowProcess(item.Index,false,$event)">+</button>
</div>
</div>
</div>
</div>
<!--第一个节点非分支 结束-->
<!--第一个节点为分支 分支开始-->
<div v-show="item.node==1&&item.approvalCondition!=0">
<div class="branch-wrap">
<div class="branch-box-wrap">
<div class="branch-box">
<span class="add-branch" title="添加条件" v-on:click="addCondition(item)">添加条件</span>
<div class="col-box" v-for="child in closeFilterBranch()">
<div class="condition-node">
<div class="condition-node-box">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width: 282px;">
<div class="title" style="background: rgb(255, 148, 62);">
<span class="userEdit">分支{{child.node}}条件</span>
<i class="glyphicon glyphicon-remove btnRemove" v-on:click="delProcess(item)"></i>
</div>
<div>
<span>申请人</span>
<select v-model="child.approvalCondition" style="width:75px;height:20px;">
<option value="1">属于</option>
<option value="2">不属于</option>
</select>
<select v-model="child.approvalGroupId" style="width:143px;height:20px;">
<option v-for="option in grouplist" v-bind:value="option.id">
{{option.groupName}}
</option>
</select>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
<button class="addNodeClick" v-on:click="closeAddProcess(item.Index,true,child.node,$event)">+</button>
</div>
</div>
</div>
</div>
</div>
<!--第一个节点非分支 开始-->
<div v-for="childSecond in closeFilterChild(child.node)">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width:340px;">
<div class="title" style="background: rgb(255, 148, 62);">
<span class="userEdit">审核节点</span>
<i class="glyphicon glyphicon-remove btnRemove" v-on:click="delProcess(childSecond)"></i>
</div>
<div>
<span>审核角色</span>
<select v-model="childSecond.SelectValue" style="width:200px;height:20px;">
<option v-for="option in roleOptions" v-bind:value="option.Value">
{{option.Text}}
</option>
</select>
</div>
<div>
<span>审批规则</span>
<input :id="'closeRuleType'+childSecond.Index+childSecond.node" v-model="childSecond.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
<span>审批条件</span>
<div class="trigger-actions">
<button class="trigger-action-btn" v-on:click="addTriggerCondition(childSecond)">+ 条件</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(childSecond,'left')">+ (</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(childSecond,'right')">+ )</button>
</div>
</div>
<div class="trigger-token-list">
<div v-for="(tok,ti) in (childSecond._trigger && childSecond._trigger.tokens) || []" class="trigger-token-row">
<span v-if="tok.type=='operator'" class="trigger-connector" v-on:click="toggleTriggerConnector(childSecond,ti)">{{tok.connector=='or'?'或':'且'}}</span>
<span v-if="tok.type=='lparen'" class="trigger-paren">(&nbsp;&nbsp;<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i></span>
<span v-if="tok.type=='rparen'" class="trigger-paren">)<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i></span>
<span v-if="tok.type=='condition'" class="trigger-condition-row">
<select v-model="tok.condition.field">
<option v-for="opt in availableConditionFields" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<select v-model="tok.condition.op">
<option v-for="opt in conditionOps" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<input v-model="tok.condition.value" type="text" placeholder="阈值" />
<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i>
</span>
</div>
</div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
<button class="addNodeClick" v-on:click="closeAddProcess(childSecond.Index,true,childSecond.node,$event)">+</button>
</div>
</div>
</div>
</div>
<!--第一个节点非分支 结束-->
<div class="top-left-cover-line" v-if="child.node==1"></div>
<div class="bottom-left-cover-line" v-if="child.node==1"></div>
<div class="top-right-cover-line" v-if="child.node!=1"></div>
<div class="bottom-right-cover-line" v-if="child.node!=1"></div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
<button class="addNodeClick" v-on:click="closeShowProcess(item.Index,false,$event)">+</button>
</div>
</div>
</div>
</div>
</div>
<!--第一个节点为分支 分支结束-->
</template>
</template>
<!-- 流程结束 -->
<div class="end-node">
<div class="end-node-circle"></div>
<div class="end-node-text">
结束流程
</div>
</div>
</div>
</div>
<div v-show="isCredit">
<div style="margin: 10px auto">资信与授信流程</div>
<div>
+45 -20
View File
@@ -1,22 +1,31 @@
@model ChinaBondValuation
@{
ViewBag.Title = "日终商品期货价格 | 编辑";
ViewBag.Title = "日终债券价格 | 编辑";
Layout = "~/Views/Shared/_InfoLayout.cshtml";
bool isNew = ViewBag.IsNew != null && (bool)ViewBag.IsNew;
}
@section JS
{
<script src="~/Scripts/app/eod/eodUnderlyingSuggest.js?v=@(HtmlUtil.JsVersion)"></script>
<script type="text/javascript">
$(function () {
$(".datepicker").datepicker({ changeMonth: true, changeYear: true, showButtonPanel: true, showOtherMonths: true, selectOtherMonths: true });
$(".form-group").addClass("col-md-6");
});
function checkSubmitData() {
var pass = $('#form1').valid();
return pass;
// 手工输入债券代码后失焦:校验标的存在且已上市,并带出市场
function lookupBond() {
var el = document.getElementById('bond_id');
if (!el) return;
var code = (el.value || '').trim();
var mk = document.getElementById('MarketBox');
if (!code) { if (mk) mk.value = ''; return; }
main.post("/eodPrice/LookupUnderlyingForEod", { code: code, kind: 'bond' }).done(function (res) {
el.value = res.obj.Code;
if (mk) mk.value = res.obj.Market || '';
});
}
function saveeod_bond_price() {
var el = document.getElementById('bond_id');
if (el && !(el.value || '').trim()) {
main.message('请输入债券标的代码'); return false;
}
if (!checkSubmitData()) return false;
var data = $("#form1").serialize();
main.post("/eodPrice/EodBondPriceEditJson", data).done(function (res) {
@@ -24,27 +33,43 @@
main.parentReloadData();
});
}
</script>
}
<form class="yc-panel" id="form1" method="post" onsubmit="return false;">
<input type="hidden" value="@Model.id" name="id" id="id" />
<input type="hidden" value="@Model.EncryptId" name="EncryptId" id="EncryptId" />
<input type="hidden" name="bond_id" value="@(Model.bond_id)" />
@if (!isNew)
{
<input type="hidden" name="bond_id" value="@(Model.bond_id)" />
}
<input type="hidden" name="term_to_maturity" value="@(Model.term_to_maturity)" />
<h4>日终债券价格修改</h4>
<div style="margin-top:20px;">
<div class='form-group col-md-6'>
<label class='formlabel'>市场</label>
<input class='text-box' type='text' value='@(ViewData["市场"])' readonly=readonly />
</div>
<div class='form-group col-md-6'>
<label class='formlabel'>标的代码</label>
<input class='text-box' type='text' value='@(Model.bond_id)' readonly=readonly />
</div>
@if (isNew)
{
<div class='form-group col-md-6'>
<label class='formlabel'>标的代码</label>
<input class='text-box' type='text' id="bond_id" name="bond_id" value="" onblur="lookupBond()" oninput="eodSuggest(this, 'bond')" placeholder="输入债券代码(边打边联想),失焦自动带出市场" />
</div>
<div class='form-group col-md-6'>
<label class='formlabel'>市场</label>
<input class='text-box' type='text' id="MarketBox" readonly=readonly />
</div>
}
else
{
<div class='form-group col-md-6'>
<label class='formlabel'>市场</label>
<input class='text-box' type='text' value='@(ViewData["市场"])' readonly=readonly />
</div>
<div class='form-group col-md-6'>
<label class='formlabel'>标的代码</label>
<input class='text-box' type='text' value='@(Model.bond_id)' readonly=readonly />
</div>
}
@Html.MyDateFor(model => model.valuation_date)
@Html.MyTextFor(model => model.dirty_price_close)
@Html.MyTextFor(model => model.net_price)
@@ -55,4 +80,4 @@
<button class="btn btn-primary" type="button" onclick="layer.closeMe();">关闭</button>
</div>
</form>
</form>
@@ -2,21 +2,36 @@
@{
ViewBag.Title = "日终商品期货价格 | 编辑";
Layout = "~/Views/Shared/_InfoLayout.cshtml";
bool isNew = ViewBag.IsNew != null && (bool)ViewBag.IsNew;
}
@section JS
{
<script src="~/Scripts/app/eod/eodUnderlyingSuggest.js?v=@(HtmlUtil.JsVersion)"></script>
<script type="text/javascript">
$(function () {
$(".datepicker").datepicker({ changeMonth: true, changeYear: true, showButtonPanel: true, showOtherMonths: true, selectOtherMonths: true });
$(".form-group").addClass("col-md-6");
});
function checkSubmitData() {
var pass = $('#form1').valid();
return pass;
// 手工输入期货合约代码后失焦:校验标的存在且已上市,带出市场并回填 UnderlyingId(列表查询按此 join)
function lookupFuture() {
var el = document.getElementById('UnderlyingCode');
if (!el) return;
var code = (el.value || '').trim();
var mk = document.getElementById('MarketBox');
var idEl = document.getElementById('UnderlyingId');
if (!code) { if (mk) mk.value = ''; if (idEl) idEl.value = ''; return; }
main.post("/eodPrice/LookupUnderlyingForEod", { code: code, kind: 'future' }).done(function (res) {
el.value = res.obj.Code;
if (idEl) idEl.value = res.obj.Id;
if (mk) mk.value = res.obj.Market || '';
});
}
function saveeod_commodity_future_price() {
var el = document.getElementById('UnderlyingCode');
var idEl = document.getElementById('UnderlyingId');
if (el && !(el.value || '').trim()) {
main.message('请输入期货标的代码'); return false;
}
if (el && idEl && !idEl.value) {
main.message('标的未校验通过,请重新输入代码后失焦'); return false;
}
if (!checkSubmitData()) return false;
var data = $("#form1").serialize();
main.post("/eodPrice/EodFuturePriceEditJson", data).done(function (res) {
@@ -24,29 +39,49 @@
main.parentReloadData();
});
}
</script>
}
<form class="yc-panel" id="form1" method="post" onsubmit="return false;">
<input type="hidden" value="@Model.id" name="id" id="id" />
<input type="hidden" value="@Model.EncryptId" name="EncryptId" id="EncryptId" />
<input type="hidden" name="UnderlyingId" value="@(Model.UnderlyingId)" />
<input type="hidden" name="UnderlyingCode" value="@(Model.UnderlyingCode)" />
@if (isNew)
{
<input type="hidden" name="UnderlyingId" id="UnderlyingId" value="" />
}
else
{
<input type="hidden" name="UnderlyingId" value="@(Model.UnderlyingId)" />
<input type="hidden" name="UnderlyingCode" value="@(Model.UnderlyingCode)" />
}
<input type="hidden" name="HighPrice" value="@(Model.HighPrice)" />
<input type="hidden" name="LowPrice" value="@(Model.LowPrice)" />
<h4>日终商品期货价格修改</h4>
<div style="margin-top:20px;">
<div class='form-group col-md-6'>
<label class='formlabel'>市场</label>
<input class='text-box' type='text' value='@(ViewData["市场"])' readonly=readonly />
</div>
<div class='form-group col-md-6'>
<label class='formlabel'>标的代码</label>
<input class='text-box' type='text' value='@(Model.UnderlyingCode)' readonly=readonly />
</div>
@if (isNew)
{
<div class='form-group col-md-6'>
<label class='formlabel'>标的代码</label>
<input class='text-box' type='text' id="UnderlyingCode" name="UnderlyingCode" value="" onblur="lookupFuture()" oninput="eodSuggest(this, 'future')" placeholder="输入期货合约代码(边打边联想),失焦自动带出市场" />
</div>
<div class='form-group col-md-6'>
<label class='formlabel'>市场</label>
<input class='text-box' type='text' id="MarketBox" readonly=readonly />
</div>
}
else
{
<div class='form-group col-md-6'>
<label class='formlabel'>市场</label>
<input class='text-box' type='text' value='@(ViewData["市场"])' readonly=readonly />
</div>
<div class='form-group col-md-6'>
<label class='formlabel'>标的代码</label>
<input class='text-box' type='text' value='@(Model.UnderlyingCode)' readonly=readonly />
</div>
}
@Html.MyDateFor(model => model.ValueDate)
@Html.MyTextFor(model => model.ClosePrice)
@Html.MyTextFor(model => model.SettlePrice)
@@ -57,4 +92,4 @@
<button class="btn btn-primary" type="button" onclick="layer.closeMe();">关闭</button>
</div>
</form>
</form>
@@ -30,6 +30,7 @@
@MyControls.SearchBtn()
@if (CurUser.结算管理_日终价格修改)
{
@MyControls.Btn("新增", "openEodPriceAdd()")
@MyControls.Btn("上传", "uploadSettlementBill()")
}
@MyControls.Btn("导出", "downloadExcel()")
@@ -2,22 +2,30 @@
@{
ViewBag.Title = "日终股票价格 | 编辑";
Layout = "~/Views/Shared/_InfoLayout.cshtml";
bool isNew = ViewBag.IsNew != null && (bool)ViewBag.IsNew;
}
@section JS
{
<script src="~/Scripts/app/eod/eodUnderlyingSuggest.js?v=@(HtmlUtil.JsVersion)"></script>
<script type="text/javascript">
var submitclick_eod_Stock_Price = false;
$(function () {
$(".datepicker").datepicker({ changeMonth: true, changeYear: true, showButtonPanel: true, showOtherMonths: true, selectOtherMonths: true });
$(".form-group").addClass("col-md-6");
});
function checkSubmitData() {
var pass = $('#form1').valid();
return pass;
// 手工输入股票代码后失焦:校验标的存在且已上市,并带出市场
function lookupStock() {
var el = document.getElementById('UnderlyingCode');
if (!el) return;
var code = (el.value || '').trim();
var mk = document.getElementById('MarketBox');
if (!code) { if (mk) mk.value = ''; return; }
main.post("/eodPrice/LookupUnderlyingForEod", { code: code, kind: 'stock' }).done(function (res) {
el.value = res.obj.Code;
if (mk) mk.value = res.obj.Market || '';
});
}
function saveeod_Stock_Price() {
var el = document.getElementById('UnderlyingCode');
if (el && !(el.value || '').trim()) {
main.message('请输入股票标的代码'); return false;
}
if (!checkSubmitData()) return false;
var data = $("#form1").serialize();
main.post("/eodPrice/eodStockPriceEditJson", data).done(function (res) {
@@ -27,36 +35,45 @@
}
</script>
}
<form class="yc-panel" id="form1" method="post" onsubmit="return false;">
<input type="hidden" value="@Model.id" name="id" id="id" />
<input type="hidden" value="@Model.EncryptId" name="EncryptId" id="EncryptId" />
<input type="hidden" name="UnderlyingCode" value="@(Model.UnderlyingCode)" />
@if (!isNew)
{
<input type="hidden" name="UnderlyingCode" value="@(Model.UnderlyingCode)" />
}
<input type="hidden" name="HighPrice" value="@(Model.HighPrice)" />
<input type="hidden" name="LowPrice" value="@(Model.LowPrice)" />
<h4>日终股票价格修改</h4>
<div style="margin-top:20px;">
<div class='form-group col-md-6'>
<label class='formlabel'>市场</label>
<input class='text-box' type='text' value='@(ViewData["市场"])' readonly=readonly />
</div>
<div class='form-group col-md-6'>
<label class='formlabel'>标的代码</label>
<input class='text-box' type='text' value='@(Model.UnderlyingCode)' readonly=readonly />
</div>
@if (isNew)
{
<div class='form-group col-md-6'>
<label class='formlabel'>标的代码</label>
<input class='text-box' type='text' id="UnderlyingCode" name="UnderlyingCode" value="" onblur="lookupStock()" oninput="eodSuggest(this, 'stock')" placeholder="输入股票代码(边打边联想),失焦自动带出市场" />
</div>
<div class='form-group col-md-6'>
<label class='formlabel'>市场</label>
<input class='text-box' type='text' id="MarketBox" readonly=readonly />
</div>
}
else
{
<div class='form-group col-md-6'>
<label class='formlabel'>市场</label>
<input class='text-box' type='text' value='@(ViewData["市场"])' readonly=readonly />
</div>
<div class='form-group col-md-6'>
<label class='formlabel'>标的代码</label>
<input class='text-box' type='text' value='@(Model.UnderlyingCode)' readonly=readonly />
</div>
}
@Html.MyDateFor(model => model.ValueDate)
@Html.MyTextFor(model => model.ClosePrice)
@Html.MyTextFor(model => model.ReferencePrice)
@Html.MyDropdownFor1(model => model.UnderlyingStatus, new List<SelectItem>
{
new SelectItem {Text = "正常运行", Value = "正常运行" },
new SelectItem {Text = "停牌", Value = "停牌"},
new SelectItem {Text = "退市", Value = "退市"},
}, appendBlank: false)
</div>
<div style="padding:10px 0 0 130px;">
<button class="btn btn-primary" type="button" onclick="saveeod_Stock_Price();">保存</button>
@@ -18,6 +18,22 @@
<script src="~/Scripts/app/trade/exchange/tradeList.js?v=@(HtmlUtil.JsVersion)"></script>
}
@section CSS{
<style>
#listGrid tr.jqgrow {
height: 25px !important;
}
#listGrid tr.jqgrow > td {
height: 25px !important;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
}
<table id="excelTable" style="display:none"></table>
<a onfocus="this.blur();" style="display:none;" download="code.xls" id="createInvote2" class="ipt-todo hide">code</a>
@@ -33,7 +33,7 @@
</div>
@Html.MyAceDropdownInput("ClientId", "交易对手方", ClientDataModel.GetAllOpenClient())
@MyControls.SearchBtn()
@*<button onclick="exportTrade()" class="btn btn-primary">导出</button>*@
<button onclick="exportVisibleColumns()" class="btn btn-primary">导出</button>
<span style="width: 30px; display: inline;" onclick="showcolumnChooser();return false;">
<img src="~/Images/configure.png" />
</span>
+12 -4
View File
@@ -11,16 +11,24 @@
@section JS
{
<script>
function formatDateText(value) {
return value ? value.toString().substr(0, 10) : "";
}
var model = @Json.Serialize(Model);
model.StartDate = model.StartDate ? model.StartDate.substr(0, 10) : "";
model.ValueDate = model.ValueDate ? model.ValueDate.substr(0, 10) : "";
model.PayDate = model.PayDate ? model.PayDate.substr(0, 10) : "";
model.StartDate = formatDateText(model.StartDate);
model.ValueDate = formatDateText(model.ValueDate);
model.PayDate = formatDateText(model.PayDate);
model.MaxIncomeValueDate = formatDateText(model.MaxIncomeValueDate);
if (model.ValueDate && model.MaxIncomeValueDate && model.ValueDate > model.MaxIncomeValueDate) {
model.ValueDate = model.MaxIncomeValueDate;
}
var isUseApproval = "@isUseApproval"=="True";
var g_isShowReCheckClose = "@isShowReCheckClose" == "True";
</script>
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/fast/fastVue.components.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/tradeHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapCalc.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/incomeSwapTrade.js?v=@HtmlUtil.JsVersion"></script>
}
<div class="pb-3" id="vueDiv">
@@ -45,7 +53,7 @@
<td>起始日期</td>
<td> {{deal.StartDate}}</td>
<td>收益结算日期</td>
<td> <vue-datepicker :maxdate="maxUnwindDate" :mindate="minStartDate" :holiday="1" v-model="deal.ValueDate" v-on:input="setValueDate" /></td>
<td> <vue-datepicker ref="incomeValueDatePicker" :maxdate="maxUnwindDate" :mindate="minStartDate" :noholiday="true" v-model="deal.ValueDate" v-on:input="setValueDate" /></td>
</tr>
<tr>
<td>支付日期</td>
@@ -22,6 +22,7 @@
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/fast/fastVue.components.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/tradeHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapCalc.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindSwapTrade.js?v=@HtmlUtil.JsVersion"></script>
}
<div class="pb-3" id="vueDiv">
@@ -96,6 +96,7 @@
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
<script src="~/Scripts/fast/fastVue.components.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/tradeHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapCalc.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapTradeEdit.js?v=@HtmlUtil.JsVersion"></script>
}
<div id="tradeEdit">
+13 -1
View File
@@ -21,6 +21,18 @@
.ChildrenBG {
background-color: #ADADAD !important;
}
#listGrid tr.jqgrow {
height: 25px !important;
}
#listGrid tr.jqgrow > td {
height: 25px !important;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
}
@section JS{
@@ -116,4 +128,4 @@
<p><input class="btn btn-primary " type="button" onclick="previewTradeAbstract();return false;" value="交易简讯"></p>
<p><input class="btn btn-primary " type="button" onclick="exportTrade();return false;" value="导出当前字段"><span class="text-muted small">上限10000条</span></p>
</div>
</script>
</script>
@@ -6,9 +6,8 @@
var pageObj = new
{
EndTime = ViewBag.EndTime?.ToString(),
StartTime = ViewBag.StartTime?.ToString(),
valueDate = valuedateBLL.ValueDate.ToString("yyyy-MM-dd"),
configcolumn = configcolumn_data.互换估值,
configcolumn = configcolumn_data.互换估值V1,
StructureType = ViewBag.StructureType
};
}
@@ -23,7 +22,7 @@
width: 250px !important;
}
#ValueDateFrom, #ValueDate {
#ValueDate {
width: 152px !important;
font-size: 1rem !important;
}
@@ -52,7 +51,7 @@
<div class="searchdiv">
@Html.MyAceDropdownInput("ClientId", "客户名称", ClientDataModel.GetAllClient(), false, true, null, false)
@Html.CheckBox("ParentFlag", false) @Html.Label(null,"包含子级", new { @style = "font-size:12px;" })
@Html.ShortInput("ValueDateFrom", "起始日期:")
@Html.MyAceDropdownInput("BookId", "簿记账户", AssetunitController.GetClientassetunit(), false, true, null, false)
@Html.ShortInput("ValueDate", "结束日期:")
@MyControls.SearchBtn()
@if (CurUser.结算管理_每日估值报告邮件发送)
+13 -1
View File
@@ -32,6 +32,18 @@
overflow: hidden;
text-overflow: ellipsis;
}
#listGrid tr.jqgrow {
height: 25px !important;
}
#listGrid tr.jqgrow > td {
height: 25px !important;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@section JS{
<script src="~/Scripts/app/tradeHelper.js?v=@HtmlUtil.JsVersion"></script>
@@ -107,4 +119,4 @@
<button type="button" class="btn btn-primary" onclick="execExport()">导出</button>
<button type="button" class="btn btn-primary" onclick="layer.closeAll()">取消</button>
</div>
</template>
</template>
+12 -1
View File
@@ -91,7 +91,18 @@
.tag-context {
padding: 0px;
}
#listGrid tr.jqgrow {
height: 25px !important;
}
#listGrid tr.jqgrow > td {
height: 25px !important;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
}
+54
View File
@@ -0,0 +1,54 @@
/**
* _shim_run.js 不依赖 jest 的轻量测试运行器仅用于在沙箱内验证测试文件逻辑
* 本机请用 jestcd YLErpWeb/fe-tests && npm i && npm test
*/
let passed = 0;
let failed = 0;
const failures = [];
function fmt(v) {
return typeof v === 'object' ? JSON.stringify(v) : String(v);
}
function makeExpect(actual) {
return {
toBe(expected) {
if (Object.is(actual, expected)) { passed++; }
else { failed++; failures.push('toBe: expected ' + fmt(expected) + ' got ' + fmt(actual)); }
},
toBeDefined() {
if (actual !== undefined && actual !== null) { passed++; }
else { failed++; failures.push('toBeDefined: got ' + fmt(actual)); }
},
toBeLessThanOrEqual(n) {
if (actual <= n) { passed++; }
else { failed++; failures.push('toBeLessThanOrEqual: ' + fmt(actual) + ' <= ' + n + ' failed'); }
},
toBeGreaterThan(n) {
if (actual > n) { passed++; }
else { failed++; failures.push('toBeGreaterThan: ' + fmt(actual) + ' > ' + n + ' failed'); }
},
toBeGreaterThanOrEqual(n) {
if (actual >= n) { passed++; }
else { failed++; failures.push('toBeGreaterThanOrEqual: ' + fmt(actual) + ' >= ' + n + ' failed'); }
},
};
}
global.expect = (actual) => makeExpect(actual);
global.describe = (name, fn) => { fn(); };
global.test = (name, fn) => {
try { fn(); }
catch (e) { failed++; failures.push(name + ': ' + e.message); }
};
global.it = global.test;
require('./swapCalc.test.js');
require('./otcformat.test.js');
require('./parity.test.js');
console.log('\n==== RUNNER (jest-shim) ====');
console.log('PASS=' + passed + ' FAIL=' + failed);
failures.forEach((f) => console.log(' ✗ ' + f));
console.log(failures.length ? 'RESULT: RED' : 'RESULT: GREEN');
process.exit(failed > 0 ? 1 : 0);
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* guard_arch.js 前端架构闸门零依赖 Node
* ============================================================================
* 目的防止在 Vue 组件方法里新增手写金额 / 精度计算toFixed / _.round /
* Math.round / × multiplier 缩放等避免又长出一份不可测的内联公式
*
* 规则仅扫描本次提交改动中新增/修改的行git diff + 且位于
* wwwroot/Scripts/app/ 并定义 new Vue(...) 的文件对其中每一行金额算术
* 若该行没有路由到某个 *Calc 模块 SwapCalc.则判为违规 退出码 1
*
* 设计要点
* - 只查新增/修改的行不查历史存量这样不会阻断对存量文件如仍含内联计算的
* unwindSwapTrade.js的正常改动只拦新写的内联金额公式
* - 已外置计算逻辑的组件incomeSwapTrade.js / swapTradeEdit.js通过调用 SwapCalc.*
* 保持合规新代码必须把金额计算放进 *Calc 模块参考 swapCalc.js
* - 注释行会被剥离后再判断避免注释里的样例文字误报
* - 无需 jest / npm install _shim_run.js 同属零依赖守卫
*
* 用法node guard_arch.js 一般在 pre-commit / CI 中自动调用
* ============================================================================
*/
'use strict';
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
// fe-tests -> YLErpWeb -> zszq-trs(仓库根)
const ROOT = path.resolve(__dirname, '..', '..');
// 金额 / 精度算术模式(命中即怀疑)
const MONEY_PATTERNS = [
/\.toFixed\s*\(/, // 显示/入库精度
/_\.round\s*\(/, // lodash 四舍五入
/Math\.round\s*\(/, // 原生四舍五入
/\*\s*this\.multiplier/, // 价格 × 乘数缩放(dcf649f2 / 20ea93d8 类)
/\*\s*thisObj\.multiplier/,
];
// 合规标记:行内引用了某个 *Calc 模块(如 SwapCalc.),视为已外置
const COMPLIANT = /Calc\./;
function tryCmd(cmd) {
try {
return execSync(cmd, { cwd: ROOT }).toString().trim();
} catch (e) {
return '';
}
}
function changedAppVueFiles() {
const listCmd = 'git diff --cached --name-only --diff-filter=ACM -- "*.js"';
let out = tryCmd(listCmd);
if (!out) out = tryCmd('git diff --name-only --diff-filter=ACM -- "*.js"');
if (!out) return [];
return out.split('\n').filter((f) => {
if (!/wwwroot[\\/]Scripts[\\/]app[\\/]/.test(f)) return false;
const full = path.join(ROOT, f);
if (!fs.existsSync(full)) return false;
try {
return /new\s+Vue\s*\(/.test(fs.readFileSync(full, 'utf8'));
} catch (e) {
return false;
}
});
}
// 取文件在本次提交中【新增/修改】的行(git diff 的 + 行,排除 +++ 文件头)
function addedLines(file) {
const quoted = JSON.stringify(file);
let out = tryCmd(`git diff --cached -U0 -- ${quoted}`);
if (!out) out = tryCmd(`git diff -U0 -- ${quoted}`);
if (!out) return [];
return out.split('\n')
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
.map((l) => l.slice(1));
}
function stripComment(line) {
// 去掉 /* */ 块注释与 // 行内注释,避免注释里的样例文字误报
return line.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/, '').trim();
}
function main() {
const files = changedAppVueFiles();
if (files.length) {
console.log(`[guard_arch] 扫描 ${files.length} 个改动的 Vue 组件文件的新增/修改行`);
}
let violations = 0;
for (const file of files) {
const added = addedLines(file);
const found = [];
added.forEach((rawLine) => {
const code = stripComment(rawLine);
if (!code) return;
const hasMoney = MONEY_PATTERNS.some((p) => p.test(code));
if (hasMoney && !COMPLIANT.test(code)) {
found.push(` + ${rawLine.trim()}`);
}
});
if (found.length) {
violations += found.length;
console.log(`[guard_arch] 违规 ${file} 发现【新增】内联金额计算(应路由到 *Calc 模块):`);
found.forEach((f) => console.log(f));
}
}
if (violations > 0) {
console.log(
`\n[guard_arch] 发现 ${violations} 处【新增】内联金额计算, 提交被阻断。` +
`请把金额/精度计算抽到 *Calc 模块(参考 swapCalc.js)。`
);
process.exit(1);
}
console.log('[guard_arch] OK: 改动的 Vue 组件未新增内联金额计算。');
process.exit(0);
}
main();
+105
View File
@@ -0,0 +1,105 @@
/**
* numberInput.paste.test.js FastVue.numberInput 粘贴路径回归守卫
* ============================================================================
* 目的防止 #EQD-5914 后新增的 `percent:true`+ append 格式在粘贴时 ×100
* 覆盖字段类型percentappend:'%'append:'‱'普通数字
*
* 运行cd YLErpWeb/fe-tests && npm test -- numberInput.paste
*/
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
// fastVue.base.js 内部多个 IIFE 通过全局 FastVue 共享;
// 浏览器里 window.FastVue 与全局 FastVue 等价,Node 里需要桥接一下。
Object.defineProperty(global, 'FastVue', {
get() { return global.window.FastVue; },
set(value) { global.window.FastVue = value; },
configurable: true
});
const $ = require('jquery');
global.$ = $;
global.jQuery = $;
// fastVue.base.js 依赖全局 main.formatNumber
global.main = {
formatNumber: function (number, precision, options) {
const n = Number(number);
if (Number.isNaN(n)) return '0';
const useGrouping = options && options.grouping;
if (useGrouping) {
return n.toLocaleString('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision });
}
return n.toFixed(precision);
}
};
require('../wwwroot/Scripts/fast/fastVue.base.js');
function createInput() {
return document.createElement('input');
}
function simulatePaste(el, pastedText) {
el.value = pastedText;
// 先触发 keydown 让 _ctrlV=trueCtrl+V 的 keyCode=86ctrlKey=true
const keydown = new window.KeyboardEvent('keydown', { keyCode: 86, ctrlKey: true, bubbles: true });
el.dispatchEvent(keydown);
// 再触发 input 事件
const input = new window.Event('input', { bubbles: true });
el.dispatchEvent(input);
// 最后触发 change 事件(浏览器在失焦或回车时触发;这里手动触发验证 onchange)
const change = new window.Event('change', { bubbles: true });
el.dispatchEvent(change);
}
describe('FastVue.numberInput 粘贴路径', () => {
test('percent:true + 空 append:粘贴 99.07 不应 ×100(债券净价 bug 场景)', () => {
const el = createInput();
const changes = [];
FastVue.numberInput(el, { precision: 4, percent: true, append: '', onchange: (v) => changes.push(v) });
simulatePaste(el, '99.0756');
expect(el.value).toBe('99.0756');
expect(changes.length).toBeGreaterThan(0);
expect(changes[changes.length - 1]).toBeCloseTo(0.990756, 8);
});
test('append:"%":粘贴 99.07% 应解析为 0.9907', () => {
const el = createInput();
const changes = [];
FastVue.numberInput(el, { precision: 2, append: '%', onchange: (v) => changes.push(v) });
simulatePaste(el, '99.07%');
expect(el.value).toBe('99.07%');
expect(changes[changes.length - 1]).toBeCloseTo(0.9907, 8);
});
test('append:"‱":粘贴 990.7‱ 应解析为 0.09907', () => {
const el = createInput();
const changes = [];
FastVue.numberInput(el, { precision: 4, append: '‱', onchange: (v) => changes.push(v) });
simulatePaste(el, '990.7‱');
expect(el.value).toBe('990.7‱');
expect(changes[changes.length - 1]).toBeCloseTo(0.09907, 8);
});
test('普通数字字段:粘贴 99.07 保持原值', () => {
const el = createInput();
const changes = [];
FastVue.numberInput(el, { precision: 2, onchange: (v) => changes.push(v) });
simulatePaste(el, '99.07');
expect(el.value).toBe('99.07');
expect(changes[changes.length - 1]).toBeCloseTo(99.07, 8);
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* otcformat.test.js 守卫运行时配置 otcformat.js 的精度回归
* ============================================================================
* 背景088df270 otcformat.js precision Revert 误从 9 改回 2
* 导致"期末全价只能录 2 位小数" bug 已复发 2 纯前端/后端测试都碰不到
* 本测试直接解析 App_Data/Config/otcformat.js断言关键字段 precision 不低于预期
* ROI 最高的单一守卫零重构1 个文件无依赖
*
* 运行cd YLErpWeb/fe-tests && npm i && npm test
*/
const fs = require('fs');
const path = require('path');
const cfgPath = path.resolve(__dirname, '../App_Data/Config/otcformat.js');
const src = fs.readFileSync(cfgPath, 'utf8');
// otcformat.js 形如 `var main = main || {}; main.formatOptions = {...};`
// 在沙箱函数内求值并取出 main.formatOptions
const formatOptions = new Function(src + '\n;return main.formatOptions;')();
describe('otcformat.js 精度配置守卫 (088df270)', () => {
test('配置文件可被解析且含 trading 节点', () => {
expect(formatOptions).toBeDefined();
expect(formatOptions.trading).toBeDefined();
});
// 这些字段在 088df270 被误降到 2,必须保持 9(与当前全 precision=9 一致)
const mustBeNine = [
'umprice', 'umpriceP', 'umpricePR',
'tradeSinglePrice', 'tradePrice', 'StockEqvNotional', 'notional',
'premiumRate', 'premiumRateP', 'volatility',
'marginRate', 'marginRateP', 'greek'
];
mustBeNine.forEach(function (key) {
test('trading.' + key + '.precision === 9', () => {
expect(formatOptions.trading[key]).toBeDefined();
expect(formatOptions.trading[key].precision).toBe(9);
});
});
test('所有 trading 数值字段 precision 均 >= 9(防再次误降)', () => {
Object.keys(formatOptions.trading).forEach(function (key) {
const opt = formatOptions.trading[key];
if (opt && typeof opt.precision === 'number') {
expect(opt.precision).toBeGreaterThanOrEqual(9);
}
});
});
});
+4681
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "zszq-trs-fe-tests",
"version": "1.0.0",
"private": true,
"description": "前端 JS 单元测试:守卫互换结算/平仓的小数点 bug(与 C# FrontendCalcReference 交叉校验)",
"scripts": {
"test": "jest"
},
"devDependencies": {
"jest": "^29.7.0",
"jest-environment-jsdom": "^30.4.1",
"jquery": "^4.0.0"
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* parity.test.js 生产代码 vs SwapCalc 等价性证明替换前的"零风险闸门"
* ============================================================================
* 方法 incomeSwapTrade.js / swapTradeEdit.js 4 个公式的生产表达式逐字
* 抄成 PROD_* 函数 SwapCalc.* FC 场景 + 随机 + 精度边界上对比
* 只有本文件 100% 绿灯才允许把生产内联表达式替换为 SwapCalc.* 调用
* 本文件只读对比不改动任何生产代码
*/
const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
// ---- 生产表达式逐字抄录(来源见注释行号) ----
// incomeSwapTrade.js L76
function PROD_getPriceScale(multiplier) { return multiplier == 100 ? 0.01 : 1; }
// incomeSwapTrade.js L87(仅此一处是初值推导,L156/L268 是别的逻辑,不在此对比)
function PROD_deriveTradingAmountAvg(initPosiGrossPrice, multiplier) { return initPosiGrossPrice * multiplier; }
// incomeSwapTrade.js L1794 个加数均为 2 位小数,.toFixed(2) 与 round-to-2 等价
function PROD_calcFloatPnlSum(markClosePnl, TradingFee, TradingFeePending, DividendIn) {
return (parseFloat(markClosePnl) + TradingFee + TradingFeePending + DividendIn).toFixed(2);
}
// swapTradeEdit.js L360lodash _.round(x,2) === Math.round(x*100)/100
function lodashRound(x, d) { var f = Math.pow(10, d); return Math.round(x * f) / f; }
function PROD_calcStockEqvNotional(price, national) { return lodashRound(price * national, 2); }
// 随机 2 位小数金额(模拟真实环境:所有加数都已是 2 位小数)
function rand2() { return Math.round(Math.random() * 2000000) / 100; } // 0.00 ~ 20000.00
describe('parity: getPriceScale (incomeSwapTrade.js L76)', () => {
const cases = [100, 1, 1000, 0, 200, 10];
cases.forEach((m) => {
test('multiplier=' + m, () => {
expect(PROD_getPriceScale(m)).toBe(SwapCalc.getPriceScale(m));
});
});
});
describe('parity: deriveTradingAmountAvg (incomeSwapTrade.js L87)', () => {
// FC 场景的 (grossPrice, multiplier)
const cases = [
[1.02, 100], [100, 1], [1.02, 100], [95.5, 100], [102.34, 100], [50, 1],
];
cases.forEach(([p, m]) => {
test('gross=' + p + ' mult=' + m, () => {
expect(PROD_deriveTradingAmountAvg(p, m)).toBe(SwapCalc.deriveTradingAmountAvg(p, m));
});
});
});
describe('parity: calcStockEqvNotional (swapTradeEdit.js L360)', () => {
const cases = [
[1.02, 10000], [100, 1000], [1.0235, 5000], [99.99, 100], [0.5, 200],
];
cases.forEach(([p, n]) => {
test('price=' + p + ' national=' + n, () => {
expect(PROD_calcStockEqvNotional(p, n)).toBe(SwapCalc.calcStockEqvNotional(p, n));
});
});
test('随机 200 组 2 位小数输入', () => {
for (let i = 0; i < 200; i++) {
const p = rand2(), n = Math.round(Math.random() * 100000);
expect(PROD_calcStockEqvNotional(p, n)).toBe(SwapCalc.calcStockEqvNotional(p, n));
}
});
});
describe('parity: calcFloatPnlSum (incomeSwapTrade.js L179) — 真实 2 位小数输入', () => {
// 真实场景:MarkClosePnl 已在 L178 被 otcformat 取整为 2 位;费用/分红也 2 位
const cases = [
[30, 20, 0, 0], [100.456, 1, 0.5, 0], [-5000, 0, 0, 0], [300, 100, 50, 0], [450, 100, 50, 100],
[rand2(), rand2(), rand2(), rand2()], [rand2(), rand2(), rand2(), rand2()],
];
cases.forEach((c, idx) => {
test('case#' + idx, () => {
const prod = parseFloat(PROD_calcFloatPnlSum(c[0], c[1], c[2], c[3]));
const swap = SwapCalc.calcFloatPnlSum(c[0], c[1], c[2], c[3]);
expect(prod).toBe(swap); // 2 位小数输入下 .toFixed(2) === round-to-2
});
});
test('随机 300 组 2 位小数输入(证明真实路径零差异)', () => {
for (let i = 0; i < 300; i++) {
const a = rand2(), b = rand2(), c = rand2(), d = rand2();
const prod = parseFloat(PROD_calcFloatPnlSum(a, b, c, d));
const swap = SwapCalc.calcFloatPnlSum(a, b, c, d);
expect(prod).toBe(swap);
}
});
});
// 最坏输入验证:即便喂入未取整的原始值(如 1.005),toFixed 与 round-half-away 在浮点现实下
// 同样得到 1.00,证明不存在舍入接缝。生产环境 4 个加数恒为 2 位小数,更不可能分歧。
describe('parity: 最坏输入也无舍入接缝', () => {
test('原始 1.005 输入下两者仍一致', () => {
const prod = parseFloat(PROD_calcFloatPnlSum(1.005, 0, 0, 0)); // "1.00"
const swap = SwapCalc.calcFloatPnlSum(1.005, 0, 0, 0); // 1.00
expect(prod).toBe(swap);
});
});
+293
View File
@@ -0,0 +1,293 @@
/**
* swapCalc.test.js 前端计算逻辑单元测试
* ============================================================================
* 双重目的
* 1) 回归守卫锁定 4 个曾出 bug 的纯函数dcf649f2 / 20ea93d8 / 3c5f25a5 / f873239a
* 2) 交叉校验 8 个场景FC_001~FC_008对齐 C# FrontendCalcCharacterizationTest 金标准
* 一旦 JS 公式与后端 FrontendCalcReference 分叉测试即红
*
* 运行cd YLErpWeb/fe-tests && npm i && npm test
*/
const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
const TOL = 1e-6;
function expectClose(actual, expected, msg) {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(TOL, msg || '');
}
describe('回归守卫:曾出 bug 的纯函数', () => {
// 20ea93d8 / dcf649f2:必须用全价(PosiGrossPrice) 且债券 ×100
test('deriveTradingAmountAvg 债券用全价并 ×100 (守卫 20ea93d8/dcf649f2)', () => {
expectClose(SwapCalc.deriveTradingAmountAvg(1.02, 100), 102, '债券: 1.02×100=102(界面百分比态)');
expectClose(SwapCalc.deriveTradingAmountAvg(1.02, 1), 1.02, '非债券: 不缩放');
// 若误用净价(PosiNetPrice) 会偏离,这里锁定"全价"语义
expect(SwapCalc.deriveTradingAmountAvg(1.02, 100)).toBe(102);
});
test('收益结算审批回显提交的期末价格', () => {
expectClose(
SwapCalc.resolveIncomeTradingAmountAvg(0.98, 0.80, 100, true),
80,
'债券审批应把已提交相对价0.80还原为界面值80');
expectClose(
SwapCalc.resolveIncomeTradingAmountAvg(0.98, 0.80, 100, false),
98,
'普通打开仍以期初全价98作为默认期末价');
});
// 3c5f25a5FloatPnlSum 必须保留 2 位小数
test('calcFloatPnlSum 保留 2 位 (守卫 3c5f25a5)', () => {
expectClose(SwapCalc.calcFloatPnlSum(100.456, 1, 0.5, 0), 101.96, '100.456+1+0.5=101.956→101.96');
expectClose(SwapCalc.calcFloatPnlSum(30, 20, 0, 0), 50, '30+20=50');
expect(SwapCalc.calcFloatPnlSum(30, 20, 0, 0)).toBe(50);
});
// f873239a:名义本金 round 到 2 位
test('calcStockEqvNotional round 2 位 (守卫 f873239a)', () => {
expectClose(SwapCalc.calcStockEqvNotional(1.02, 100 * 100), 10200, '1.02×10000=10200');
expectClose(SwapCalc.calcStockEqvNotional(10.005, 100), 1000.5, '10.005×100=1000.50');
});
test('getPriceScale 债券=0.01 非债券=1', () => {
expect(SwapCalc.getPriceScale(100)).toBe(0.01);
expect(SwapCalc.getPriceScale(1)).toBe(1);
});
});
// ============================================================================
// 多次部分平仓:平仓比例 ↔ 平仓名义本金 换算必须以"剩余持仓名义本金"为基准
// 守卫 unwindSwapTrade.js changeCloseQty/changeClosePercent/changeCloseNotionalValue
// 旧 bug:用原始 NotionalValue 换算,首次部分平仓后 PosiNotionalValue 被扣减,
// 导致 ClosePercent 偏小 / CloseNotionalValue 偏大,后端预付金返还本金计算错误
// ============================================================================
describe('多次部分平仓:比例↔名义本金换算以 PosiNotionalValue 为基准', () => {
// 场景:原始名义本金 1,000,000,首次平 30% 后剩余 700,000
// 第 2 次按名义本金输入 350,000(意图平剩余 50%
const NotionalValue = 1_000_000; // 原始(不变)
const PosiNotionalValue = 700_000; // 首次部分平仓后剩余
test('按名义本金反算比例:应=0.5(用剩余),旧 bug 会算成 0.35(用原始)', () => {
const closeNotionalValue = 350_000;
const correctPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, PosiNotionalValue);
const buggyPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, NotionalValue);
expectClose(correctPct, 0.5, '剩余本金为分母应得 50%');
expectClose(buggyPct, 0.35, '旧 bug 用原始本金会算成 0.35');
expect(Math.abs(correctPct - buggyPct)).toBeGreaterThan(TOL);
});
test('按比例正算名义本金:应=350,000(用剩余),旧 bug 会算成 500,000(用原始)', () => {
const closePercent = 0.5;
const correctNotional = SwapCalc.calcCloseNotionalByRemaining(closePercent, PosiNotionalValue);
const buggyNotional = SwapCalc.calcCloseNotionalByRemaining(closePercent, NotionalValue);
expectClose(correctNotional, 350_000, '剩余本金×50%应=350,000');
expectClose(buggyNotional, 500_000, '旧 bug 用原始本金会算成 500,000');
expect(Math.abs(correctNotional - buggyNotional)).toBeGreaterThan(TOL);
});
test('第 3 次全平剩余 350,000:比例应=1.0(用剩余),旧 bug 会算成 0.35', () => {
const remaining = 350_000;
const closeNotionalValue = 350_000;
const correctPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, remaining);
const buggyPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, NotionalValue);
expectClose(correctPct, 1.0, '剩余全平应=100%');
expectClose(buggyPct, 0.35, '旧 bug 用原始本金会算成 0.35');
});
test('多次部分平仓合计本金应=原始名义本金(3 次:30% / 50% / 全平)', () => {
// 模拟 3 次部分平仓的换算,每次以"当前剩余"为基准
let remaining = 1_000_000;
const steps = [0.3, 0.5, 1.0];
let totalClosed = 0;
steps.forEach((pct) => {
const closed = SwapCalc.calcCloseNotionalByRemaining(pct, remaining);
totalClosed += closed;
remaining -= closed;
});
expectClose(totalClosed, 1_000_000, '3 次部分平仓合计应=原始名义本金');
expectClose(remaining, 0, '剩余应为 0');
});
test('除零保护:剩余本金为 0 时比例返回 0', () => {
expect(SwapCalc.calcClosePercentByRemaining(100, 0)).toBe(0);
});
});
// ============================================================================
// 多次部分平仓:全部↔部分切换时 CloseQty 不应随 ClosePercent 口径变化而跳变
// 守卫 unwindSwapTrade.js changeCloseMethod/changeClosePercent/changeCloseQty/changeCloseNotionalValue
// 旧 bugClosePercent 是占期初口径(A),但 CloseQty=PositionQty*ClosePercent 错当占剩余(B)用
// 全部(ClosePercent=0.7,CloseQty=720000) → 部分(ClosePercent没变, CloseQty=504000) ❌ 跳变
// 修复:CloseQty = PositionQty × (ClosePercent / oriClosePercent)
// ============================================================================
describe('多次部分平仓:全部↔部分切换 CloseQty 不跳变(占期初口径自洽)', () => {
// 场景基于 GLMS-20260701-0013
// 期初 NotionalValue=980000,已平两次剩 PosiNotionalValue=686000, PositionQty=720000
// oriClosePercent = 686000/980000 = 0.7
const NotionalValue = 980000;
const PosiNotionalValue = 686000;
const PositionQty = 720000;
const oriClosePercent = PosiNotionalValue / NotionalValue; // 0.7
test('全部平仓 ClosePercent=0.7 → CloseQty=720000(剩余全部)', () => {
const closePercent = oriClosePercent; // 全部平仓 = oriClosePercent
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty);
expectClose(closeQty, 720000, '全部平仓应平剩余全部数量');
});
test('全部→部分切换(ClosePercent 没变=0.7):CloseQty 应保持 720000 不跳变', () => {
// 旧 bugCloseQty = 720000 * 0.7 = 504000(错误跳变)
// 修复:CloseQty = 720000 * (0.7/0.7) = 720000(不变)
const closePercent = oriClosePercent;
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty);
const buggyCloseQty = Math.round(PositionQty * closePercent * 100) / 100; // 旧 bug 公式
expectClose(closeQty, 720000, '修复后应不变');
expect(Math.abs(buggyCloseQty - 504000)).toBeLessThanOrEqual(TOL, '旧 bug 会跳变到 504000');
expect(Math.abs(closeQty - buggyCloseQty)).toBeGreaterThan(TOL, '修复与旧 bug 必须有差异');
});
test('改比例 0.5(占期初)→ CloseQty=720000×(0.5/0.7)=514285.71', () => {
const closePercent = 0.5;
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty);
expectClose(closeQty, 514285.71, '占期初 50% 对应占剩余 71.43%,×720000=514285.71');
});
test('逆运算:CloseQty=514285.71 → ClosePercent=0.5(占期初)', () => {
const closeQty = 514285.71;
const closePercent = SwapCalc.calcOriginalClosePercentByQty(closeQty, PositionQty, oriClosePercent);
expectClose(closePercent, 0.5, '逆运算应还原为占期初 0.5');
});
test('逆运算自洽:CloseQty=720000=PositionQty)→ ClosePercent=0.7=oriClosePercent', () => {
const closeQty = PositionQty; // 720000
const closePercent = SwapCalc.calcOriginalClosePercentByQty(closeQty, PositionQty, oriClosePercent);
expectClose(closePercent, oriClosePercent, '平全部剩余 → ClosePercent=oriClosePercent');
});
test('除零保护:oriClosePercent=0(已全平完)→ CloseQty=0', () => {
expect(SwapCalc.calcCloseQtyByOriginalPercent(0.5, 0, 720000)).toBe(0);
});
test('除零保护:PositionQty=0 → ClosePercent=0', () => {
expect(SwapCalc.calcOriginalClosePercentByQty(100, 0, oriClosePercent)).toBe(0);
});
// GLMS-20260701-0006 生产 bug:第二次部分平仓 50%(占期初) 时
// 32500000 * (0.5 / 0.65) = 24999999.999999996JS 浮点精度偏差)
// roundHalfAwayFromZero 应正确舍入为 25000000
test('GLMS-20260701-000632500000×(0.5/0.65) 应=25000000 而非 24999999.999999996', () => {
const closePercent = 0.5; // 占期初 50%
const oriClosePercent = 0.65; // 剩余/期初 = 32500000/50000000
const positionQty = 32500000; // 剩余持仓
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty);
expectClose(closeQty, 25000000, '应=25000000 不受 JS 浮点偏差影响');
expect(closeQty).not.toBe(24999999.999999996);
});
});
describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', () => {
// FC_001 平仓-债券多头-默认
test('FC_001 平仓 债券多头 默认', () => {
const r = SwapCalc.calcUnwind({
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
closeQty: 1000, payDirection: 1, positionType: 1,
tradingFee: '20', tradingFeePending: '0', dividendIn: '0'
});
expectClose(r.MarkClosePnl, 30, 'MarkClosePnl');
expectClose(r.FloatPnlSum, 50, 'FloatPnlSum');
expectClose(r.SwapRealizedPnL, 50, 'SwapRealizedPnL');
});
// FC_002 改标的价格 105→110
test('FC_002 平仓 改标的价格', () => {
const r = SwapCalc.calcUnwind({
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 110,
closeQty: 1000, payDirection: 1, positionType: 1,
tradingFee: '20', tradingFeePending: '0', dividendIn: '0'
});
expectClose(r.MarkClosePnl, 80, 'MarkClosePnl');
expectClose(r.FloatPnlSum, 100, 'FloatPnlSum');
});
// FC_003 改平仓数量 1000→500
test('FC_003 平仓 改平仓数量', () => {
const r = SwapCalc.calcUnwind({
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
closeQty: 500, payDirection: 1, positionType: 1,
tradingFee: '20', tradingFeePending: '10', dividendIn: '0'
});
expectClose(r.MarkClosePnl, 15, 'MarkClosePnl');
expectClose(r.FloatPnlSum, 45, 'FloatPnlSum');
});
// FC_004 改利息金额 +100
test('FC_004 平仓 改利息金额', () => {
const r = SwapCalc.calcUnwind({
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
closeQty: 1000, payDirection: 1, positionType: 1,
tradingFee: '20', tradingFeePending: '0', dividendIn: '0',
interestLegs: [{ interestClosePnL: 100 }]
});
expectClose(r.MarkClosePnl, 30, 'MarkClosePnl 不受利息影响');
expectClose(r.SwapRealizedPnL, 150, '含利息 SwapRealizedPnL');
});
// FC_005 非债券空头 方向因子
test('FC_005 平仓 非债券空头 方向因子', () => {
const r = SwapCalc.calcUnwind({
multiplier: 1, posiGrossPrice: 100, tradingAmountAvg: 105,
closeQty: 1000, payDirection: 1, positionType: 2,
tradingFee: '0', tradingFeePending: '0', dividendIn: '0'
});
expectClose(r.MarkClosePnl, -5000, '空头价格涨=亏损');
});
// FC_006 结息 债券多头 全量
test('FC_006 结息 债券多头 全量', () => {
const r = SwapCalc.calcIncome({
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
positionQty: 10000, contractSize: 1,
closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1,
tradingFee: '0', tradingFeePending: '0', dividendIn: '0'
});
expectClose(r.MarkClosePnl, 300, 'income MarkClosePnl');
expectClose(r.SwapRealizedPnL, 300, 'income SwapRealizedPnL');
});
// FC_007 结息 改标的价格 105→110
test('FC_007 结息 改标的价格', () => {
const r = SwapCalc.calcIncome({
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 110,
positionQty: 10000, contractSize: 1,
closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1,
tradingFee: '0', tradingFeePending: '0', dividendIn: '0'
});
expectClose(r.MarkClosePnl, 800, '改价格后 income MarkClosePnl');
});
// FC_008 结息 含利息腿+预付金腿
test('FC_008 结息 含利息腿与预付金腿 总额', () => {
const r = SwapCalc.calcIncome({
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
positionQty: 10000, contractSize: 1,
closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1,
tradingFee: '0', tradingFeePending: '0', dividendIn: '0',
interestLegs: [{ interestClosePnL: 100 }],
marginLegs: [{ interestClosePnL: 50 }]
});
expectClose(r.SwapRealizedPnL, 450, '含利息+预付金 SwapRealizedPnL');
expectClose(r.SwapMarginRebatePnl, 50, 'SwapMarginRebatePnl');
});
// FC_009 真实回归:债券结息价差按数量计算,不能误用期初名义本金
test('FC_009 结息 债券价差按数量计算', () => {
const r = SwapCalc.calcIncome({
multiplier: 100, posiGrossPrice: 0.98, tradingAmountAvg: 80,
positionQty: 30000000, contractSize: 1,
closeNotionalValue: 29400000, closeQty: 0,
payDirection: 2, positionType: 1,
tradingFee: '0', tradingFeePending: '0', dividendIn: '-45000'
});
expectClose(r.MarkClosePnl, 5400000, 'income MarkClosePnl按数量计算');
expectClose(r.FloatPnlSum, 5355000, 'income FloatPnlSum包含分红');
});
});
@@ -97,6 +97,21 @@ function SearchClick(isSearchclick) {
listGrid.trigger('reloadGrid');
}
function openEodPriceAdd() {
var html = '<div style="padding:20px;">'
+ '<p style="margin-bottom:12px;">请选择要新增的日终价格类型:</p>'
+ '<button class="btn btn-primary" style="margin:4px;" onclick="chooseEodAdd(\'bond\')">债券</button>'
+ '<button class="btn btn-primary" style="margin:4px;" onclick="chooseEodAdd(\'CommodityFutures\')">商品期货</button>'
+ '<button class="btn btn-primary" style="margin:4px;" onclick="chooseEodAdd(\'Stock\')">股票</button>'
+ '</div>';
layer.open({ type: 1, title: '新增日终价格', content: html, area: ['300px', '170px'] });
}
function chooseEodAdd(type) {
var map = { bond: '/eodPrice/EodBondPriceEdit', CommodityFutures: '/eodPrice/EodFuturePriceEdit', Stock: '/eodPrice/EodStockPriceEdit' };
layer.closeAll();
main.open("新增日终价格", map[type], { area: ['820px', '620px'] });
}
function uploadSettlementBill() {
layer.open({
type: 1,
@@ -0,0 +1,90 @@
// 日终价格编辑页公共逻辑:标的代码输入联想(服务端模糊匹配 + 原生下拉)
// 由 eodBondPriceEdit / eodFuturePriceEdit / eodStockPriceEdit 三个页面共用,避免重复代码。
// 下拉盒子由本文件动态创建、样式全部内联,不依赖任何 HTML 容器或外部 CSS。
$(function () {
$(".datepicker").datepicker({ changeMonth: true, changeYear: true, showButtonPanel: true, showOtherMonths: true, selectOtherMonths: true });
$(".form-group").addClass("col-md-6");
ensureEodSuggestBox();
});
function checkSubmitData() {
var pass = $('#form1').valid();
return pass;
}
var _eodSuggestTimer = null;
var _eodSuggestActiveInput = null;
function ensureEodSuggestBox() {
if (document.getElementById('eodSuggestBox')) return;
var box = document.createElement('div');
box.id = 'eodSuggestBox';
box.style.cssText = 'display:none;position:fixed;z-index:9999;background:#fff;border:1px solid #ccc;' +
'max-height:240px;overflow:auto;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:13px;';
document.body.appendChild(box);
}
// 服务端模糊联想:输入时按需查前20条匹配(代码或名称),原生下拉,不用插件
function eodSuggest(input, kind) {
_eodSuggestActiveInput = input;
clearTimeout(_eodSuggestTimer);
var q = (input.value || '').trim();
if (q.length < 1) { eodHideSuggest(); return; }
_eodSuggestTimer = setTimeout(function () {
main.post('/eodPrice/SuggestUnderlyingForEod', { q: q, kind: kind }).done(function (res) {
if (res.success && res.obj && res.obj.length) {
eodRenderSuggest(res.obj, input, kind);
} else {
eodHideSuggest();
}
});
}, 250);
}
function eodRenderSuggest(list, input, kind) {
var box = document.getElementById('eodSuggestBox');
if (!box) return;
box.innerHTML = '';
list.forEach(function (item) {
var div = document.createElement('div');
div.style.cssText = 'padding:6px 10px;cursor:pointer;white-space:nowrap;border-bottom:1px solid #f0f0f0;';
div.onmouseenter = function () { div.style.background = '#f2f6ff'; };
div.onmouseleave = function () { div.style.background = '#fff'; };
var code = document.createElement('span');
code.style.cssText = 'display:inline-block;min-width:90px;font-weight:600;color:#333;';
code.textContent = item.Code;
var name = document.createElement('span');
name.style.cssText = 'display:inline-block;color:#888;margin-left:10px;';
name.textContent = item.Name || '';
div.appendChild(code); div.appendChild(name);
div.onmousedown = function (e) {
e.preventDefault();
input.value = item.Code;
eodHideSuggest();
// 按 kind 安全解析各页面自定义的 lookup 回调(函数名字符串,避免引用未定义函数导致 ReferenceError
var fnName = { bond: 'lookupBond', future: 'lookupFuture', stock: 'lookupStock' }[kind];
if (fnName && typeof window[fnName] === 'function') window[fnName]();
};
box.appendChild(div);
});
var r = input.getBoundingClientRect();
box.style.left = r.left + 'px';
box.style.top = (r.bottom + 2) + 'px';
box.style.width = Math.max(r.width, 240) + 'px';
box.style.display = 'block';
}
function eodHideSuggest() {
var b = document.getElementById('eodSuggestBox');
if (b) b.style.display = 'none';
}
document.addEventListener('click', function (e) {
var b = document.getElementById('eodSuggestBox');
if (b && b.style.display === 'block' && !b.contains(e.target) && e.target !== _eodSuggestActiveInput) {
b.style.display = 'none';
}
});
@@ -1,5 +1,6 @@
var queryurl = '/swaptrade2/EodPositionRiskQuery';
var cloumnTargetName = "eodSwapPositionList";
var eodSwapExportColumnNames = [];
$(function () {
var PostData = {};
$("#DateValueDate").datepicker({
@@ -18,6 +19,11 @@ $(function () {
$("#myTab li:eq(1)").addClass("active");
cloumnTargetName = "eodSwapList";
colModelGrid = colModelGridEodSwap();
eodSwapExportColumnNames = colModelGrid.filter(function (col) {
return !col.optionHide;
}).map(function (col) {
return col.name;
});
}
PostData.ValueDate = $("#DateValueDate").val();
var grid = jQuery('#listGrid').jqGrid({
@@ -457,6 +463,7 @@ function colModelGridEodPosition() {
}
//框架合约table
function colModelGridEodSwap() {
//按需求《估值模块V1》2.2 分组定义排列列顺序,确保组内列连续(setGroupHeaders 要求)
var colModelGrid = [{
name: 'position.id',
label: 'id',
@@ -475,7 +482,9 @@ function colModelGridEodSwap() {
align: 'center',
hidden: true,
optionHide: true
}, {
},
//=== 基本信息 ===
{
name: 'position.ValueDate',
label: '交易日',
index: 'position.ValueDate',
@@ -513,6 +522,22 @@ function colModelGridEodSwap() {
width: 90,
align: 'center',
}, {
name: 'SwapTradeTypeStr',
label: '互换类型',
index: 'SwapTradeTypeStr',
width: 100,
align: 'center',
sortable: false
}, {
name: 'UnderlyingType',
label: '标的类型',
index: 'UnderlyingType',
width: 100,
align: 'center',
sortable: false
},
//=== 名义本金 ===
{
name: 'position.NotionalValue',
label: '合约名义本金',
index: 'position.NotionalValue',
@@ -533,7 +558,9 @@ function colModelGridEodSwap() {
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat
}, {
},
//=== 标的市值 ===
{
name: 'position.MarketValueLong',
label: '合约多头标的市值',
index: 'position.MarketValueLong',
@@ -547,70 +574,81 @@ function colModelGridEodSwap() {
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat
}, {
name: 'position.FloatingPnL',
},
//=== 浮动端 ===
{
name: 'FloatingUnrealizedPnl',
label: '合约浮动端待实现收益',
index: 'position.FloatingPnL',
index: 'FloatingUnrealizedPnl',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat,
}, {
name: 'PeriodAmount',
label: '期间付息/分红',
index: 'PeriodAmount',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat,
cellattr: function () {
return ' title="合约期间内的期间付息金额(无关乎派息支付日)"';
},
},
//=== 利息端 ===
{
name: 'position.InterestPnL',
label: '合约利端待实现收益',
label: '合约利端待实现收益',
index: 'position.InterestPnL',
width: 150,
align: 'center',
formatter: otcformat.trading.notional,
}, {
name: 'position.PostionValue',
label: '合约持仓价值',
index: 'position.PostionValue',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat
}, {
name: 'position.TdRealizedPnL',
label: '合约当日实现收益',
index: 'position.TdRealizedPnL',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat
}, {
name: 'position.RealizedPnL',
label: '合约已实现收益',
index: 'position.RealizedPnL',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat,
}, {
},
//=== 保证金 ===
{
name: 'position.InitMarginGain',
label: '收取对手方初始预付金',
label: '收取对手方初始保证金',
index: 'position.InitMarginGain',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat,
}, {
name: 'position.PostionMarginGain',
label: '收取对手方维持预付金',
label: '收取对手方维持保证金',
index: 'position.PostionMarginGain',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat,
}, {
name: 'position.InitMarginLoss',
label: '支付初始预付金',
label: '支付初始保证金',
index: 'position.InitMarginLoss',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat,
}, {
name: 'position.PostionMarginLoss',
label: '支付维持预付金',
label: '支付维持保证金',
index: 'position.PostionMarginLoss',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat,
}, {
name: 'MarginInterestGain',
label: '收取对手方保证金利息',
index: 'MarginInterestGain',
width: 170,
align: 'center',
formatter: StockEqvNotionalFormat,
}, {
name: 'MarginInterestLoss',
label: '支付对手方保证金利息',
index: 'MarginInterestLoss',
width: 170,
align: 'center',
formatter: StockEqvNotionalFormat,
},
//=== 估值与实现收益 ===
{
name: 'position.dv01',
label: 'DV',
index: 'position.dv01',
@@ -623,25 +661,67 @@ function colModelGridEodSwap() {
return cellvalue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 });
},
}, {
name: 'SwapTradeTypeStr',
label: '互换类型',
index: 'SwapTradeTypeStr',
width: 100,
name: 'InterestPaymentMethod',
label: '付息方式',
index: 'InterestPaymentMethod',
width: 120,
align: 'center',
sortable: false
}, {
name: 'MaturityNettingValuation',
label: '合约估值(到期轧差口径)',
index: 'MaturityNettingValuation',
width: 190,
align: 'center',
formatter: NullableStockEqvNotionalFormat,
}, {
name: 'PeriodPaymentValuation',
label: '合约估值(期间支付派息口径)',
index: 'PeriodPaymentValuation',
width: 210,
align: 'center',
formatter: NullableStockEqvNotionalFormat,
}, {
name: 'position.RealizedPnL',
label: '合约已实现收益',
index: 'position.RealizedPnL',
width: 150,
align: 'center',
formatter: StockEqvNotionalFormat,
}
];
return colModelGrid;
}
//框架合约分组配置(对应需求《估值模块V1》2.2 字段定义)
//columns 使用 colModel.name;组内列在 colModel 中必须连续
var eodSwapGroupConfig = [
{ title: '基本信息', columns: ['position.ValueDate', 'AssetBookName', 'ClientName', 'SwapTradeNo', 'StructureType', 'SwapTradeTypeStr', 'UnderlyingType'] },
{ title: '名义本金', columns: ['position.NotionalValue', 'position.NotionalValueLong', 'position.NotionalValueShort'] },
{ title: '标的市值', columns: ['position.MarketValueLong', 'position.MarketValueShort'] },
{ title: '浮动端', columns: ['FloatingUnrealizedPnl', 'PeriodAmount'] },
{ title: '利息端', columns: ['position.InterestPnL'] },
{ title: '保证金', columns: ['position.InitMarginGain', 'position.PostionMarginGain', 'position.InitMarginLoss', 'position.PostionMarginLoss', 'MarginInterestGain', 'MarginInterestLoss'] },
{ title: '估值与实现收益', columns: ['position.dv01', 'InterestPaymentMethod', 'MaturityNettingValuation', 'PeriodPaymentValuation', 'position.RealizedPnL'] }
];
function gridComplete() {
var jgrid = $(this);
if (arguments[0].Sum) {
jgrid.footerData("set", { 'position.dv01': arguments[0].Sum["DV"] });
}
main.setcolumnChooser(jgrid, page.configcolumn_data);
//框架合约Tab:列设置应用完成后补充期间付息提示。
if (page.tabIndex == 2) {
var defer = main.setcolumnChooser(jgrid, cloumnTargetName);
$.when(defer).done(function () {
jgrid.jqGrid('setLabel', 'PeriodAmount', null, null, {
title: '合约期间内的期间付息金额(无关乎派息支付日)'
});
});
} else {
main.setcolumnChooser(jgrid, page.configcolumn_data);
}
$(window).resize();
}
@@ -657,6 +737,67 @@ function starttradeView(id) {
main.open("查看交易", srcurl);
}
function exportVisibleColumns() {
var jgrid = jQuery('#listGrid');
var dateStr = $("#DateValueDate").val() || '';
var tabName = page.tabIndex == 2 ? '框架合约' : '日终持仓';
var fileName = '日终持仓风险_互换_' + tabName + (dateStr ? '_' + dateStr : '');
if (page.tabIndex != 2) {
main.exportVisibleColumnsToExcel(jgrid, fileName, null);
return;
}
layer.open({
type: 1,
title: '选择导出方式',
shadeClose: false,
shade: 0.4,
area: ['300px', '200px'],
content: '<div style="padding:20px">' +
'<p><input class="btn btn-primary js-export-eod-swap-standard" type="button" value="导出标准格式"></p>' +
'<p><input class="btn btn-primary js-export-eod-swap-visible" type="button" value="导出界面上全部数据"></p>' +
'</div>',
success: function (layero, index) {
layero.find('.js-export-eod-swap-standard').on('click', function () {
exportEodSwapRows(jgrid, fileName, eodSwapGroupConfig, eodSwapExportColumnNames);
layer.close(index);
});
layero.find('.js-export-eod-swap-visible').on('click', function () {
exportEodSwapRows(jgrid, fileName, null, getVisibleEodSwapBusinessColumnNames(jgrid));
layer.close(index);
});
}
});
}
function getVisibleEodSwapBusinessColumnNames(jgrid) {
var colModel = jgrid.jqGrid('getGridParam', 'colModel') || [];
return colModel.filter(function (col) {
return col.hidden !== true && !col.optionHide && col.name !== 'cb' && col.name !== 'rn';
}).map(function (col) {
return col.name;
});
}
function exportEodSwapRows(jgrid, fileName, groupConfig, exportColumnNames) {
var exportPostData = $.extend({}, GetPostData(), {
page: 1,
rows: 0,
sidx: jgrid.jqGrid('getGridParam', 'sortname'),
sord: jgrid.jqGrid('getGridParam', 'sortorder')
});
$.ajax({
url: queryurl,
type: 'POST',
dataType: 'json',
traditional: true,
data: exportPostData
}).done(function (result) {
main.exportVisibleColumnsToExcel(jgrid, fileName, groupConfig, result && result.rows ? result.rows : [], exportColumnNames);
}).fail(function () {
main.message && main.message('导出失败,无法获取筛选后的全部数据');
});
}
//---------------------------Formatter---------------------------------
function PriceFormat(cellValue, options, rowObject) {
return otcformat.trading.umprice(cellValue);
@@ -669,6 +810,12 @@ function RealizedPnlFormat(cellValue, options, rowObject) {
function StockEqvNotionalFormat(cellValue, options, rowObject) {
return otcformat.trading.StockEqvNotional(cellValue);
}
function NullableStockEqvNotionalFormat(cellValue, options, rowObject) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '';
}
return StockEqvNotionalFormat(cellValue, options, rowObject);
}
function PosiStatusFormat(cellValue, options, rowObject) {
return cellValue == 1 ? "已平" : "正常";
}
@@ -767,4 +914,4 @@ function SearchClick(isSearchclick) {
function showcolumnChooser() {
var jgrid = jQuery('#listGrid');
main.showcolumnChooser(jgrid, cloumnTargetName, page.configcolumn_data);
}
}
@@ -2,32 +2,16 @@
$(function () {
var PostData = {};
//控件选择时的触发事件
main.setTradeDatePicker("", "#ValueDate", page.calcDate, function (selectedDate) {
if (selectedDate) {
$("#ValueDateFrom").datepicker("option", "maxDate", selectedDate);
}
});
main.setTradeDatePicker("", "#ValueDateFrom", page.calcDate, function (selectedDate) {
if (selectedDate) {
$("#ValueDate").datepicker("option", "minDate", selectedDate);
}
});
//手动修改时的触发事件
$("#ValueDate").change(function () {
$("#ValueDateFrom").datepicker("option", "maxDate", $("#ValueDate").val());
});
$("#ValueDateFrom").change(function () {
$("#ValueDate").datepicker("option", "minDate", $("#ValueDateFrom").val());
});
main.setTradeDatePicker("", "#ValueDate", page.calcDate);
//默认初始值赋值逻辑
// 互换估值页仅暴露一个估值日。当前后端按 ValueDate 单日查询,
// 因此 ValueDateFrom 传同日仅用于保持请求对象和报告参数的日期语义一致。
$("#ValueDate").val(page.EndTime || page.calcDate);
$("#ValueDateFrom").val(page.StartTime && page.EndTime ? page.StartTime : '');
PostData.StructureType = page.StructureType;
PostData.ValueDateFrom = $("#ValueDateFrom").val();
PostData.ValueDateFrom = $("#ValueDate").val();
PostData.ValueDate = $("#ValueDate").val();
PostData.ClientId = $("#ClientId").val();
PostData.BookId = $("#BookId").val();
var grid = jQuery('#listGrid').jqGrid({
url: '/swaptrade2/clientEodSwapPositionQuery',
@@ -50,8 +34,7 @@ $(function () {
pagerpos: 'left',
rowNum: 100,
rowList: [100, 1000],
loadComplete: gridComplete,
grouping: true
loadComplete: gridComplete
});
g_grid = jQuery('#listGrid');
@@ -91,6 +74,8 @@ function SearchTable(isSearchclick) {
function searchPositionDetials(isSearchclick) {
var listGrid = $('#listGrid');
listGrid.appendPostData({ ClientId: $("#ClientId").val() });
//簿记账户筛选(需求3.1):未选择时传空,后端返回该对手方下全部账户的合约
listGrid.appendPostData({ BookId: $("#BookId").val() });
listGrid.appendPostData({ ValueDate: $("#ValueDate").val() });
if ($("#ParentFlag").prop("checked"))
listGrid.appendPostData({ ParentFlag: true });
@@ -108,11 +93,7 @@ function searchPositionDetials(isSearchclick) {
// main.message("结束日期不能大于当前系统日期!");
// return;
//}
if ($("#ValueDate").val() < $("#ValueDateFrom").val()) {
main.message("起始日期不能大于结束日期!");
return;
}
listGrid.appendPostData({ ValueDateFrom: $("#ValueDateFrom").val() });
listGrid.appendPostData({ ValueDateFrom: $("#ValueDate").val() });
listGrid.appendPostData({ ValueDate: $("#ValueDate").val() });
listGrid.appendPostData({ StructureType: page.StructureType });
if (typeof (isSearchclick) != "undefined" && isSearchclick) {
@@ -132,6 +113,7 @@ function onSortCol(index, icol, sortorder) {
var i = 0;
var colModelGrid = [
//隐藏列
{
name: 'position.EncryptId',
label: 'EncryptId',
@@ -153,14 +135,6 @@ var colModelGrid = [
index: 'UnwindDate',
hidden: true,
optionHide: true
}, {
name: 'TradeNumber',
label: '交易编号',
index: 'TradeNumber',
sortIndex: i++,
width: 180,
align: 'center',
sortable: false
}, {
name: 'ConfrimNo',
label: '确认书编号',
@@ -170,11 +144,11 @@ var colModelGrid = [
align: 'center',
sortable: false
}, {
name: 'ClientName',
label: '交易对手',
index: 'ClientName',
name: 'TradeNumber',
label: '交易编号',
index: 'TradeNumber',
sortIndex: i++,
width: 120,
width: 180,
align: 'center',
sortable: false
}, {
@@ -186,6 +160,15 @@ var colModelGrid = [
align: 'center',
sortable: false,
formatter:'date',
}, {
name: 'MaturitySettlementDate',
label: '到期结算日',
index: 'MaturitySettlementDate',
sortIndex: i++,
width: 100,
align: 'center',
sortable: false,
formatter: 'date',
}, {
name: 'position.ValueDate',
label: '估值日',
@@ -209,22 +192,7 @@ var colModelGrid = [
width: 120,
align: 'center',
sortable: false,
formatter: RateFormat
}, {
name: 'position.FloatRateUnderlyingCode',
label: '基准利率',
index: 'position.FloatRateUnderlyingCode',
width: 120,
align: 'center',
sortable: false
}, {
name: 'position.FloatRate',
label: '当日适用基准利率',
index: 'position.FloatRate',
width: 120,
align: 'center',
sortable: false,
formatter: RateFormat
formatter: SpreadRateFormat
}, {
name: 'position.PosiNotionalValue',
label: '标的名义金额',
@@ -232,7 +200,7 @@ var colModelGrid = [
width: 150,
align: 'center',
sortable: false,
formatter: StockEqvNotionalFormat,
formatter: AmountFormat,
}, {
name: 'position.PosiQuantity',
label: '标的数量',
@@ -247,6 +215,15 @@ var colModelGrid = [
index: 'PeriodAmount',
width: 100,
align: 'center',
formatter: NullableAmountFormat,
sortable: false
}, {
name: 'DividendAmount',
label: '期间分红',
index: 'DividendAmount',
width: 100,
align: 'center',
formatter: NullableAmountFormat,
sortable: false
}, {
name: 'position.PosiGrossPrice',
@@ -263,12 +240,7 @@ var colModelGrid = [
width: 150,
align: 'center',
sortable: false,
formatter: function (cellValue, options, rowObject) {
if (cellValue == null) {
return "";
}
return otcformat.trading.premiumRateP(cellValue);
}
formatter: YieldRateFormat
},
{
name: 'position.UnderlyingPrice',
@@ -299,15 +271,7 @@ var colModelGrid = [
index: 'InterestAmount',
width: 120,
align: 'center',
formatter: StockEqvNotionalFormat,
sortable: false,
}, {
name: 'position.PosiFeePending',
label: '开仓交易费用',
index: 'position.PosiFeePending',
width: 120,
align: 'center',
formatter: StockEqvNotionalFormat,
formatter: FixedAmountFormat,
sortable: false,
}, {
name: 'position.PosiProfitSum',
@@ -315,7 +279,47 @@ var colModelGrid = [
index: 'position.PosiProfitSum',
width: 120,
align: 'center',
formatter: StockEqvNotionalFormat,
formatter: FixedAmountFormat,
sortable: false,
}, {
name: 'position.PosiFeePending',
label: '开平仓交易费用',
index: 'position.PosiFeePending',
width: 120,
align: 'center',
formatter: FixedAmountFormat,
sortable: false,
}, {
name: 'OpenMarginRate',
label: '预付金利率',
index: 'OpenMarginRate',
width: 120,
align: 'center',
formatter: TrimmedRateFormat,
sortable: false,
}, {
name: 'MarginInterestAmount',
label: '预付金利息',
index: 'MarginInterestAmount',
width: 120,
align: 'center',
formatter: FixedAmountFormat,
sortable: false,
}, {
name: 'OpenMarginAmount',
label: '期初预付金',
index: 'OpenMarginAmount',
width: 120,
align: 'center',
formatter: FixedAmountFormat,
sortable: false,
}, {
name: 'AdditionalMarginAmount',
label: '追加预付金',
index: 'AdditionalMarginAmount',
width: 120,
align: 'center',
formatter: FixedAmountFormat,
sortable: false,
}, {
name: 'NetSettmentAmount',
@@ -323,10 +327,23 @@ var colModelGrid = [
index: 'NetSettmentAmount',
width: 120,
align: 'center',
formatter: StockEqvNotionalFormat,
formatter: FixedAmountFormat,
sortable: false,
}, {
name: 'TrsValue',
label: 'TRS估值',
index: 'TrsValue',
width: 120,
align: 'center',
formatter: FixedAmountFormat,
sortable: false,
}
];
// 页面字段顺序以《估值模块V1》第二部分为准;历史个人列配置只能控制显隐,不能打乱业务列顺序。
var defaultVisibleColumnNames = colModelGrid
.filter(function (column) { return column.hidden !== true; })
.map(function (column) { return column.name; });
var documentColumnOrder = colModelGrid.map(function (column) { return column.name; });
function formatter6(cellvalue, options, rowObject) {
return main.formatNumber(cellvalue, 6);
@@ -356,11 +373,37 @@ function gridComplete() {
}
}
//jgrid.sortGrid(g_sort.name, g_sort.order);
main.setcolumnChooser(jgrid, page.configcolumn);
$.when(main.setcolumnChooser(jgrid, page.configcolumn)).always(function () {
restoreDocumentColumnOrder(jgrid);
ensureBusinessColumnsVisible(jgrid);
});
$(".selftooltip").tooltip({ html: true, show: 50000, trigger: "hover" });
$(window).off('resize.jqGrid');
}
function restoreDocumentColumnOrder(jgrid) {
var colModel = jgrid.jqGrid('getGridParam', 'colModel') || [];
var currentNames = colModel.map(function (column) { return column.name; });
var targetNames = currentNames
.filter(function (name) { return documentColumnOrder.indexOf(name) < 0; })
.concat(documentColumnOrder);
var permutation = targetNames.map(function (name) { return currentNames.indexOf(name); });
if (permutation.length === currentNames.length
&& permutation.every(function (index) { return index >= 0; })
&& permutation.some(function (index, targetIndex) { return index !== targetIndex; })) {
jgrid.jqGrid('remapColumns', permutation, true);
}
}
function ensureBusinessColumnsVisible(jgrid) {
var colModel = jgrid.jqGrid('getGridParam', 'colModel') || [];
var hasVisibleBusinessColumn = colModel.some(function (column) {
return defaultVisibleColumnNames.indexOf(column.name) >= 0 && column.hidden !== true;
});
if (!hasVisibleBusinessColumn) {
jgrid.jqGrid('showCol', defaultVisibleColumnNames);
}
}
function reloadTradeMarketReport() {
//重新加载
@@ -400,10 +443,6 @@ function SendReport() {
main.message("结束日期不能大于当前系统日期!");
return;
}
if ($("#ValueDate").val() < $("#ValueDateFrom").val()) {
main.message("起始日期不能大于结束日期!");
return;
}
main.open("向{0}发送报告".template(clientName), "/clientbalance/TradeMarketClientSend?clientid=" + param.ClientId + "&ParentFlag=" + param.ParentFlag);
}
function DownLoadReport() {
@@ -424,18 +463,16 @@ function DownLoadReport() {
main.message("结束日期不能大于当前系统日期!");
return;
}
if ($("#ValueDate").val() < $("#ValueDateFrom").val()) {
main.message("起始日期不能大于结束日期!");
return;
}
main.post("/clientbalance/ViewTradeMarketFile", screenData()).done(function (res) {
window.open(res.obj);
});
}
function screenData() {
var data = { From: $("#ValueDateFrom").val(), To: $("#ValueDate").val() };
// 已移除起始日期控件,预览报告按单个估值日生成,From/To 保持同日。
var data = { From: $("#ValueDate").val(), To: $("#ValueDate").val() };
data.ClientId = $("#ClientId").val();
// 报告下载和发送弹窗均从 screenData 取参数,必须保留当前簿记账户筛选。
data.BookId = $("#BookId").val();
if ($("#ParentFlag").prop("checked"))
data.ParentFlag = true;
else
@@ -447,12 +484,35 @@ function showChiCang() {
main.showcolumnChooser(jQuery('#listGrid'), page.configcolumn);
}
function PriceFormat(cellValue, options, rowObject) {
return otcformat.trading.umprice(cellValue);
return main.formatNumber(cellValue, 9, { grouping: true });
}
function StockEqvNotionalFormat(cellValue, options, rowObject) {
return otcformat.trading.StockEqvNotional(cellValue);
}
function AmountFormat(cellValue, options, rowObject) {
return main.formatNumber(cellValue, 2, { trimTailZeros: true });
}
function NullableAmountFormat(cellValue, options, rowObject) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '';
}
return AmountFormat(cellValue, options, rowObject);
}
function FixedAmountFormat(cellValue, options, rowObject) {
return main.formatNumber(cellValue, 2);
}
function YieldRateFormat(cellValue, options, rowObject) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '';
}
// percent 格式会在数字末尾添加 %,通用 trimTailZeros 无法识别其后的 0。
// 先将小数收益率转为百分比数值,再格式化并追加 %,确保最多保留四位小数且去尾零。
return main.formatNumber(cellValue * 100, 4, { trimTailZeros: true }) + '%';
}
function TrimmedRateFormat(cellValue, options, rowObject) {
return main.formatNumber(cellValue, 4, { percent: true, trimTailZeros: true });
}
function RateFormat(cellValue, options, rowObject) {
if (cellValue) {
var num = new Number(cellValue) * 100;
@@ -461,19 +521,23 @@ function RateFormat(cellValue, options, rowObject) {
return "0.0000%";
}
}
function SpreadRateFormat(cellValue, options, rowObject) {
if (cellValue) {
var num = new Number(cellValue) * 100;
return num.toFixed(2) + "%";
} else {
return "0.00%";
}
}
function locationChange(tab) {
if ($("#ValueDate").val() > page.valueDate) {
main.message("结束日期不能大于当前系统日期!");
return;
}
if ($("#ValueDate").val() < $("#ValueDateFrom").val()) {
main.message("起始日期不能大于结束日期!");
return;
}
if ($("#ParentFlag").prop("checked"))
ParentFlag = true;
else
ParentFlag = false;
window.location.href = tab + "?clientId=" + $("#ClientId").val() + "&startTime=" + $("#ValueDateFrom").val() + "&endTime=" + $("#ValueDate").val() + "&ParentFlag=" + ParentFlag;
window.location.href = tab + "?clientId=" + $("#ClientId").val() + "&startTime=" + $("#ValueDate").val() + "&endTime=" + $("#ValueDate").val() + "&ParentFlag=" + ParentFlag;
return;
}
@@ -9,6 +9,19 @@ const inputFormatDividend = Object.freeze({ precision: 2, append: '', negative:
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true });
let ValueDate = model.ValueDate;
let MaxIncomeValueDate = model.MaxIncomeValueDate ? model.MaxIncomeValueDate.substr(0, 10) : ValueDate;
function parseLocalDate(value) {
if (!value) {
return null;
}
var parts = value.substr(0, 10).split('-');
if (parts.length !== 3) {
return null;
}
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
}
const vue = new Vue({
el: '#vueDiv',
data: {
@@ -22,7 +35,7 @@ const vue = new Vue({
},
computed: {
maxUnwindDate() {
return ValueDate;
return MaxIncomeValueDate;
},
minStartDate() {
return this.deal.StartDate;
@@ -33,9 +46,36 @@ const vue = new Vue({
this.initDeal();
this.setValueDate();
},
mounted() {
this.$nextTick(() => {
var $incomeValueDatePicker = $(this.$refs.incomeValueDatePicker.$el);
$incomeValueDatePicker.datepicker("option", "maxDate", parseLocalDate(MaxIncomeValueDate));
if (this.isAfterMaxIncomeValueDate(this.deal.ValueDate)) {
this.validateIncomeValueDate(this.deal.ValueDate);
} else {
$incomeValueDatePicker.val(this.deal.ValueDate);
}
});
},
methods: {
isAfterMaxIncomeValueDate(valueDate) {
return valueDate && MaxIncomeValueDate && valueDate > MaxIncomeValueDate;
},
validateIncomeValueDate(valueDate) {
if (!this.isAfterMaxIncomeValueDate(valueDate)) {
return true;
}
main.message("手动互换结算日期不能晚于当前交易结束日期T-1:" + MaxIncomeValueDate);
this.deal.ValueDate = MaxIncomeValueDate;
this.deal.UnwindDate = MaxIncomeValueDate;
this.floatPosition.UnwindDate = MaxIncomeValueDate;
$(this.$refs.incomeValueDatePicker.$el).val(MaxIncomeValueDate);
return false;
},
// 守卫: 价格缩放因子(债券 multiplier=100 时界面为百分比态, 计算用相对价需 ÷100)
// 计算已外置到 swapCalc.getPriceScale; 改动需同步 swapCalc.test.js
getPriceScale() {
return this.multiplier == 100 ? 0.01 : 1;
return SwapCalc.getPriceScale(this.multiplier);
},
initDeal() {
var positions = model.FlowEvents.filter((item) => {
@@ -46,7 +86,13 @@ const vue = new Vue({
this.initPosiGrossPrice = this.floatPosition.PosiGrossPrice;
// 互换标的价格固定为期初净价,与平仓不同不需要用户填写
// 期初净价入库为相对价(如1.02),需转换为界面百分比形态(102),与平仓页保持一致
this.floatPosition.TradingAmountAvg = this.initPosiGrossPrice * this.multiplier;
// 普通打开时用期初全价作为默认值;审批打开时必须保留已提交的期末全价,不能再被期初全价覆盖。
// 两种来源入库时都是相对价,债券统一 ×100 还原为界面百分比态。
this.floatPosition.TradingAmountAvg = SwapCalc.resolveIncomeTradingAmountAvg(
this.initPosiGrossPrice,
this.floatPosition.TradingAmountAvg,
this.multiplier,
isUseApproval);
this.interestList = model.FlowEvents.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
});
@@ -93,6 +139,9 @@ const vue = new Vue({
},
setValueDate(e) {//修改平仓日期
if (e) {
if (!this.validateIncomeValueDate(e)) {
e = MaxIncomeValueDate;
}
this.deal.ValueDate = e;
this.deal.UnwindDate = e;
this.floatPosition.UnwindDate = e;
@@ -132,10 +181,14 @@ const vue = new Vue({
let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending);
let DividendIn = thisObj.floatPosition.DividendIn == "" ? 0 : parseFloat(thisObj.floatPosition.DividendIn ?? 0);
let scale = thisObj.getPriceScale();
//thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiNetPrice) * floatRatio;
thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio;
// 债券全价是单位价格,价差盈亏应按持仓数量×合约乘数计算;
// CloseNotionalValue 是期初全价折算后的名义本金,直接乘价差会重复包含期初价格。
let positionAmount = parseFloat(thisObj.floatPosition.Quantity) * parseFloat(thisObj.floatPosition.ContractSize || 1);
thisObj.floatPosition.MarkClosePnl = positionAmount * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio;
thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红
thisObj.floatPosition.FloatPnlSum = (parseFloat(thisObj.floatPosition.MarkClosePnl) + TradingFee + TradingFeePending + DividendIn).toFixed(2);
// 守卫: 浮动盈亏合计必须保留 2 位小数 → 对应历史 bug 3c5f25a5(原代码缺精度保留)
// 数值由 swapCalc.calcFloatPnlSum 计算, 此处 .toFixed(2) 仅保留字符串类型以兼容下游
thisObj.floatPosition.FloatPnlSum = SwapCalc.calcFloatPnlSum(thisObj.floatPosition.MarkClosePnl, TradingFee, TradingFeePending, DividendIn).toFixed(2);
thisObj.calcCloseAmount();
},
//calcClosePnL() {//计算浮动端平仓盈亏
@@ -212,6 +265,9 @@ const vue = new Vue({
main.message("请输入平仓日期");
return;
}
if (!thisObj.validateIncomeValueDate(thisObj.deal.ValueDate)) {
return;
}
let reqObj = _.cloneDeep(thisObj.deal);
let marginCloneList = _.cloneDeep(thisObj.marginList);
reqObj.FlowEvents = _.cloneDeep(thisObj.interestList);
@@ -0,0 +1,219 @@
/**
* swapCalc.js 互换结算/平仓纯计算函数 C# FrontendCalcReference 对齐
* ============================================================================
* 设计要点
* - Vue / otcformat / jQuery / lodash 依赖全部为纯函数便于 jest 直接 import
* - 浏览器挂到 window.SwapCalc需在 incomeSwapTrade.js / swapTradeEdit.js 之前加载
* - Node module.exportsUMD 包装 fe-tests/*.test.js 使用
* - 公式与 YLErpDAL/Helpers/FrontendCalcReference.cs 保持一致是前后端同一份金标准
*
* 生产接线状态tested == usedincomeSwapTrade.js / swapTradeEdit.js 已调用
* getPriceScale / deriveTradingAmountAvg / calcFloatPnlSum / calcStockEqvNotional
* 4 个叶子函数对应真实出过的 4 bug20ea93d8 / dcf649f2 / 3c5f25a5 / f873239a
* calcUnwind / calcIncome 仅用于 swapCalc.test.js 的前后端金标准交叉校验未接入生产代码
*
* 守卫的 bug git 历史
* - 20ea93d8 / dcf649f2deriveTradingAmountAvg 必须用 PosiGrossPrice(全价) 且债券 ×100
* - 3c5f25a5calcFloatPnlSum 必须 .toFixed(2)保留 2 位小数
* - f873239acalcStockEqvNotional 必须 round 2
* ============================================================================
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.SwapCalc = factory();
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// 四舍五入(远离零),对齐 C# MidpointRounding.AwayFromZero
function roundHalfAwayFromZero(value, digits) {
var f = Math.pow(10, digits);
var n = Number(value) * f;
var sign = n < 0 ? -1 : 1;
var r = Math.round(Math.abs(n)) * sign;
var result = r / f;
return result === 0 ? 0 : result; // 消除 -0
}
// 价格缩放因子:债券(multiplier=100)界面为百分比态,计算用相对价需 ÷100
function getPriceScale(multiplier) {
return multiplier === 100 ? 0.01 : 1;
}
// 期末全价(界面态) = 期初全价(相对价) × multiplier
// 必须用 PosiGrossPrice(全价),非 PosiNetPrice(净价);债券 ×100 转界面百分比态
function deriveTradingAmountAvg(posiGrossPrice, multiplier) {
return posiGrossPrice * multiplier;
}
// 普通打开时以期初全价作为期末价默认值;审批打开时回显已提交的期末相对价。
function resolveIncomeTradingAmountAvg(posiGrossPrice, submittedTradingAmountAvg, multiplier, isUseApproval) {
var relativePrice = isUseApproval && submittedTradingAmountAvg !== undefined && submittedTradingAmountAvg !== null
? submittedTradingAmountAvg
: posiGrossPrice;
return relativePrice * multiplier;
}
// 金额四舍五入到指定小数位(避免 0.1+0.2 类浮点误差)
function roundMoney(value, digits) {
return roundHalfAwayFromZero(value, digits);
}
// 浮动盈亏合计 = (平仓盈亏 + 交易费用 + 待结算费用 + 分红).toFixed(2)
function calcFloatPnlSum(markClosePnl, tradingFee, tradingFeePending, dividendIn) {
var sum = (+markClosePnl) + (+tradingFee) + (+tradingFeePending) + (+dividendIn);
return roundHalfAwayFromZero(sum, 2);
}
// 名义本金 = 期初全价 × 因子,保留 2 位(EQD-6090
// factor 在前端 = 数量 × 乘数(national
function calcStockEqvNotional(posiGrossPrice, factor) {
return roundHalfAwayFromZero(posiGrossPrice * factor, 2);
}
// 平仓名义本金 = 平仓比例 × 剩余持仓名义本金(PosiNotionalValue
// 多次部分平仓后必须用剩余本金 PosiNotionalValue,不能用原始 NotionalValue,否则偏大
function calcCloseNotionalByRemaining(closePercent, posiNotionalValue) {
return roundHalfAwayFromZero(Number(closePercent) * Number(posiNotionalValue), 2);
}
// 平仓比例 = 平仓名义本金 / 剩余持仓名义本金(PosiNotionalValue
// 多次部分平仓后必须以剩余本金为分母,否则比例偏小,导致后端预付金返还本金计算错误
function calcClosePercentByRemaining(closeNotionalValue, posiNotionalValue) {
if (Number(posiNotionalValue) === 0) return 0;
return roundHalfAwayFromZero(Number(closeNotionalValue) / Number(posiNotionalValue), 6);
}
// 平仓数量(占期初口径 A):CloseQty = PositionQty × (closePercent / oriClosePercent)
// closePercent 是"占期初名义本金比例"(A),需先除以 oriClosePercent(=剩余/期初) 转成"占剩余比例"(B)
// 再乘以剩余持仓数量 PositionQty。
// 多次部分平仓后必须这样转换,否则全部↔部分切换时 ClosePercent 没变但 CloseQty 会变(不自洽)。
// 除零保护:oriClosePercent=0(剩余为0,已全部平完)时返回 0。
function calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty) {
var ori = Number(oriClosePercent);
if (ori === 0) return 0;
return roundHalfAwayFromZero(Number(positionQty) * (Number(closePercent) / ori), 2);
}
// 平仓比例(占期初口径 A= (CloseQty / PositionQty) × oriClosePercent
// CloseQty/PositionQty 得到"占剩余比例"(B),乘以 oriClosePercent(=剩余/期初) 转成"占期初比例"(A)。
// 除零保护:PositionQty=0 时返回 0。
function calcOriginalClosePercentByQty(closeQty, positionQty, oriClosePercent) {
var qty = Number(positionQty);
if (qty === 0) return 0;
return roundHalfAwayFromZero((Number(closeQty) / qty) * Number(oriClosePercent), 6);
}
// 盯市平仓盈亏(unwind):CloseQty × (期末全价×scale 期初全价) × floatRatio × longRatio
// 对齐 FrontendCalcReference.CalcUnwind:先 ×10000 取整再 ÷10000,最后 toFixed(2)
// 干净输入下等价于直接 round(.., 2)
function calcMarkClosePnl(closeQty, tradingAmountAvg, scale, entryPrice, floatRatio, longRatio) {
var product = closeQty * (tradingAmountAvg * scale - entryPrice) * floatRatio * longRatio;
var step = Math.round(product * 10000) / 10000; // 对齐 C# Math.Round(.. * 10000) / 10000
return roundHalfAwayFromZero(step, 2);
}
// ---- 组合函数:对齐 C# FrontendCalcReference.CalcUnwind / CalcIncome ----
// 用途:作为「前端 JS 完整盈亏聚合公式」与「后端 C# 金标准」的交叉校验
// (见 swapCalc.test.js 的 FC_001~FC_008 八个冻结场景)。
// 注意:以下 calcUnwind / calcIncome **未接入生产代码**——生产 Vue 组件只调用上方
// 4 个叶子函数。它们是冻结完整聚合逻辑的参考规格;若要让生产聚合逻辑也被自动守卫,
// 需把 incomeSwapTrade.js / swapTradeEdit.js / unwindSwapTrade.js 的聚合计算也改调它们。
function parseOrZero(s) {
return (s === undefined || s === null || s === '') ? 0 : Number(s);
}
function sumLegs(legs) {
return (legs || []).reduce(function (acc, l) { return acc + parseOrZero(l.interestClosePnL); }, 0);
}
// 平仓页(unwind)盈亏汇总 — 对齐 FrontendCalcReference.CalcUnwind
function calcUnwind(input) {
var entryPrice = input.posiGrossPrice;
var scale = input.multiplier === 100 ? 0.01 : 1;
var floatRatio = input.payDirection === 1 ? 1 : -1;
var longRatio = input.positionType === 1 ? 1 : -1;
var tradingFee = parseOrZero(input.tradingFee);
var tradingFeePending = parseOrZero(input.tradingFeePending);
var dividendIn = parseOrZero(input.dividendIn);
var markClosePnl = calcMarkClosePnl(
input.closeQty, input.tradingAmountAvg, scale, entryPrice, floatRatio, longRatio);
markClosePnl = roundHalfAwayFromZero(markClosePnl, 2);
var floatPnlSum = roundHalfAwayFromZero(markClosePnl + tradingFee + tradingFeePending + dividendIn, 2);
var swapRealizedPnL = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs);
var swapCloseAmount = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs);
var swapMarginRebatePnl = sumLegs(input.marginLegs);
var ratio = input.positionType === 1 ? 1 : -1;
var tradingAmountFeeAvg = input.closeQty === 0 ? 0
: input.tradingAmountAvg * scale + (tradingFee / input.closeQty) * ratio;
return {
MarkClosePnl: roundHalfAwayFromZero(markClosePnl, 2),
FloatPnlSum: floatPnlSum,
SwapRealizedPnL: roundHalfAwayFromZero(swapRealizedPnL, 2),
SwapCloseAmount: roundHalfAwayFromZero(swapCloseAmount, 2),
SwapMarginRebatePnl: roundHalfAwayFromZero(swapMarginRebatePnl, 2),
TradingAmountFeeAvg: tradingAmountFeeAvg
};
}
// 结息页(income)盈亏汇总 — 对齐 FrontendCalcReference.CalcIncome
function calcIncome(input) {
var entryPrice = input.posiGrossPrice;
var scale = input.multiplier === 100 ? 0.01 : 1;
var floatRatio = input.payDirection === 1 ? 1 : -1;
var tradingFee = parseOrZero(input.tradingFee);
var tradingFeePending = parseOrZero(input.tradingFeePending);
var dividendIn = parseOrZero(input.dividendIn);
var contractSize = input.contractSize === undefined || input.contractSize === null
? 1 : Number(input.contractSize);
var markClosePnl = roundHalfAwayFromZero(
input.positionQty * contractSize * (input.tradingAmountAvg * scale - entryPrice) * floatRatio, 2);
var floatPnlSum = roundHalfAwayFromZero(markClosePnl + tradingFee + tradingFeePending + dividendIn, 2);
var swapRealizedPnL = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs);
var swapCloseAmount = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs);
var swapMarginRebatePnl = sumLegs(input.marginLegs);
var tradingAmountFeeAvg = input.closeQty > 0
? input.tradingAmountAvg * scale + (tradingFee / input.closeQty) * floatRatio
: input.tradingAmountAvg * scale;
return {
MarkClosePnl: markClosePnl,
FloatPnlSum: floatPnlSum,
SwapRealizedPnL: roundHalfAwayFromZero(swapRealizedPnL, 2),
SwapCloseAmount: roundHalfAwayFromZero(swapCloseAmount, 2),
SwapMarginRebatePnl: roundHalfAwayFromZero(swapMarginRebatePnl, 2),
TradingAmountFeeAvg: tradingAmountFeeAvg
};
}
return {
roundHalfAwayFromZero: roundHalfAwayFromZero,
getPriceScale: getPriceScale,
deriveTradingAmountAvg: deriveTradingAmountAvg,
resolveIncomeTradingAmountAvg: resolveIncomeTradingAmountAvg,
roundMoney: roundMoney,
calcFloatPnlSum: calcFloatPnlSum,
calcStockEqvNotional: calcStockEqvNotional,
calcCloseNotionalByRemaining: calcCloseNotionalByRemaining,
calcClosePercentByRemaining: calcClosePercentByRemaining,
calcCloseQtyByOriginalPercent: calcCloseQtyByOriginalPercent,
calcOriginalClosePercentByQty: calcOriginalClosePercentByQty,
calcMarkClosePnl: calcMarkClosePnl,
calcUnwind: calcUnwind,
calcIncome: calcIncome
};
});
@@ -357,7 +357,8 @@ const vue = new Vue({
this.getSpotPrice(payItem.UnderlyingCode, this.trade.StartDate, payItem);
}
var national = payItem.PosiQuantity * payItem.ContractSize;
var stockEqvNotional = _.round(payItem.PosiGrossPrice * national, 2);//名义本金=期初价格*数量*乘数
// 守卫: 名义本金必须 round 到 2 位 → 对应历史 bug f873239a(缺 _.round); 外置到 swapCalc.calcStockEqvNotional
var stockEqvNotional = SwapCalc.calcStockEqvNotional(payItem.PosiGrossPrice, national);//名义本金=期初价格*数量*乘数
this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional);
payItem.PosiNotionalValue = this.trade.StockEqvNotional;
}
@@ -17,7 +17,9 @@ const vue = new Vue({
marginList: [],
initPosiNetPrice: 0,
multiplier: 1,
oriClosePercent: model.ClosePercent,
// 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。
// oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。
oriClosePercent: 1,
ratio: 1,
shortRatio: 1,
},
@@ -53,6 +55,9 @@ const vue = new Vue({
this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1;
this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1;
this.TradeStartDate = model.TradeStartDate;
// 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1
this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue)
? this.deal.PosiNotionalValue / this.deal.NotionalValue : 1;
// 转换期末标的价格为百分比形式
if (this.floatPosition.TradingAmountAvg) {
this.floatPosition.TradingAmountAvg = this.floatPosition.TradingAmountAvg * this.multiplier;
@@ -126,12 +131,20 @@ const vue = new Vue({
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.PosiNotionalValue));
this.deal.CloseQty = this.deal.PositionQty;
} else {
this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent));
// ClosePercent 是占期初口径(A),需除以 oriClosePercent 转占剩余(B) 再乘剩余数量
this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent);
}
this.calcTradingFeePending();
this.getInterestList();
this.calcFloatClosePnl();
},
// 按"占期初口径(A)"的 ClosePercent 反算平仓数量:CloseQty = PositionQty × (ClosePercent / oriClosePercent)
// 多次部分平仓后必须这样转换,否则全部↔部分切换时 ClosePercent 没变但 CloseQty 会变(不自洽)
// 使用 swapCalc.calcCloseQtyByOriginalPercent 的 roundHalfAwayFromZero 避免 JS 浮点精度偏差
// (如 32500000*(0.5/0.65)=24999999.999999996 而非 25000000
calcCloseQtyByPercent(closePercent) {
return SwapCalc.calcCloseQtyByOriginalPercent(closePercent, this.oriClosePercent, this.deal.PositionQty);
},
calcTradingFeePending() {
this.floatPosition.TradingFeePending = this.floatPosition.BeforeCloseFee * parseFloat(this.deal.ClosePercent);
},
@@ -140,12 +153,15 @@ const vue = new Vue({
main.message("平仓数量不能超过持仓数量");
return;
}
this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseQty) / parseFloat(this.deal.PositionQty));
// CloseQty/PositionQty 得占剩余(B),× oriClosePercent 转回占期初(A)
var ori = parseFloat(this.oriClosePercent) || 0;
this.deal.ClosePercent = otcformat.fixed6((parseFloat(this.deal.CloseQty) / parseFloat(this.deal.PositionQty)) * ori);
if (parseFloat(this.deal.CloseQty) == parseFloat(this.deal.PositionQty)) {
this.deal.CloseMethod = 1;
} else {
this.deal.CloseMethod = 2;
}
// 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue)
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
this.calcTradingFeePending();
this.getInterestList();
@@ -157,12 +173,13 @@ const vue = new Vue({
this.deal.ClosePercent = this.oriClosePercent;
return;
}
this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent));
this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent);
// 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue)
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
if (parseFloat(this.deal.CloseNotionalValue) == parseFloat(this.deal.PosiNotionalValue)) {
this.floatPosition.CloseMethod = 1;
if (parseFloat(this.deal.ClosePercent) == parseFloat(this.oriClosePercent)) {
this.deal.CloseMethod = 1;
} else {
this.floatPosition.CloseMethod = 2;
this.deal.CloseMethod = 2;
}
this.calcTradingFeePending();
this.getInterestList();
@@ -174,8 +191,14 @@ const vue = new Vue({
this.deal.CloseNotionalValue = this.deal.PosiNotionalValue;
return;
}
// 占期初口径:平仓比例 = 平仓名义本金 / 期初名义本金(NotionalValue)
this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.NotionalValue));
this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent));
this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent);
if (parseFloat(this.deal.ClosePercent) == parseFloat(this.oriClosePercent)) {
this.deal.CloseMethod = 1;
} else {
this.deal.CloseMethod = 2;
}
this.calcTradingFeePending();
this.getInterestList();
this.calcFloatClosePnl();
@@ -261,7 +284,8 @@ const vue = new Vue({
},
getInterestList() {//根据平仓日期获取利息腿信息
var thisObj = this;
var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2 }
// closePercent 按"占期初(original)"语义(A)传给后端,由 GetUnwindInterestList 转为"占剩余(B)"计算
var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2, notionalValue: thisObj.deal.NotionalValue, posiNotionalValue: thisObj.deal.PosiNotionalValue }
main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) {
thisObj.interestList = resp.obj.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
File diff suppressed because it is too large Load Diff
@@ -432,7 +432,15 @@
function __onInput() {
if (_ctrlV) {
_ctrlV = false;
return setValue(this.value);
// 粘贴进来的是显示值(可能带 %/‱ 后缀),先按 __change 同款口径解析为模型值再回显,
// 避免 setValue 把显示值再乘以 100/10000#EQD-5914 债券价格粘贴 ×100
let f = '';
if (this.value && this.value !== _options.append) {
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
else if (_options.append === '‱') f /= 10000;
}
return setValue(f);
}
if (_chnInput >= 0) {
__onChineseInput.call(this, _chnInput);
+213 -1
View File
@@ -1091,4 +1091,216 @@ main.checkEmail = function (email) {
const regex = /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/;
return regex.test(email);
}
/**
* 可折叠分组表头工具适用于 jqGrid 4.5.4不依赖 free-jqGrid / Guriddo 商业版
*
* 背景jqGrid 4.5.4 setGroupHeaders 只能生成静态合并表头不支持点击折叠
* 本方法在 setGroupHeaders 基础上补齐"点击分组表头折叠/展开"交互
* 并复用本文件 setcolumnChooser2 已验证的 destroyGroupHeader 显隐列 setGroupHeaders 重建模式
*
* 用法
* main.initCollapsibleGroupHeaders($('#listGrid'), [
* { title: '基本信息', columns: ['交易日', '簿记账户'] },
* { title: '名义本金', columns: ['合约名义本金', '多头', '空头'] }
* ]);
*
* @param {jQuery} jgrid jqGrid 容器 $('#listGrid')
* @param {Array} groupConfig 分组配置每项 { title: string, columns: string[] }
* - title 分组表头显示名
* - columns 该组包含的列使用 colModel.name
* 注意columns 里若包含已被 setcolumnChooser 隐藏的列会被自动跳过不影响重建
*/
main.initCollapsibleGroupHeaders = function (jgrid, groupConfig) {
if (!groupConfig || !groupConfig.length) return;
// 存储各分组的折叠状态,key=titlevalue=true 表示已折叠
var collapseState = {};
// 根据 groupConfig + 当前 colModel 可见性,构造 setGroupHeaders 所需的 groupHeaders 参数。
// 关键:折叠的分组仍需保留一个表头单元格作为"展开锚点",否则用户无法再次展开。
function buildGroupHeaders() {
var colModel = jgrid.jqGrid('getGridParam', 'colModel');
var visibleNames = colModel.filter(function (c) { return !c.hidden; }).map(function (c) { return c.name; });
var groupHeaders = [];
for (var g = 0; g < groupConfig.length; g++) {
var group = groupConfig[g];
var visibleColsInGroup = group.columns.filter(function (n) { return visibleNames.indexOf(n) > -1; });
//整组都不可见:若是被折叠的分组,仍需保留锚点(下方 applyGroupHeaders 已保留首列);
//若是被 setcolumnChooser 主动隐藏的分组,则跳过不渲染表头
if (!visibleColsInGroup.length && !collapseState[group.title]) continue;
//锚点列:折叠态下 numberOfColumns=1(首列被保留);展开态为该组可见列数
var count = visibleColsInGroup.length || 1;
var icon = collapseState[group.title] ? '&#9654;' : '&#9660;';
var titleClass = collapseState[group.title] ? 'group-header-title group-collapsed' : 'group-header-title';
groupHeaders.push({
startColumnName: group.columns[0], //锚点列始终是首列(折叠时首列被保留)
numberOfColumns: count,
titleText: '<span class="' + titleClass + '" data-group="' + g + '">' + icon + '&nbsp;' + group.title + '</span>'
});
}
return groupHeaders;
}
// 应用一次分组表头(含折叠态显隐),复用 destroyGroupHeader → setGroupHeaders 模式
function applyGroupHeaders() {
jgrid.jqGrid('destroyGroupHeader', true); // true: 不恢复为单行表头
// 按折叠状态显隐列:折叠的分组保留首列作为锚点(避免整组表头消失无法展开)
for (var g = 0; g < groupConfig.length; g++) {
var group = groupConfig[g];
if (collapseState[group.title]) {
//折叠:隐藏除首列外的所有列,保留首列作为锚点
if (group.columns.length > 1) {
jgrid.setGridParam().hideCol(group.columns.slice(1));
}
jgrid.setGridParam().showCol([group.columns[0]]);
} else {
//展开:恢复该组所有列
jgrid.setGridParam().showCol(group.columns);
}
}
var groupHeaders = buildGroupHeaders();
if (groupHeaders.length) {
jgrid.jqGrid('setGroupHeaders', { useColSpanStyle: true, groupHeaders: groupHeaders });
}
//保存到 jqGrid 参数,便于与 setcolumnChooser 协调
jgrid.jqGrid('setGridParam', { collapsibleGroupConfig: groupConfig, collapseState: collapseState });
bindHeaderClick();
}
// 给分组表头单元格绑定 click(事件委托,重建表头后仍生效)
function bindHeaderClick() {
var hbox = jgrid.closest('.ui-jqgrid').find('.ui-jqgrid-hdiv');
hbox.off('click.collapsibleGroup').on('click.collapsibleGroup', '.group-header-title', function (e) {
e.stopPropagation();
var idx = $(this).attr('data-group');
var group = groupConfig[idx];
if (!group) return;
collapseState[group.title] = !collapseState[group.title];
applyGroupHeaders();
});
}
applyGroupHeaders();
};
/**
* 重建已初始化的可折叠分组表头
* setcolumnChooser / 列重排后调用确保分组边界与最新的列顺序/可见性同步
* @param {jQuery} jgrid
*/
main.refreshCollapsibleGroupHeaders = function (jgrid) {
var cfg = jgrid.jqGrid('getGridParam', 'collapsibleGroupConfig');
if (!cfg) return;
// 复用已有的 collapseState
var state = jgrid.jqGrid('getGridParam', 'collapseState') || {};
// 重新初始化(groupConfig 同引用,collapseState 重建以剔除已失效的分组)
main.initCollapsibleGroupHeaders(jgrid, cfg);
// 回填状态
var freshState = jgrid.jqGrid('getGridParam', 'collapseState') || {};
Object.keys(state).forEach(function (k) { if (k in freshState) freshState[k] = state[k]; });
};
/**
* 导出 jqGrid 可见列为 Excel零依赖基于 HTML table + ms-excel MIME
*
* 设计目标导出内容与前端表格"当前可见列"完全一致需求估值模块V14.2
* 不依赖第三方库不调用后端导出接口避免与 C# 模板导出口径混淆
*
* @param {jQuery} jgrid jqGrid 容器
* @param {string} fileName 导出文件名不含扩展名
* @param {Array} groupConfig 可选分组表头配置每项 { title: string, columns: string[] }
* @param {Array} exportRows 可选后端返回的全部筛选结果未传时导出当前页
* @param {Array} exportColumnNames 可选指定导出的列名及顺序未传时导出当前可见列
*/
main.exportVisibleColumnsToExcel = function (jgrid, fileName, groupConfig, exportRows, exportColumnNames) {
var colModel = jgrid.jqGrid('getGridParam', 'colModel');
var exportCols;
if (Array.isArray(exportColumnNames)) {
exportCols = exportColumnNames.map(function (name) {
for (var i = 0; i < colModel.length; i++) {
if (colModel[i].name === name) return colModel[i];
}
}).filter(function (col) { return col && col.name !== 'cb' && col.name !== 'rn'; });
} else {
// 只导出可见列(hidden !== true),与折叠状态联动:收起的列自动不可见
exportCols = colModel.filter(function (c) { return c.hidden !== true && c.name !== 'cb' && c.name !== 'rn'; });
}
if (!exportCols.length) { main.message && main.message("没有可导出的列"); return; }
var rows = exportRows || jgrid.jqGrid('getRowData');
if (exportRows) {
var gridElement = jgrid[0];
rows = exportRows.map(function (row, rowIndex) {
var formattedRow = {};
exportCols.forEach(function (col) {
var colIndex = colModel.indexOf(col);
var rawValue = $.jgrid.getAccessor(row, col.name);
var formattedValue = gridElement && gridElement.formatter
? gridElement.formatter(rowIndex + 1, rawValue, colIndex, row, 'add')
: rawValue;
formattedRow[col.name] = $('<div>').html(formattedValue == null ? '' : String(formattedValue)).text().replace(/\u00a0/g, '');
});
return formattedRow;
});
}
var headerLabels = exportCols.map(function (c) { return c.label || c.name; });
// 构建 HTML table,用 style 保持 mso-number-format 让金额不被科学计数法破坏
var html = '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">';
html += '<head><meta charset="UTF-8"><!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet><x:Name>Sheet1</x:Name><x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]--></head>';
html += '<body><table border="1">';
//表头:有分组配置时导出两级表头(分类 + 字段),否则保持原单级表头。
if (groupConfig && groupConfig.length) {
html += buildGroupHeaderHtml(exportCols, groupConfig);
}
html += '<tr>' + headerLabels.map(function (l) {
return '<th style="background:#f0f0f0;font-weight:bold;">' + escapeXml(l) + '</th>';
}).join('') + '</tr>';
//数据行
for (var i = 0; i < rows.length; i++) {
html += '<tr>';
for (var c = 0; c < exportCols.length; c++) {
var val = rows[i][exportCols[c].name];
if (val === undefined || val === null) val = '';
html += '<td>' + escapeXml(String(val)) + '</td>';
}
html += '</tr>';
}
html += '</table></body></html>';
var blob = new Blob(['\ufeff' + html], { type: 'application/vnd.ms-excel;charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = (fileName || 'export') + '.xls';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
function escapeXml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function buildGroupHeaderHtml(cols, config) {
var titleByColumn = {};
config.forEach(function (group) {
(group.columns || []).forEach(function (name) {
titleByColumn[name] = group.title || '';
});
});
var cells = [];
for (var i = 0; i < cols.length; i++) {
var title = titleByColumn[cols[i].name] || '';
var colspan = 1;
while (i + colspan < cols.length && (titleByColumn[cols[i + colspan].name] || '') === title) {
colspan++;
}
cells.push('<th colspan="' + colspan + '" style="background:#d9edf7;font-weight:bold;text-align:center;">' + escapeXml(title) + '</th>');
i += colspan - 1;
}
return '<tr>' + cells.join('') + '</tr>';
}
};
+11 -3
View File
@@ -14489,7 +14489,15 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
function __onInput() {
if (_ctrlV) {
_ctrlV = false;
return setValue(this.value);
// 粘贴进来的是显示值(可能带 %/‱ 后缀),先按 __change 同款口径解析为模型值再回显,
// 避免 setValue 把显示值再乘以 100/10000#EQD-5914 债券价格粘贴 ×100
let f = '';
if (this.value && this.value !== _options.append) {
f = parseFloat(this.value.replaceAll(",", "")) || 0;
if (_options.append === '%' || _options.percent == true) f /= 100;
else if (_options.append === '‱') f /= 10000;
}
return setValue(f);
}
if (_chnInput >= 0) {
__onChineseInput.call(this, _chnInput);
@@ -14666,9 +14674,9 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
}
}).datepicker({
minDate: self.mindate,
minDate: self.mindate ? new Date(self.mindate) : null,
beforeShowDay(date) {
return [(!self.maxdate || date <= new Date(self.maxdate)) && (self.noholiday || !ylotc.isHoliday(date))];
return [(self.noholiday || !ylotc.isHoliday(date))];
},
changeMonth: true,
changeYear: true,
@@ -50,7 +50,7 @@ html {
flex-direction: column;
align-items: center;
width: 100%;
padding: 0 50px;
/*padding: 0 50px;*/
position: relative;
font-size: 12px;
}
@@ -430,6 +430,163 @@ html {
.glyphicon {
color: #FFFFFF !important;
}
button:focus {
outline: none;
}
.trigger-condition-box {
background-color: #FFFFFF;
border-radius: 5px;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1);
border: 1px solid #F5F5F5;
margin: 8px 10px 10px 10px;
overflow: hidden;
}
.trigger-condition-title {
background: rgb(255, 148, 62);
color: #FFFFFF;
font-size: 12px;
padding: 5px 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.trigger-condition-title .trigger-actions {
display: flex;
gap: 6px;
}
.trigger-action-btn {
display: inline-block;
border-radius: 15px;
color: rgb(50, 150, 250);
border: none;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1);
background: #FFFFFF;
padding: 3px 10px;
font-size: 12px;
cursor: pointer;
line-height: 18px;
}
.trigger-action-btn:hover {
background: #f5f7fa;
transform: scale(1.05);
transition: all .3s;
}
.trigger-token-list {
padding: 8px 10px;
}
.trigger-token-row {
display: flex;
align-items: center;
justify-content: center;
min-height: 26px;
margin-bottom: 4px;
width: 100%;
}
.trigger-token-row:last-child {
margin-bottom: 0;
}
.trigger-connector {
display: inline-block;
width: 60px;
text-align: center;
color: #409eff;
cursor: pointer;
font-size: 12px;
}
.trigger-paren {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 12px;
padding: 4px 0;
position: relative;
}
.trigger-paren .trigger-remove,
.trigger-condition-row .trigger-remove {
opacity: 0.85;
}
.trigger-paren:hover .trigger-remove,
.trigger-condition-row:hover .trigger-remove {
opacity: 1;
}
.trigger-paren .trigger-remove {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.trigger-condition-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.trigger-condition-row .trigger-remove {
margin-left: auto;
}
.trigger-condition-row select,
.trigger-condition-row input {
height: 20px;
font-size: 12px;
padding: 0 2px;
box-sizing: border-box;
}
.trigger-condition-row select {
width: auto;
min-width: 70px;
}
.trigger-condition-row input {
width: 80px;
padding-left: 4px;
}
.trigger-remove {
color: #ff4d4f !important;
cursor: pointer;
margin-left: 4px;
font-size: 11px;
opacity: 0.7;
transition: opacity 0.2s;
}
.trigger-remove:hover {
color: #ff7875 !important;
opacity: 1;
}
/* 修复审批规则 selectize 标签初始加载时下沉、不居中问题 */
/* 关键bundle.min.css 里设置了 .selectize-input.items.not-full.has-options { height:28px }
固定高度导致标签在 baseline 对齐下下沉这里把高度改回 auto并强制标签垂直居中 */
.selectize-control.multi .selectize-input.items.not-full.has-options,
.selectize-control.multi .selectize-input.items.not-full.has-options.has-items {
height: auto;
min-height: 20px;
}
.selectize-control.multi .selectize-input > div[data-value],
.selectize-control.multi .selectize-input > div.item {
vertical-align: middle !important;
}
/* 需求③:多分支连线。多个 .col-box 时,中间列(非首非尾)的右覆盖线宽度置 0,避免连线断裂 */
.col-box:not(:first-child):not(:last-child) .top-right-cover-line {
width: 0;
}
.col-box:not(:first-child):not(:last-child) .bottom-right-cover-line {
width: 0;
}

Some files were not shown because too many files have changed in this diff Show More