feat: 新增TRS合约日终Kafka推送

- 在日终任务的逐日循环中,于当日收盘、风险监控和日终文件生成完成后,
  按 valueDate 推送一条 TRS 合约全量快照;区间收盘逐日推送,空日推送空快照。
- 基于 eod_swap、trade、eod_swap_position、swap_position 组装合约字段,
  Kafka Key 使用 yyyy-MM-dd 格式的 valueDate。
- 新增带 Key 的 Kafka 发送重载;保留原有无 Key 发送及其吞异常行为,避免影响既有调用。
- 推送失败最多尝试 3 次;最终失败记录 Error 日志,并按 eod_swap.id 写入 push_status,
  空快照失败使用 record_id=0。
- 增加 ContractTopic 配置,默认及各部署环境使用 onederiv.trs.contract.v1。
- 增加定向单测,覆盖空快照、区间逐日推送、重试成功、三次失败和字段映射。
This commit is contained in:
tengyufan
2026-08-25 16:07:21 +08:00
parent 147bcf4bf7
commit 336cc3d88b
12 changed files with 546 additions and 7 deletions
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace YLErp.DBModels
{
public enum PushStateEnum
{
= 0,
= 1,
= 2
}
/// <summary>
/// 外发推送失败状态。只记录定位信息,不保存报文。
/// </summary>
[Table("push_status")]
public class PushStatus
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long id { get; set; }
[Column("value_date")]
public DateTime ValueDate { get; set; }
[Column("push_type")]
public int PushType { get; set; }
[Column("record_id")]
public long RecordId { get; set; }
[Column("state")]
public PushStateEnum State { get; set; }
[Column("retry_count")]
public int RetryCount { get; set; }
[Column("last_error")]
public string LastError { get; set; }
[Column("push_time")]
public DateTime? PushTime { get; set; }
[Column("create_time")]
public DateTime CreateTime { get; set; }
[Column("update_time")]
public DateTime? UpdateTime { get; set; }
}
}
@@ -0,0 +1,210 @@
using YLErp.Abstract;
using YLErp.Helpers;
using YLErp.Modules.EodModule;
namespace YLErp.Modules.EodModuleTests
{
[TestClass]
public class TrsContractKafkaPushServiceTest
{
[TestMethod]
public void Push_空日快照_发送一条空消息并使用业务日期作为Key()
{
var valueDate = new DateTime(2026, 8, 24);
var producer = new RecordingKafkaProducer();
var service = CreateService(producer, valueDate);
service.Push(valueDate);
Assert.AreEqual(1, producer.Messages.Count);
Assert.AreEqual("onederiv.trs.contract.v1", producer.Messages[0].Topic);
Assert.AreEqual("2026-08-24", producer.Messages[0].Key);
var payload = JsonHelper.Deserialize<TrsContractSnapshot>(producer.Messages[0].Message);
Assert.AreEqual("2026-08-24", payload.ValueDate);
Assert.AreEqual(0, payload.ContractCount);
Assert.AreEqual(0, payload.Contracts.Count);
}
[TestMethod]
public void Push_区间内每天分别调用_每个日期各发送一条快照()
{
var valueDates = new[]
{
new DateTime(2026, 8, 20),
new DateTime(2026, 8, 21),
new DateTime(2026, 8, 24)
};
var producer = new RecordingKafkaProducer();
var service = new TestableTrsContractKafkaPushService(producer, valueDates.ToDictionary(x => x, CreateEmptySnapshot));
foreach (var valueDate in valueDates)
{
service.Push(valueDate);
}
CollectionAssert.AreEqual(
new[] { "2026-08-20", "2026-08-21", "2026-08-24" },
producer.Messages.Select(x => x.Key).ToArray());
CollectionAssert.AreEqual(
new[] { "2026-08-20", "2026-08-21", "2026-08-24" },
producer.Messages.Select(x => JsonHelper.Deserialize<TrsContractSnapshot>(x.Message).ValueDate).ToArray());
}
[TestMethod]
public void Push_首次失败后成功_停止重试且不记录最终失败()
{
var valueDate = new DateTime(2026, 8, 24);
var producer = new RecordingKafkaProducer { FailuresBeforeSuccess = 1 };
var service = CreateService(producer, valueDate);
service.Push(valueDate);
Assert.AreEqual(2, producer.AttemptCount);
Assert.AreEqual(1, producer.Messages.Count);
Assert.AreEqual(0, service.FailureRecords.Count);
}
[TestMethod]
public void Push_连续失败三次_记录最终失败和三次尝试()
{
var valueDate = new DateTime(2026, 8, 24);
var producer = new RecordingKafkaProducer { FailuresBeforeSuccess = int.MaxValue };
var service = CreateService(producer, valueDate);
service.Push(valueDate);
Assert.AreEqual(3, producer.AttemptCount);
Assert.AreEqual(0, producer.Messages.Count);
Assert.AreEqual(1, service.FailureRecords.Count);
Assert.AreEqual(valueDate, service.FailureRecords[0].ValueDate);
Assert.AreEqual(3, service.FailureRecords[0].RetryCount);
Assert.IsInstanceOfType(service.FailureRecords[0].Exception, typeof(InvalidOperationException));
}
[TestMethod]
public void BuildContract_字段使用日终快照和约定来源()
{
var valueDate = new DateTime(2026, 8, 24);
var eodSwap = new eod_swap
{
id = 10,
ValueDate = valueDate,
SwapTradeId = 7,
SwapTradeNo = "TRS-001",
BookId = 3,
ClientId = 8,
NotionalValue = 1000000m,
dv01 = 12.34m,
InitMarginGain = 100m,
InitMarginLoss = 0m
};
var trade = new trade
{
id = 7,
UnderlyingCode = "600000.SH",
UnderlyingAssetName = "浦发银行",
UnderlyingInstrumentType = "Stock",
StartDate = new DateTime(2026, 8, 1),
ExerciseDate = new DateTime(2027, 8, 1)
};
var positions = new List<eod_swap_position>
{
new() { SwapTradeId = 7, PositionId = 101, UnderlyingCode = "600000.SH", PositionType = 1 },
new() { SwapTradeId = 7, PositionId = 102, InterestMode = (int)InterestModeEnum., InterestRateDefault = 0.0123m, InterestDirection = 2 }
};
var swapPositions = new Dictionary<long, swap_position>
{
[101] = new() { id = 101, category_tag = "互换利率" },
[102] = new() { id = 102, category_tag = "互换利率" }
};
var item = TrsContractKafkaPushService.BuildContract(
eodSwap,
new Dictionary<int, trade> { [7] = trade },
positions,
swapPositions);
Assert.AreEqual("2026-08-24", item.TradeDate);
Assert.AreEqual(3, item.BookId);
Assert.AreEqual("TRS-001", item.SwapTradeNo);
Assert.AreEqual(8, item.ClientId);
Assert.AreEqual("600000.SH", item.UnderlyingCode);
Assert.AreEqual("浦发银行", item.UnderlyingName);
Assert.AreEqual("Stock", item.UnderlyingInstrumentType);
Assert.AreEqual(1000000m, item.NotionalValue);
Assert.AreEqual("2026-08-01", item.StartDate);
Assert.AreEqual("2027-08-01", item.MaturityDate);
Assert.AreEqual(12.34m, item.Dv01);
Assert.AreEqual(0.0123m, item.FixedRate);
Assert.AreEqual(2, item.InterestDirection);
Assert.AreEqual(1, item.FloatingDirection);
Assert.AreEqual(100m, item.InitMarginGain);
Assert.AreEqual(0m, item.InitMarginLoss);
}
private static TestableTrsContractKafkaPushService CreateService(RecordingKafkaProducer producer, DateTime valueDate)
{
return new TestableTrsContractKafkaPushService(
producer,
new Dictionary<DateTime, TrsContractSnapshot> { [valueDate] = CreateEmptySnapshot(valueDate) });
}
private static TrsContractSnapshot CreateEmptySnapshot(DateTime valueDate)
{
return new TrsContractSnapshot
{
SchemaVersion = "v1",
ValueDate = valueDate.ToString("yyyy-MM-dd"),
PushTime = "2026-08-24 12:00:00",
ContractCount = 0,
Contracts = new List<TrsContractSnapshotItem>()
};
}
private sealed class TestableTrsContractKafkaPushService : TrsContractKafkaPushService
{
private readonly IReadOnlyDictionary<DateTime, TrsContractSnapshot> _snapshots;
public List<(DateTime ValueDate, int RetryCount, Exception Exception)> FailureRecords { get; } = new();
public TestableTrsContractKafkaPushService(IKafkaProduce producer, IReadOnlyDictionary<DateTime, TrsContractSnapshot> snapshots)
: base(new YLContext(), producer, "onederiv.trs.contract.v1")
{
_snapshots = snapshots;
}
protected override TrsContractSnapshot BuildSnapshot(DateTime valueDate)
{
return _snapshots[valueDate];
}
protected override void RecordFailures(DateTime valueDate, int retryCount, Exception exception)
{
FailureRecords.Add((valueDate, retryCount, exception));
}
}
private sealed class RecordingKafkaProducer : IKafkaProduce
{
public int FailuresBeforeSuccess { get; set; }
public int AttemptCount { get; private set; }
public List<(string Topic, string Key, string Message)> Messages { get; } = new();
public void Produce(string topic, string message)
{
throw new NotSupportedException();
}
public void Produce(string topic, string key, string message)
{
AttemptCount++;
if (AttemptCount <= FailuresBeforeSuccess)
{
throw new InvalidOperationException("Kafka unavailable");
}
Messages.Add((topic, key, message));
}
}
}
}
+2
View File
@@ -9,5 +9,7 @@ namespace YLErp.Abstract
public interface IKafkaProduce
{
void Produce(string topic, string message);
void Produce(string topic, string key, string message);
}
}
+1
View File
@@ -354,6 +354,7 @@ namespace YLErp.BLL
public DbSet<eod_swap_position> eod_swap_position { get; set; }
public DbSet<swap_event> swap_event { get; set; }
public DbSet<eod_swap> eod_swap { get; set; }
public DbSet<PushStatus> push_status { get; set; }
public DbSet<TradeObervation> trade_obervation { get; set; }
public DbSet<SystemLog> SystemLogs { get; set; }
+16 -6
View File
@@ -63,19 +63,29 @@ namespace YLErp.Helpers
public void Produce(string topic,string message)
{
var kafkaMessage = new Message<string, string>
{
Key=null,
Value = message
};
try
{
_producer.ProduceAsync(topic, kafkaMessage).GetAwaiter().GetResult();
ProduceCore(topic, null, message);
}
catch (Exception ex)
{
_logger.Error($"Topic:{topic} send failed",ex);
}
}
public void Produce(string topic, string key, string message)
{
ProduceCore(topic, key, message);
}
private void ProduceCore(string topic, string key, string message)
{
var kafkaMessage = new Message<string, string>
{
Key = key,
Value = message
};
_producer.ProduceAsync(topic, kafkaMessage).GetAwaiter().GetResult();
}
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ namespace YLErp.Model
/// <summary>
/// TRS合约数据推送topic(对外,如onebp等)
/// </summary>
public string ContractTopic { get; set; } = "onederi.trs.onebp.contract.v1";
public string ContractTopic { get; set; } = "onederiv.trs.contract.v1";
}
}
@@ -10,6 +10,12 @@ using YLErp.Modules.SystemModule;
using YLErp.Modules.TradeDalModule;
using YLErp.Modules.TradeModule.DealModule;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using YieldChain.Commons;
using YLErp.Abstract;
using YLErp.Model;
namespace YLErp.Modules.EodModule.SettlementModule
{
/// <summary>
@@ -445,12 +451,35 @@ where {nameof(t.TaskStartTime)}>'{startDateStr}' and {nameof(t.TaskState)}={(int
}
ClientBalanceUtility.saveClientRiskMonitor(eodTask.ValueDate);
new EodFileService(this.OptUser).GenerateFileAfterEod(eodTask.ValueDate);
PushTrsContractSnapshot(eodTask.ValueDate);
//执行下一日
eodTask.ValueDate = eodTask.ValueDate.AddDays(1);
eodTask.TaskEndTime = DateTime.Now;
}
}
private void PushTrsContractSnapshot(DateTime valueDate)
{
try
{
var provider = YLServiceLocator.ServiceProvider;
var kafkaProduce = provider?.GetService<IKafkaProduce>();
var kafkaOptions = provider?.GetService<IOptions<KafkaConfig>>();
if (kafkaProduce == null || kafkaOptions?.Value == null)
{
LogFactory.GetLogger("TRS合约日终Kafka推送").Error("Kafka service or configuration is unavailable");
return;
}
using var pushDbContext = DbContextFactory.GetYLDbContext();
new TrsContractKafkaPushService(pushDbContext, kafkaProduce, kafkaOptions.Value.ContractTopic).Push(valueDate);
}
catch (Exception ex)
{
LogFactory.GetLogger("TRS合约日终Kafka推送").Error($"TRS contract snapshot task failed, valueDate:{valueDate:yyyy-MM-dd}", ex);
}
}
/// <summary>
/// 查找第一个可用的任务
/// </summary>
@@ -0,0 +1,233 @@
using YLErp.Abstract;
using YLErp.BLL;
using YLErp.DBModels;
using YLErp.Helpers;
namespace YLErp.Modules.EodModule
{
/// <summary>
/// 收盘后按交易日推送 TRS 合约全量快照。
/// </summary>
public class TrsContractKafkaPushService
{
private const string DateFormat = "yyyy-MM-dd";
private const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss";
private const string InterestCategory = "互换利率";
private const int TrsContractPushType = 1;
private const int MaxAttempts = 3;
private readonly YLContext _dbContext;
private readonly IKafkaProduce _kafkaProduce;
private readonly string _topic;
private readonly IYcLogger _logger;
public TrsContractKafkaPushService(YLContext dbContext, IKafkaProduce kafkaProduce, string topic)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_kafkaProduce = kafkaProduce ?? throw new ArgumentNullException(nameof(kafkaProduce));
_topic = string.IsNullOrWhiteSpace(topic) ? throw new ArgumentException("Kafka topic is empty", nameof(topic)) : topic;
_logger = LogFactory.GetLogger(nameof(TrsContractKafkaPushService));
}
public void Push(DateTime valueDate)
{
valueDate = valueDate.Date;
TrsContractSnapshot snapshot;
string payload;
try
{
snapshot = BuildSnapshot(valueDate);
payload = JsonHelper.Serialize(snapshot, true, true);
}
catch (Exception ex)
{
_logger.Error($"TRS contract snapshot build failed, valueDate:{valueDate:yyyy-MM-dd}", ex);
RecordFailures(valueDate, 0, ex);
return;
}
var key = valueDate.ToString(DateFormat);
Exception lastException = null;
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
_kafkaProduce.Produce(_topic, key, payload);
_logger.Info($"TRS contract snapshot sent, valueDate:{key}, topic:{_topic}, count:{snapshot.ContractCount}, attempt:{attempt}");
return;
}
catch (Exception ex)
{
lastException = ex;
_logger.Error($"TRS contract snapshot send failed, valueDate:{key}, topic:{_topic}, attempt:{attempt}", ex);
}
}
_logger.Error($"TRS contract snapshot send exhausted retries, valueDate:{key}, topic:{_topic}, attempts:{MaxAttempts}");
RecordFailures(valueDate, MaxAttempts, lastException);
}
protected virtual void RecordFailures(DateTime valueDate, int retryCount, Exception exception)
{
try
{
var recordIds = _dbContext.eod_swap
.Where(x => x.ValueDate == valueDate)
.Select(x => x.id)
.ToList();
if (recordIds.Count == 0)
{
recordIds.Add(0);
}
var now = DateTime.Now;
var statuses = _dbContext.push_status
.Where(x => x.ValueDate == valueDate
&& x.PushType == TrsContractPushType
&& recordIds.Contains(x.RecordId))
.ToList();
var error = exception?.ToString();
if (error?.Length > 2000)
{
error = error.Substring(0, 2000);
}
foreach (var recordId in recordIds)
{
var status = statuses.FirstOrDefault(x => x.RecordId == recordId);
if (status == null)
{
status = new PushStatus
{
ValueDate = valueDate,
PushType = TrsContractPushType,
RecordId = recordId,
CreateTime = now
};
_dbContext.push_status.Add(status);
}
status.State = PushStateEnum.;
status.RetryCount = retryCount;
status.LastError = error;
status.PushTime = now;
status.UpdateTime = now;
}
_dbContext.SaveChanges();
}
catch (Exception ex)
{
_logger.Error($"TRS contract push failure status save failed, valueDate:{valueDate:yyyy-MM-dd}", ex);
}
}
protected virtual TrsContractSnapshot BuildSnapshot(DateTime valueDate)
{
var eodSwaps = _dbContext.eod_swap
.Where(x => x.ValueDate == valueDate)
.AsNoTracking()
.ToList();
var tradeIds = eodSwaps.Select(x => x.SwapTradeId).Distinct().ToList();
var trades = _dbContext.trade
.Where(x => tradeIds.Contains(x.id))
.AsNoTracking()
.ToDictionary(x => x.id);
var eodPositions = _dbContext.eod_swap_position
.Where(x => x.ValueDate == valueDate && tradeIds.Contains(x.SwapTradeId) && !x.Invalid)
.AsNoTracking()
.ToList();
var positionIds = eodPositions.Select(x => x.PositionId).Distinct().ToList();
var swapPositions = _dbContext.swap_position
.Where(x => positionIds.Contains(x.id) && !x.Invalid && x.category_tag == InterestCategory)
.AsNoTracking()
.ToDictionary(x => x.id);
var contracts = eodSwaps.Select(eodSwap => BuildContract(eodSwap, trades, eodPositions, swapPositions)).ToList();
return new TrsContractSnapshot
{
SchemaVersion = "v1",
ValueDate = valueDate.ToString(DateFormat),
PushTime = DateTime.Now.ToString(DateTimeFormat),
ContractCount = contracts.Count,
Contracts = contracts
};
}
internal static TrsContractSnapshotItem BuildContract(
eod_swap eodSwap,
IReadOnlyDictionary<int, trade> trades,
IReadOnlyCollection<eod_swap_position> eodPositions,
IReadOnlyDictionary<long, swap_position> swapPositions)
{
if (!trades.TryGetValue(eodSwap.SwapTradeId, out var trade))
{
throw new InvalidOperationException($"TRS trade not found, swapTradeId:{eodSwap.SwapTradeId}");
}
var positions = eodPositions.Where(x => x.SwapTradeId == eodSwap.SwapTradeId).ToList();
var floating = positions.Where(x => !string.IsNullOrWhiteSpace(x.UnderlyingCode) && swapPositions.ContainsKey(x.PositionId)).ToList();
var interest = positions.Where(x => string.IsNullOrWhiteSpace(x.UnderlyingCode)
&& ConsTrade.InterestModels.Contains(x.InterestMode)
&& swapPositions.TryGetValue(x.PositionId, out var swapPosition)
&& swapPosition.category_tag == InterestCategory).ToList();
if (floating.Count != 1 || interest.Count != 1)
{
throw new InvalidOperationException($"TRS legs invalid, swapTradeId:{eodSwap.SwapTradeId}, floating:{floating.Count}, interest:{interest.Count}");
}
var interestLeg = interest[0];
var floatingLeg = floating[0];
return new TrsContractSnapshotItem
{
TradeDate = eodSwap.ValueDate.ToString(DateFormat),
BookId = eodSwap.BookId,
SwapTradeNo = eodSwap.SwapTradeNo,
ClientId = eodSwap.ClientId,
UnderlyingCode = trade.UnderlyingCode,
UnderlyingName = trade.UnderlyingAssetName,
UnderlyingInstrumentType = trade.UnderlyingInstrumentType,
NotionalValue = eodSwap.NotionalValue,
Dv01 = eodSwap.dv01 ?? 0,
StartDate = trade.StartDate?.ToString(DateFormat),
MaturityDate = trade.ExerciseDate?.ToString(DateFormat),
FixedRate = interestLeg.InterestRateDefault,
InterestDirection = interestLeg.InterestDirection,
FloatingDirection = floatingLeg.PositionType,
InitMarginGain = eodSwap.InitMarginGain,
InitMarginLoss = eodSwap.InitMarginLoss
};
}
}
public class TrsContractSnapshot
{
public string SchemaVersion { get; set; }
public string ValueDate { get; set; }
public string PushTime { get; set; }
public int ContractCount { get; set; }
public List<TrsContractSnapshotItem> Contracts { get; set; }
}
public class TrsContractSnapshotItem
{
public string TradeDate { get; set; }
public int BookId { get; set; }
public string SwapTradeNo { get; set; }
public int ClientId { get; set; }
public string UnderlyingCode { get; set; }
public string UnderlyingName { get; set; }
public string UnderlyingInstrumentType { get; set; }
public decimal NotionalValue { get; set; }
public decimal Dv01 { get; set; }
public string StartDate { get; set; }
public string MaturityDate { get; set; }
public decimal FixedRate { get; set; }
public int InterestDirection { get; set; }
public int FloatingDirection { get; set; }
public decimal InitMarginGain { get; set; }
public decimal InitMarginLoss { get; set; }
}
}
+1
View File
@@ -34,6 +34,7 @@
"CompressionType": 0, // 消息压缩方式,可以是 None(0)、Gzip(1)、Snappy(2)、Lz4(3)、Zstd(4) 中的一种,
"MessageTimeoutMs": 3000, // 控制生产者等待消息确认的时间,单位是毫秒
"ClientRateTopic": "ylClientRateTopic", //客户互换费率生产topic
"ContractTopic": "onederiv.trs.contract.v1", //TRS合约日终推送topic
"HedgingAccountTopic": "ylHedgingAccountTopic", //对冲账户生产topic
"ReqAccountCapitalTopic": "ReqAccountCapital", //账户资金请求topic
"OnRspAccountCapitalTopic": "OnRspAccountCapital", //账户资金请求返回topic
+1
View File
@@ -34,6 +34,7 @@
"CompressionType": 0, // 消息压缩方式,可以是 None(0)、Gzip(1)、Snappy(2)、Lz4(3)、Zstd(4) 中的一种,
"MessageTimeoutMs": 3000, // 控制生产者等待消息确认的时间,单位是毫秒
"ClientRateTopic": "ylClientRateTopic", //客户互换费率生产topic
"ContractTopic": "onederiv.trs.contract.v1", //TRS合约日终推送topic
"HedgingAccountTopic": "ylHedgingAccountTopic", //对冲账户生产topic
"ReqAccountCapitalTopic": "ReqAccountCapital", //账户资金请求topic
"OnRspAccountCapitalTopic": "OnRspAccountCapital", //账户资金请求返回topic
+1
View File
@@ -36,6 +36,7 @@
"CompressionType": 0, // 消息压缩方式,可以是 None(0)、Gzip(1)、Snappy(2)、Lz4(3)、Zstd(4) 中的一种,
"MessageTimeoutMs": 3000, // 控制生产者等待消息确认的时间,单位是毫秒
"ClientRateTopic": "ylClientRateTopic", //客户互换费率生产topic
"ContractTopic": "onederiv.trs.contract.v1", //TRS合约日终推送topic
"HedgingAccountTopic": "ylHedgingAccountTopic", //对冲账户生产topic
"AccountCapitalTopicGroupId": "YiLian_OnRspAccountCapitalConsumer", //账户资金消费组
"ReqAccountCapitalTopic": "ReqAccountCapital", //账户资金请求topic
+1
View File
@@ -36,6 +36,7 @@
"CompressionType": 0, // 消息压缩方式,可以是 None(0)、Gzip(1)、Snappy(2)、Lz4(3)、Zstd(4) 中的一种,
"MessageTimeoutMs": 3000, // 控制生产者等待消息确认的时间,单位是毫秒
"ClientRateTopic": "ylClientRateTopic", //客户互换费率生产topic
"ContractTopic": "onederiv.trs.contract.v1", //TRS合约日终推送topic
"HedgingAccountTopic": "ylHedgingAccountTopic", //对冲账户生产topic
"AccountCapitalTopicGroupId": "YiLian_OnRspAccountCapitalConsumer", //账户资金消费组
"ReqAccountCapitalTopic": "ReqAccountCapital", //账户资金请求topic