using System.Text.Json; using Dapper; using KArtSell.Modules.ModelOperations.TradeExecution; using Microsoft.Extensions.Logging; using Npgsql; using Xunit; namespace KArtSell.Integration.Tests.TradeExecution; [Collection("Database")] public class TradeExecutionTests : IAsyncLifetime { private readonly NpgsqlDataSource _dataSource; private readonly ILogger _logger; public TradeExecutionTests() { var connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") ?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!"; var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); _dataSource = dataSourceBuilder.Build(); _logger = new LoggerFactory().CreateLogger(); } public async Task InitializeAsync() { await using var connection = await _dataSource.OpenConnectionAsync(); } public async Task DisposeAsync() { await _dataSource.DisposeAsync(); } private async Task SeedSellDecisionAsync() { await using var connection = await _dataSource.OpenConnectionAsync(); var modelId = Guid.NewGuid(); await connection.ExecuteAsync( "INSERT INTO model_operations.models (id, ticker, correlation_id) VALUES (@Id, @Ticker, @CorrelationId)", new { Id = modelId, Ticker = "TEST", CorrelationId = Guid.NewGuid() }); var sellDecisionId = Guid.NewGuid(); await connection.ExecuteAsync( """ INSERT INTO model_operations.sell_decisions (id, model_id, status, created_by, published_at, correlation_id) VALUES (@Id, @ModelId, 'PENDING', 'test@company.com', NOW(), @CorrelationId) """, new { Id = sellDecisionId, ModelId = modelId, CorrelationId = Guid.NewGuid() }); return sellDecisionId; } [Fact] public async Task CreateTrade_WithValidData_ShouldInsertSuccessfully() { var sql = new TradeSql(_dataSource, _logger); var sellDecisionId = await SeedSellDecisionAsync(); var correlationId = Guid.NewGuid(); var trade = Trade.Create(sellDecisionId, 1000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade); var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId); Assert.NotNull(retrieved); Assert.Equal(trade.Id, retrieved.Id); Assert.Equal(TradeStatus.Pending, retrieved.Status); Assert.Equal(1000, retrieved.Quantity); } [Fact] public async Task MarkSubmitted_UpdatesTradeStatusCorrectly() { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade); var response = JsonDocument.Parse("{}").RootElement; trade.MarkSubmitted("KIS-ORDER-123", response); await sql.UpdateTradeStatusAsync(trade, response, null); var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId); Assert.NotNull(retrieved); Assert.Equal(TradeStatus.Submitted, retrieved.Status); Assert.Equal("KIS-ORDER-123", retrieved.KisOrderId); } [Fact] public async Task MarkFilled_CalculatesCorrectTotals() { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade); var response = JsonDocument.Parse("{}").RootElement; trade.MarkFilled(1000, 49.95m, response, DateTime.UtcNow); await sql.UpdateTradeStatusAsync(trade, response, null); var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId); Assert.NotNull(retrieved); Assert.Equal(TradeStatus.FullyFilled, retrieved.Status); Assert.Equal(1000, retrieved.ExecutedQuantity); Assert.Equal(49.95m, retrieved.UnitPrice); Assert.Equal(49950m, retrieved.TotalAmount); } [Fact] public async Task GetTradesByStatus_ReturnsCorrectTrades() { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); var trade1 = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); var trade2 = Trade.Create(await SeedSellDecisionAsync(), 2000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade1); await sql.InsertTradeAsync(trade2); var trades = await sql.GetTradesByStatusAsync(TradeStatus.Pending, correlationId); Assert.NotEmpty(trades); Assert.Contains(trades, t => t.Id == trade1.Id); Assert.Contains(trades, t => t.Id == trade2.Id); } [Fact] public async Task MarkConfirmed_SetsSettlementTimestamp() { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade); trade.TotalAmount = 49950m; trade.MarkConfirmed(DateTime.UtcNow, 50m); await sql.UpdateTradeStatusAsync(trade, null, null); var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId); Assert.NotNull(retrieved); Assert.Equal(TradeStatus.Confirmed, retrieved.Status); Assert.Equal(50m, retrieved.Commission); Assert.Equal(49900m, retrieved.NetProceeds); } [Fact] public async Task TradeStatusHistory_TracksAllTransitions() { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade); var response = JsonDocument.Parse("{}").RootElement; trade.MarkSubmitted("KIS-1", response); await sql.UpdateTradeStatusAsync(trade, response, null); trade.MarkAccepted(response); await sql.UpdateTradeStatusAsync(trade, response, null); var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId); Assert.NotNull(retrieved); Assert.Equal(TradeStatus.Accepted, retrieved.Status); } [Fact] public async Task CountTradesByStatus_ReturnsAccurateCount() { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); var trade1 = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); var trade2 = Trade.Create(await SeedSellDecisionAsync(), 2000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade1); await sql.InsertTradeAsync(trade2); var count = await sql.CountTradesByStatusAsync(TradeStatus.Pending); Assert.True(count >= 2); } [Fact] public void ErrorClassification_TransientErrors_Identified() { var ex = new KisTradeExecutionException( "Timeout", ErrorClassification.Transient ); Assert.Equal(ErrorClassification.Transient, ex.Classification); } [Fact] public void ErrorClassification_PermanentErrors_Identified() { var ex = new KisTradeExecutionException( "Invalid order", ErrorClassification.Permanent ); Assert.Equal(ErrorClassification.Permanent, ex.Classification); } [Fact] public void ErrorClassification_LiquidityErrors_Identified() { var ex = new KisTradeExecutionException( "Insufficient liquidity", ErrorClassification.Liquidity ); Assert.Equal(ErrorClassification.Liquidity, ex.Classification); } [Fact] public void Trade_StateTransitions_ValidSequence() { var trade = Trade.Create(Guid.NewGuid(), 1000, Guid.NewGuid(), DateTime.UtcNow); Assert.Equal(TradeStatus.Pending, trade.Status); var response = JsonDocument.Parse("{}").RootElement; trade.MarkSubmitted("KIS-123", response); Assert.Equal(TradeStatus.Submitted, trade.Status); trade.MarkAccepted(response); Assert.Equal(TradeStatus.Accepted, trade.Status); trade.MarkFilled(1000, 49.95m, response, DateTime.UtcNow); Assert.Equal(TradeStatus.FullyFilled, trade.Status); trade.MarkConfirmed(DateTime.UtcNow, 50m); Assert.Equal(TradeStatus.Confirmed, trade.Status); trade.MarkReconciled(); Assert.Equal(TradeStatus.Reconciled, trade.Status); } [Fact] public void Trade_PartialFill_StatusCorrect() { var trade = Trade.Create(Guid.NewGuid(), 1000, Guid.NewGuid(), DateTime.UtcNow); var response = JsonDocument.Parse("{}").RootElement; trade.MarkFilled(500, 49.95m, response, DateTime.UtcNow); Assert.Equal(TradeStatus.PartiallyFilled, trade.Status); Assert.Equal(500, trade.ExecutedQuantity); } [Fact] public void Trade_RevisionIncrementsOnStateChange() { var trade = Trade.Create(Guid.NewGuid(), 1000, Guid.NewGuid(), DateTime.UtcNow); var initialRevision = trade.Revision; var response = JsonDocument.Parse("{}").RootElement; trade.MarkSubmitted("KIS-123", response); Assert.Equal(initialRevision + 1, trade.Revision); } }