using System.Text.Json; using Microsoft.Extensions.Logging; using Polly; using Polly.CircuitBreaker; namespace KArtSell.Modules.ModelOperations.TradeExecution; public enum ErrorClassification { Transient, Permanent, Liquidity } public class KisTradeExecutionException : Exception { public ErrorClassification Classification { get; set; } public JsonElement? KisResponse { get; set; } public KisTradeExecutionException(string message, ErrorClassification classification, JsonElement? kisResponse = null) : base(message) { Classification = classification; KisResponse = kisResponse; } } public interface IKisTradeExecutionService { Task<(string OrderId, JsonElement Response)> ExecuteTradeAsync(Guid tradeId, int quantity, decimal limitPrice, Guid correlationId, CancellationToken ct = default); Task<(string Status, int ExecutedQty, decimal UnitPrice, JsonElement Response)> GetOrderStatusAsync(string kisOrderId, Guid correlationId, CancellationToken ct = default); Task<(bool Success, JsonElement Response)> CancelOrderAsync(string kisOrderId, string reason, Guid correlationId, CancellationToken ct = default); Task<(bool Success, JsonElement Response)> ConfirmSettlementAsync(string kisOrderId, Guid correlationId, CancellationToken ct = default); } public class KisTradeExecutionService : IKisTradeExecutionService { private readonly HttpClient _httpClient; private readonly IAsyncPolicy _resilience; private readonly ILogger _logger; private const string KisApiBase = "https://openapi.kis.com/v1"; private const int MaxRetries = 3; public KisTradeExecutionService(HttpClient httpClient, ILogger logger) { _httpClient = httpClient; _logger = logger; _resilience = BuildResiliencePolicy(); } public async Task<(string OrderId, JsonElement Response)> ExecuteTradeAsync( Guid tradeId, int quantity, decimal limitPrice, Guid correlationId, CancellationToken ct = default) { var requestBody = new { symbol = "US0100", orderType = "limit", quantity = quantity, price = limitPrice, timeInForce = "day" }; var content = new StringContent( JsonSerializer.Serialize(requestBody), System.Text.Encoding.UTF8, "application/json" ); var request = new HttpRequestMessage(HttpMethod.Post, $"{KisApiBase}/orders") { Content = content }; request.Headers.Add("X-Trade-ID", tradeId.ToString()); request.Headers.Add("X-Correlation-ID", correlationId.ToString()); try { var response = await _resilience.ExecuteAsync( async (ct) => await _httpClient.SendAsync(request, ct), ct ); var responseContent = await response.Content.ReadAsStringAsync(ct); var responseJson = JsonDocument.Parse(responseContent).RootElement; if (!response.IsSuccessStatusCode) { var classification = ClassifyError(response.StatusCode, responseJson); _logger.LogError( "KIS trade submission failed: {TradeId} {StatusCode} {@Classification}", tradeId, response.StatusCode, classification ); throw new KisTradeExecutionException( $"KIS API error: {response.StatusCode}", classification, responseJson ); } var orderId = responseJson.GetProperty("orderId").GetString(); _logger.LogInformation("Trade submitted to KIS: {TradeId} -> {OrderId}", tradeId, orderId); return (orderId!, responseJson); } catch (HttpRequestException ex) when (ex.InnerException is TimeoutException) { _logger.LogWarning("KIS timeout for trade {TradeId}", tradeId); throw new KisTradeExecutionException( "KIS request timed out", ErrorClassification.Transient, null ); } } public async Task<(string Status, int ExecutedQty, decimal UnitPrice, JsonElement Response)> GetOrderStatusAsync( string kisOrderId, Guid correlationId, CancellationToken ct = default) { var request = new HttpRequestMessage(HttpMethod.Get, $"{KisApiBase}/orders/{kisOrderId}"); request.Headers.Add("X-Correlation-ID", correlationId.ToString()); var response = await _resilience.ExecuteAsync( async (ct) => await _httpClient.SendAsync(request, ct), ct ); var responseContent = await response.Content.ReadAsStringAsync(ct); var responseJson = JsonDocument.Parse(responseContent).RootElement; if (!response.IsSuccessStatusCode) { var classification = ClassifyError(response.StatusCode, responseJson); throw new KisTradeExecutionException( $"Failed to get order status: {response.StatusCode}", classification, responseJson ); } var status = responseJson.GetProperty("status").GetString(); var executedQty = responseJson.GetProperty("executedQuantity").GetInt32(); var unitPrice = responseJson.GetProperty("price").GetDecimal(); _logger.LogInformation( "Order status: {OrderId} {Status} (filled: {ExecutedQty})", kisOrderId, status, executedQty ); return (status!, executedQty, unitPrice, responseJson); } public async Task<(bool Success, JsonElement Response)> CancelOrderAsync( string kisOrderId, string reason, Guid correlationId, CancellationToken ct = default) { var requestBody = new { reason = reason }; var content = new StringContent( JsonSerializer.Serialize(requestBody), System.Text.Encoding.UTF8, "application/json" ); var request = new HttpRequestMessage(HttpMethod.Delete, $"{KisApiBase}/orders/{kisOrderId}") { Content = content }; request.Headers.Add("X-Correlation-ID", correlationId.ToString()); var response = await _resilience.ExecuteAsync( async (ct) => await _httpClient.SendAsync(request, ct), ct ); var responseContent = await response.Content.ReadAsStringAsync(ct); var responseJson = JsonDocument.Parse(responseContent).RootElement; if (!response.IsSuccessStatusCode) { throw new KisTradeExecutionException( $"Failed to cancel order: {response.StatusCode}", ErrorClassification.Permanent, responseJson ); } _logger.LogInformation("Order cancelled: {OrderId}", kisOrderId); return (true, responseJson); } public async Task<(bool Success, JsonElement Response)> ConfirmSettlementAsync( string kisOrderId, Guid correlationId, CancellationToken ct = default) { var requestBody = new { confirm = true }; var content = new StringContent( JsonSerializer.Serialize(requestBody), System.Text.Encoding.UTF8, "application/json" ); var request = new HttpRequestMessage(HttpMethod.Patch, $"{KisApiBase}/orders/{kisOrderId}/settlement") { Content = content }; request.Headers.Add("X-Correlation-ID", correlationId.ToString()); var response = await _resilience.ExecuteAsync( async (ct) => await _httpClient.SendAsync(request, ct), ct ); var responseContent = await response.Content.ReadAsStringAsync(ct); var responseJson = JsonDocument.Parse(responseContent).RootElement; if (!response.IsSuccessStatusCode) { throw new KisTradeExecutionException( $"Failed to confirm settlement: {response.StatusCode}", ErrorClassification.Permanent, responseJson ); } _logger.LogInformation("Settlement confirmed: {OrderId}", kisOrderId); return (true, responseJson); } private static ErrorClassification ClassifyError(System.Net.HttpStatusCode statusCode, JsonElement response) { return statusCode switch { System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.ServiceUnavailable or System.Net.HttpStatusCode.TooManyRequests => ErrorClassification.Transient, System.Net.HttpStatusCode.BadRequest or System.Net.HttpStatusCode.Forbidden or System.Net.HttpStatusCode.Unauthorized => ErrorClassification.Permanent, _ => GetErrorTypeFromResponse(response) }; } private static ErrorClassification GetErrorTypeFromResponse(JsonElement response) { if (response.TryGetProperty("errorCode", out var errorCode)) { var code = errorCode.GetString(); return code switch { "INSUFFICIENT_LIQUIDITY" or "PARTIAL_FILL" => ErrorClassification.Liquidity, "RATE_LIMITED" or "TIMEOUT" => ErrorClassification.Transient, _ => ErrorClassification.Permanent }; } return ErrorClassification.Permanent; } private IAsyncPolicy BuildResiliencePolicy() { var retryPolicy = Policy .Handle() .Or() .OrResult(r => (int)r.StatusCode >= 500 || r.StatusCode == System.Net.HttpStatusCode.RequestTimeout || r.StatusCode == System.Net.HttpStatusCode.TooManyRequests ) .WaitAndRetryAsync( retryCount: MaxRetries, sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), onRetry: (outcome, timespan, retryCount, context) => { _logger.LogWarning( "KIS request retry {RetryCount}/{MaxRetries} after {DelayMs}ms", retryCount, MaxRetries, timespan.TotalMilliseconds ); } ); var circuitBreakerPolicy = Policy .Handle() .OrResult(r => (int)r.StatusCode >= 500) .CircuitBreakerAsync( handledEventsAllowedBeforeBreaking: 5, durationOfBreak: TimeSpan.FromSeconds(30), onBreak: (outcome, timespan) => { _logger.LogError("KIS circuit breaker opened for {DurationSeconds}s", timespan.TotalSeconds); }, onReset: () => { _logger.LogInformation("KIS circuit breaker reset"); } ); return Policy.WrapAsync(retryPolicy, circuitBreakerPolicy); } }