diff --git a/src/KArtSell.Host/Features/Portfolio/VS04_RebalanceEndpoint.cs b/src/KArtSell.Host/Features/Portfolio/VS04_RebalanceEndpoint.cs new file mode 100644 index 00000000..2a22a321 --- /dev/null +++ b/src/KArtSell.Host/Features/Portfolio/VS04_RebalanceEndpoint.cs @@ -0,0 +1,431 @@ +using FastEndpoints; +using Hangfire; +using Npgsql; +using System.Text.Json; +using KArtSell.Modules.ModelOperations.Domain; + +namespace KArtSell.Host.Features.Portfolio; + +/// +/// VS-04 BE: Portfolio Rebalance Endpoint +/// POST /api/portfolio/{id}/rebalance - Trigger portfolio rebalancing +/// GET /api/portfolio/{id}/composition - Get current composition +/// +/// Orchestrates portfolio aggregation, drift analysis, and Hangfire job scheduling +/// Idempotent by (portfolio_id, target_weights_hash, correlation_id) +/// + +public sealed class RebalanceRequest +{ + public List TargetWeights { get; set; } = new(); + public decimal DriftThreshold { get; set; } = 5; +} + +public sealed class TargetWeightDto +{ + public string Symbol { get; set; } = ""; + public decimal TargetPercent { get; set; } +} + +public sealed class RebalanceResponse +{ + public Guid JobId { get; set; } + public string Status { get; set; } = "Queued"; + public int EstimatedTradeCount { get; set; } + public decimal EstimatedCost { get; set; } + public string CorrelationId { get; set; } = ""; + public DateTime QueuedAt { get; set; } +} + +public sealed class PortfolioCompositionResponse +{ + public Guid PortfolioId { get; set; } + public DateOnly SnapshotDate { get; set; } + public List Positions { get; set; } = new(); + public decimal TotalValue { get; set; } + public DateTime LastUpdate { get; set; } +} + +public sealed class PositionDto +{ + public string Symbol { get; set; } = ""; + public decimal Quantity { get; set; } + public decimal MarketPrice { get; set; } + public decimal MarketValue { get; set; } + public decimal WeightPercent { get; set; } +} + +public sealed class TriggerRebalanceEndpoint : Endpoint +{ + private readonly IPortfolioRebalanceService _rebalanceService; + + public TriggerRebalanceEndpoint(IPortfolioRebalanceService rebalanceService) + { + _rebalanceService = rebalanceService; + } + + public override void Configure() + { + Post("/api/portfolio/{portfolioId}/rebalance"); + Roles("PortfolioManager"); + AllowAnonymous(); + } + + public override async Task HandleAsync(RebalanceRequest req, CancellationToken ct) + { + var portfolioIdStr = Route("portfolioId"); + if (!Guid.TryParse(portfolioIdStr, out var portfolioId)) + { + ThrowError("Invalid portfolio ID"); + return; + } + + var correlationId = HttpContext.TraceIdentifier; + + var (jobId, tradeCount, cost) = await _rebalanceService.ScheduleRebalanceAsync( + portfolioId: portfolioId, + targetWeights: req.TargetWeights.Select(w => new TargetWeight(w.Symbol, w.TargetPercent)).ToList(), + driftThreshold: req.DriftThreshold, + correlationId: correlationId, + cancellationToken: ct); + + HttpContext.Response.StatusCode = StatusCodes.Status202Accepted; + HttpContext.Response.ContentType = "application/json"; + await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(new RebalanceResponse + { + JobId = jobId, + Status = "Queued", + EstimatedTradeCount = tradeCount, + EstimatedCost = cost, + CorrelationId = correlationId, + QueuedAt = DateTime.UtcNow, + }), ct); + } +} + +public sealed class GetCompositionEndpoint : EndpointWithoutRequest +{ + private readonly IPortfolioRebalanceService _rebalanceService; + + public GetCompositionEndpoint(IPortfolioRebalanceService rebalanceService) + { + _rebalanceService = rebalanceService; + } + + public override void Configure() + { + Get("/api/portfolio/{portfolioId}/composition"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var portfolioIdStr = Route("portfolioId"); + if (!Guid.TryParse(portfolioIdStr, out var portfolioId)) + { + ThrowError("Invalid portfolio ID"); + return; + } + + var composition = await _rebalanceService.GetCompositionAsync(portfolioId, ct); + + if (composition == null) + { + ThrowError("Portfolio not found"); + return; + } + + HttpContext.Response.StatusCode = StatusCodes.Status200OK; + HttpContext.Response.ContentType = "application/json"; + await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(composition), ct); + } +} + +/// +/// VS-04 Application Handler: Orchestrates rebalance operations +/// + +public interface IPortfolioRebalanceService +{ + Task<(Guid JobId, int TradeCount, decimal Cost)> ScheduleRebalanceAsync( + Guid portfolioId, + List targetWeights, + decimal driftThreshold, + string correlationId, + CancellationToken cancellationToken); + + Task GetCompositionAsync(Guid portfolioId, CancellationToken cancellationToken); +} + +public class PortfolioRebalanceService : IPortfolioRebalanceService +{ + private readonly NpgsqlDataSource _dataSource; + private readonly IBackgroundJobClient _jobClient; + + public PortfolioRebalanceService(NpgsqlDataSource dataSource, IBackgroundJobClient jobClient) + { + _dataSource = dataSource; + _jobClient = jobClient; + } + + public async Task<(Guid JobId, int TradeCount, decimal Cost)> ScheduleRebalanceAsync( + Guid portfolioId, + List targetWeights, + decimal driftThreshold, + string correlationId, + CancellationToken cancellationToken) + { + var jobId = Guid.NewGuid(); + + // Fetch current portfolio composition + var positions = await FetchPositionsAsync(portfolioId, cancellationToken); + var portfolio = PortfolioPolicy.AggregatePortfolio(portfolioId, DateOnly.FromDateTime(DateTime.UtcNow), positions); + var currentWeights = PortfolioPolicy.CalculateCurrentWeights(portfolio); + + // Analyze drift + var analysis = PortfolioPolicy.AnalyzeDrift(portfolio, targetWeights, driftThreshold); + var cost = PortfolioPolicy.EstimateRebalanceCost(analysis); + + // Check idempotency + var existingJob = await CheckIdempotencyAsync(portfolioId, targetWeights, correlationId, cancellationToken); + if (existingJob.HasValue) + return (existingJob.Value, analysis.TradesRequired.Count, cost); + + // Insert job record + await InsertJobRecordAsync(jobId, portfolioId, targetWeights, correlationId, cancellationToken); + + // Schedule Hangfire job + _jobClient.Enqueue(j => + j.ExecuteAsync(jobId, portfolioId, targetWeights, correlationId, CancellationToken.None)); + + return (jobId, analysis.TradesRequired.Count, cost); + } + + public async Task GetCompositionAsync(Guid portfolioId, CancellationToken cancellationToken) + { + const string sql = """ + SELECT symbol, quantity, market_price, market_value, weight_percent + FROM risk_management.portfolio_positions + WHERE portfolio_id = @portfolioId + AND published_at <= @cutoff + AND removed_at IS NULL + AND trading_date = CURRENT_DATE + ORDER BY weight_percent DESC; + """; + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@portfolioId", portfolioId); + cmd.Parameters.AddWithValue("@cutoff", DateTime.UtcNow); + + var positions = new List(); + decimal totalValue = 0; + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var marketValue = reader.GetDecimal(3); + positions.Add(new PositionDto + { + Symbol = reader.GetString(0), + Quantity = reader.GetDecimal(1), + MarketPrice = reader.GetDecimal(2), + MarketValue = marketValue, + WeightPercent = reader.GetDecimal(4), + }); + totalValue += marketValue; + } + + if (positions.Count == 0) + return null; + + return new PortfolioCompositionResponse + { + PortfolioId = portfolioId, + SnapshotDate = DateOnly.FromDateTime(DateTime.UtcNow), + Positions = positions, + TotalValue = totalValue, + LastUpdate = DateTime.UtcNow, + }; + } + + private async Task> FetchPositionsAsync(Guid portfolioId, CancellationToken cancellationToken) + { + const string sql = """ + SELECT symbol, quantity, market_price, cost_basis_per_unit + FROM risk_management.portfolio_positions + WHERE portfolio_id = @portfolioId + AND published_at <= @cutoff + AND removed_at IS NULL + AND trading_date = CURRENT_DATE; + """; + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@portfolioId", portfolioId); + cmd.Parameters.AddWithValue("@cutoff", DateTime.UtcNow); + + var positions = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + positions.Add(new Position( + Symbol: reader.GetString(0), + Quantity: reader.GetDecimal(1), + MarketPrice: reader.GetDecimal(2), + CostBasisPerUnit: reader.IsDBNull(3) ? 0 : reader.GetDecimal(3))); + } + + return positions; + } + + private async Task CheckIdempotencyAsync( + Guid portfolioId, + List targetWeights, + string correlationId, + CancellationToken cancellationToken) + { + var weightsHash = HashTargetWeights(targetWeights); + const string sql = """ + SELECT job_id FROM risk_management.rebalance_jobs + WHERE portfolio_id = @portfolioId + AND target_weights_hash = @hash + AND correlation_id = @correlationId + AND status IN ('Running', 'Completed') + LIMIT 1; + """; + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@portfolioId", portfolioId); + cmd.Parameters.AddWithValue("@hash", weightsHash); + cmd.Parameters.AddWithValue("@correlationId", correlationId); + + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return result is Guid jobId ? jobId : null; + } + + private async Task InsertJobRecordAsync( + Guid jobId, + Guid portfolioId, + List targetWeights, + string correlationId, + CancellationToken cancellationToken) + { + var weightsHash = HashTargetWeights(targetWeights); + const string sql = """ + INSERT INTO risk_management.rebalance_jobs + (job_id, portfolio_id, target_weights_hash, correlation_id, status, requested_at, requested_by) + VALUES (@jobId, @portfolioId, @hash, @correlationId, 'Queued', CURRENT_TIMESTAMP, 'API'); + """; + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@jobId", jobId); + cmd.Parameters.AddWithValue("@portfolioId", portfolioId); + cmd.Parameters.AddWithValue("@hash", weightsHash); + cmd.Parameters.AddWithValue("@correlationId", correlationId); + + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + private static string HashTargetWeights(List weights) + { + var sorted = weights.OrderBy(w => w.Symbol).Select(w => $"{w.Symbol}:{w.TargetPercent}"); + var hash = string.Join("|", sorted); + return Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(hash)); + } +} + +/// +/// VS-04 ASYNC: Rebalance Job Handler (Hangfire) +/// + +public interface IPortfolioRebalanceJob +{ + Task ExecuteAsync(Guid jobId, Guid portfolioId, List targetWeights, string correlationId, CancellationToken ct); +} + +public class PortfolioRebalanceJobHandler : IPortfolioRebalanceJob +{ + private readonly NpgsqlDataSource _dataSource; + + public PortfolioRebalanceJobHandler(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task ExecuteAsync(Guid jobId, Guid portfolioId, List targetWeights, string correlationId, CancellationToken ct) + { + var startTime = DateTime.UtcNow; + + try + { + await UpdateJobStatusAsync(jobId, "Running", ct); + + // Simulate rebalance execution (real implementation: call trading API) + await Task.Delay(1000, ct); + + // Mark complete + var duration = (int)(DateTime.UtcNow - startTime).TotalSeconds; + await UpdateJobStatusAsync(jobId, "Completed", ct, duration); + + // Publish event + await PublishRebalancedEventAsync(jobId, portfolioId, correlationId, ct); + } + catch (Exception ex) + { + await UpdateJobStatusAsync(jobId, "Failed", ct, null, ex.Message); + throw; + } + } + + private async Task UpdateJobStatusAsync(Guid jobId, string status, CancellationToken ct = default, int? durationSeconds = null, string? errorMessage = null) + { + const string sql = """ + UPDATE risk_management.rebalance_jobs + SET status = @status, completed_at = CASE WHEN @status IN ('Completed', 'Failed') THEN CURRENT_TIMESTAMP ELSE NULL END, + duration_seconds = @duration, last_error_message = @error, updated_at = CURRENT_TIMESTAMP + WHERE job_id = @jobId; + """; + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@jobId", jobId); + cmd.Parameters.AddWithValue("@status", status); + cmd.Parameters.AddWithValue("@duration", durationSeconds ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@error", errorMessage ?? (object)DBNull.Value); + + await cmd.ExecuteNonQueryAsync(ct); + } + + private async Task PublishRebalancedEventAsync(Guid jobId, Guid portfolioId, string correlationId, CancellationToken ct) + { + const string sql = """ + INSERT INTO shared.outbox (aggregate_id, event_type, payload, published_at, correlation_id) + VALUES (@aggregateId, 'PortfolioRebalanced', @payload, CURRENT_TIMESTAMP, @correlationId); + """; + + var payload = JsonSerializer.Serialize(new + { + eventType = "PortfolioRebalanced", + portfolioId, + jobId, + rebalancedAt = DateTime.UtcNow, + }); + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@aggregateId", portfolioId); + cmd.Parameters.AddWithValue("@payload", payload); + cmd.Parameters.AddWithValue("@correlationId", correlationId); + + await cmd.ExecuteNonQueryAsync(ct); + } +} diff --git a/src/KArtSell.Host/Features/Portfolio/VS05_RiskMetricsEndpoint.cs b/src/KArtSell.Host/Features/Portfolio/VS05_RiskMetricsEndpoint.cs new file mode 100644 index 00000000..2b5c55ce --- /dev/null +++ b/src/KArtSell.Host/Features/Portfolio/VS05_RiskMetricsEndpoint.cs @@ -0,0 +1,293 @@ +using FastEndpoints; +using Hangfire; +using Npgsql; +using System.Text.Json; +using KArtSell.Modules.ModelOperations.Domain; + +namespace KArtSell.Host.Features.Portfolio; + +/// +/// VS-05 BE: Risk Metrics Endpoint +/// GET /api/portfolio/{id}/risk - Fetch current risk metrics +/// +/// Returns: VAR, Sharpe, Sortino, volatility, concentration +/// Scheduled: Daily at 9:30 KST (after market open) +/// Cached: < 1 hour +/// + +public sealed class RiskMetricsResponse +{ + public Guid PortfolioId { get; set; } + public DateOnly CalculationDate { get; set; } + public RiskMetricsDto Metrics { get; set; } = new(); + public int QualityScore { get; set; } + public DateTime LastUpdate { get; set; } +} + +public sealed class RiskMetricsDto +{ + public decimal VAR95Amount { get; set; } + public decimal VAR95Percent { get; set; } + public decimal SharpeRatio { get; set; } + public decimal SortinoRatio { get; set; } + public decimal Volatility { get; set; } + public decimal TopFivePercent { get; set; } + public decimal HirschmanIndex { get; set; } + public decimal MaxSinglePosition { get; set; } +} + +public sealed class GetRiskMetricsEndpoint : EndpointWithoutRequest +{ + private readonly IRiskMetricsService _metricsService; + + public GetRiskMetricsEndpoint(IRiskMetricsService metricsService) + { + _metricsService = metricsService; + } + + public override void Configure() + { + Get("/api/portfolio/{portfolioId}/risk"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var portfolioIdStr = Route("portfolioId"); + if (!Guid.TryParse(portfolioIdStr, out var portfolioId)) + { + ThrowError("Invalid portfolio ID"); + return; + } + + var metrics = await _metricsService.GetMetricsAsync(portfolioId, ct); + + if (metrics == null) + { + ThrowError("Metrics not found or not yet calculated"); + return; + } + + HttpContext.Response.StatusCode = StatusCodes.Status200OK; + HttpContext.Response.ContentType = "application/json"; + await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(metrics), ct); + } +} + +/// +/// VS-05 Application Handler: Orchestrates risk calculation +/// + +public interface IRiskMetricsService +{ + Task GetMetricsAsync(Guid portfolioId, CancellationToken cancellationToken); +} + +public class RiskMetricsService : IRiskMetricsService +{ + private readonly NpgsqlDataSource _dataSource; + + public RiskMetricsService(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task GetMetricsAsync(Guid portfolioId, CancellationToken cancellationToken) + { + const string sql = """ + SELECT + portfolio_id, calculation_date, + var_95_amount, var_95_percent, + sharpe_ratio, sortino_ratio, volatility_annualized, + top_five_percent, hirschman_index, max_single_position, + quality_score, published_at + FROM risk_management.risk_metrics + WHERE portfolio_id = @portfolioId + AND published_at <= @cutoff + AND removed_at IS NULL + ORDER BY calculation_date DESC + LIMIT 1; + """; + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@portfolioId", portfolioId); + cmd.Parameters.AddWithValue("@cutoff", DateTime.UtcNow); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + return null; + + return new RiskMetricsResponse + { + PortfolioId = reader.GetGuid(0), + CalculationDate = DateOnly.FromDateTime(reader.GetDateTime(1)), + Metrics = new RiskMetricsDto + { + VAR95Amount = reader.GetDecimal(2), + VAR95Percent = reader.GetDecimal(3), + SharpeRatio = reader.GetDecimal(4), + SortinoRatio = reader.GetDecimal(5), + Volatility = reader.GetDecimal(6), + TopFivePercent = reader.GetDecimal(7), + HirschmanIndex = reader.GetDecimal(8), + MaxSinglePosition = reader.GetDecimal(9), + }, + QualityScore = reader.GetInt32(10), + LastUpdate = reader.GetDateTime(11), + }; + } +} + +/// +/// VS-05 ASYNC: Daily Risk Calculation Job (Hangfire) +/// Scheduled: 9:30 KST (after market open, uses prices from 9:00) +/// + +public interface IRiskCalculationJob +{ + Task ExecuteAsync(Guid portfolioId, DateOnly calculationDate, CancellationToken ct); +} + +public class RiskCalculationJobHandler : IRiskCalculationJob +{ + private readonly NpgsqlDataSource _dataSource; + + public RiskCalculationJobHandler(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task ExecuteAsync(Guid portfolioId, DateOnly calculationDate, CancellationToken ct) + { + var startTime = DateTime.UtcNow; + + try + { + await UpdateJobStatusAsync(portfolioId, calculationDate, "Running", ct); + + // Fetch historical prices + var priceHistory = await FetchPriceHistoryAsync(portfolioId, calculationDate, ct); + + if (priceHistory.Count == 0) + { + await UpdateJobStatusAsync(portfolioId, calculationDate, "Completed", ct); + return; + } + + // Calculate returns + var returns = RiskMetricsPolicy.CalculateReturns(priceHistory, 252); + + // Calculate metrics + var var95 = RiskMetricsPolicy.CalculateVAR95(returns, 100000m); // Mock: 100k portfolio + var sharpe = RiskMetricsPolicy.CalculateSharpe(returns); + var sortino = RiskMetricsPolicy.CalculateSortino(returns); + var volatility = RiskMetricsPolicy.CalculateVolatility(returns); + + // Mock weights (real: fetch from VS-04) + var weights = new List(); + var (topFive, hirschman, maxPosition) = RiskMetricsPolicy.CalculateConcentration(weights); + + var (qualityScore, _) = RiskMetricsPolicy.AssessDataQuality(returns); + + // Insert metrics + await InsertMetricsAsync(portfolioId, calculationDate, var95, sharpe, sortino, volatility, topFive, hirschman, maxPosition, qualityScore, ct); + + var duration = (int)(DateTime.UtcNow - startTime).TotalSeconds; + await UpdateJobStatusAsync(portfolioId, calculationDate, "Completed", ct, duration); + + // Publish event + await PublishMetricsEventAsync(portfolioId, calculationDate, ct); + } + catch (Exception ex) + { + await UpdateJobStatusAsync(portfolioId, calculationDate, "Failed", ct, null, ex.Message); + throw; + } + } + + private async Task> FetchPriceHistoryAsync(Guid portfolioId, DateOnly upToDate, CancellationToken ct) + { + // Mock: return empty list (real implementation: fetch from market_data schema) + await Task.CompletedTask; + return new(); + } + + private async Task UpdateJobStatusAsync(Guid portfolioId, DateOnly calculationDate, string status, CancellationToken ct, int? durationSeconds = null, string? errorMessage = null) + { + const string sql = """ + UPDATE risk_management.risk_calculation_jobs + SET status = @status, completed_at = CASE WHEN @status IN ('Completed', 'Failed') THEN CURRENT_TIMESTAMP ELSE NULL END, + duration_seconds = @duration, error_message = @error, updated_at = CURRENT_TIMESTAMP + WHERE portfolio_id = @portfolioId AND calculation_date = @date; + """; + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@portfolioId", portfolioId); + cmd.Parameters.AddWithValue("@date", calculationDate); + cmd.Parameters.AddWithValue("@status", status); + cmd.Parameters.AddWithValue("@duration", durationSeconds ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@error", errorMessage ?? (object)DBNull.Value); + + await cmd.ExecuteNonQueryAsync(ct); + } + + private async Task InsertMetricsAsync( + Guid portfolioId, DateOnly calculationDate, + decimal var95, decimal sharpe, decimal sortino, decimal volatility, + decimal topFive, decimal hirschman, decimal maxPosition, + int qualityScore, CancellationToken ct) + { + const string sql = """ + INSERT INTO risk_management.risk_metrics + (portfolio_id, calculation_date, var_95_amount, var_95_percent, sharpe_ratio, sortino_ratio, + volatility_annualized, top_five_percent, hirschman_index, max_single_position, quality_score, published_at) + VALUES (@portfolioId, @date, @var95, @var95Pct, @sharpe, @sortino, @vol, @top5, @hirsch, @maxPos, @quality, CURRENT_TIMESTAMP); + """; + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@portfolioId", portfolioId); + cmd.Parameters.AddWithValue("@date", calculationDate.ToDateTime(TimeOnly.MinValue)); + cmd.Parameters.AddWithValue("@var95", var95); + cmd.Parameters.AddWithValue("@var95Pct", (var95 / 100000) * 100); // Mock percent + cmd.Parameters.AddWithValue("@sharpe", sharpe); + cmd.Parameters.AddWithValue("@sortino", sortino); + cmd.Parameters.AddWithValue("@vol", volatility); + cmd.Parameters.AddWithValue("@top5", topFive); + cmd.Parameters.AddWithValue("@hirsch", hirschman); + cmd.Parameters.AddWithValue("@maxPos", maxPosition); + cmd.Parameters.AddWithValue("@quality", qualityScore); + + await cmd.ExecuteNonQueryAsync(ct); + } + + private async Task PublishMetricsEventAsync(Guid portfolioId, DateOnly calculationDate, CancellationToken ct) + { + const string sql = """ + INSERT INTO shared.outbox (aggregate_id, event_type, payload, published_at, correlation_id) + VALUES (@aggregateId, 'PortfolioMetricsCalculated', @payload, CURRENT_TIMESTAMP, @correlationId); + """; + + var payload = JsonSerializer.Serialize(new + { + eventType = "PortfolioMetricsCalculated", + portfolioId, + calculationDate, + calculatedAt = DateTime.UtcNow, + }); + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@aggregateId", portfolioId); + cmd.Parameters.AddWithValue("@payload", payload); + cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); + + await cmd.ExecuteNonQueryAsync(ct); + } +} diff --git a/src/KArtSell.Host/Features/Portfolio/VS06_VS07_RiskEndpoint.cs b/src/KArtSell.Host/Features/Portfolio/VS06_VS07_RiskEndpoint.cs new file mode 100644 index 00000000..65d0902c --- /dev/null +++ b/src/KArtSell.Host/Features/Portfolio/VS06_VS07_RiskEndpoint.cs @@ -0,0 +1,365 @@ +using FastEndpoints; +using Hangfire; +using Npgsql; +using System.Text.Json; +using KArtSell.Modules.ModelOperations.Domain; + +namespace KArtSell.Host.Features.Portfolio; + +#region ========== VS-06: STRESS TESTING ========== + +public sealed class TriggerStressTestRequest +{ + public string ScenarioId { get; set; } = "bear"; +} + +public sealed class StressTestResponse +{ + public Guid StressTestId { get; set; } + public string Status { get; set; } = "Queued"; + public string ScenarioId { get; set; } = ""; + public string CorrelationId { get; set; } = ""; + public DateTime QueuedAt { get; set; } +} + +public sealed class GetStressResultResponse +{ + public Guid StressTestId { get; set; } + public string ScenarioId { get; set; } = ""; + public decimal PortfolioLoss { get; set; } + public decimal PortfolioLossPercent { get; set; } + public decimal BaselineVAR { get; set; } + public decimal StressedVAR { get; set; } + public DateTime CompletedAt { get; set; } +} + +public sealed class TriggerStressTestEndpoint : Endpoint +{ + private readonly IStressTestService _stressService; + + public TriggerStressTestEndpoint(IStressTestService stressService) + { + _stressService = stressService; + } + + public override void Configure() + { + Post("/api/portfolio/{portfolioId}/stress"); + Roles("RiskAnalyst"); + AllowAnonymous(); + } + + public override async Task HandleAsync(TriggerStressTestRequest req, CancellationToken ct) + { + var portfolioIdStr = Route("portfolioId"); + if (!Guid.TryParse(portfolioIdStr, out var portfolioId)) + { + ThrowError("Invalid portfolio ID"); + return; + } + + var correlationId = HttpContext.TraceIdentifier; + var stressTestId = await _stressService.ScheduleStressTestAsync(portfolioId, req.ScenarioId, correlationId, ct); + + HttpContext.Response.StatusCode = StatusCodes.Status202Accepted; + HttpContext.Response.ContentType = "application/json"; + await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(new StressTestResponse + { + StressTestId = stressTestId, + Status = "Queued", + ScenarioId = req.ScenarioId, + CorrelationId = correlationId, + QueuedAt = DateTime.UtcNow, + }), ct); + } +} + +public interface IStressTestService +{ + Task ScheduleStressTestAsync(Guid portfolioId, string scenarioId, string correlationId, CancellationToken ct); +} + +public class StressTestService : IStressTestService +{ + private readonly NpgsqlDataSource _dataSource; + private readonly IBackgroundJobClient _jobClient; + + public StressTestService(NpgsqlDataSource dataSource, IBackgroundJobClient jobClient) + { + _dataSource = dataSource; + _jobClient = jobClient; + } + + public async Task ScheduleStressTestAsync(Guid portfolioId, string scenarioId, string correlationId, CancellationToken ct) + { + var testId = Guid.NewGuid(); + + const string sql = """ + INSERT INTO risk_management.stress_test_results + (stress_test_id, portfolio_id, scenario_id, run_date, correlation_id, status) + VALUES (@testId, @portfolioId, @scenarioId, CURRENT_DATE, @correlationId, 'Queued'); + """; + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@testId", testId); + cmd.Parameters.AddWithValue("@portfolioId", portfolioId); + cmd.Parameters.AddWithValue("@scenarioId", scenarioId); + cmd.Parameters.AddWithValue("@correlationId", correlationId); + + await cmd.ExecuteNonQueryAsync(ct); + + _jobClient.Enqueue(j => + j.ExecuteAsync(testId, portfolioId, scenarioId, correlationId, CancellationToken.None)); + + return testId; + } +} + +public interface IStressTestJob +{ + Task ExecuteAsync(Guid stressTestId, Guid portfolioId, string scenarioId, string correlationId, CancellationToken ct); +} + +public class StressTestJobHandler : IStressTestJob +{ + private readonly NpgsqlDataSource _dataSource; + + public StressTestJobHandler(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task ExecuteAsync(Guid stressTestId, Guid portfolioId, string scenarioId, string correlationId, CancellationToken ct) + { + try + { + await Task.Delay(2000, ct); // Mock processing + + // Update results (mock: -20% loss for bear scenario) + var loss = scenarioId == "bear" ? -20.0m : 0m; + + const string sql = """ + UPDATE risk_management.stress_test_results + SET status = 'Completed', portfolio_loss_percent = @loss, completed_at = CURRENT_TIMESTAMP + WHERE stress_test_id = @testId; + """; + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@testId", stressTestId); + cmd.Parameters.AddWithValue("@loss", loss); + + await cmd.ExecuteNonQueryAsync(ct); + } + catch (Exception) + { + // Publish event on completion + const string updateSql = """ + UPDATE risk_management.stress_test_results + SET status = 'Failed' WHERE stress_test_id = @testId; + """; + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = updateSql; + cmd.Parameters.AddWithValue("@testId", stressTestId); + await cmd.ExecuteNonQueryAsync(ct); + throw; + } + } +} + +#endregion + +#region ========== VS-07: RISK ALERTS ========== + +public sealed class GetAlertsResponse +{ + public List ActiveAlerts { get; set; } = new(); + public List ResolvedAlerts { get; set; } = new(); +} + +public sealed class AlertDto +{ + public Guid AlertId { get; set; } + public string ThresholdType { get; set; } = ""; + public string Severity { get; set; } = ""; + public decimal CurrentValue { get; set; } + public decimal Threshold { get; set; } + public DateTime TriggeredAt { get; set; } + public string Message { get; set; } = ""; +} + +public sealed class GetAlertsEndpoint : EndpointWithoutRequest +{ + private readonly IAlertService _alertService; + + public GetAlertsEndpoint(IAlertService alertService) + { + _alertService = alertService; + } + + public override void Configure() + { + Get("/api/portfolio/{portfolioId}/alerts"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var portfolioIdStr = Route("portfolioId"); + if (!Guid.TryParse(portfolioIdStr, out var portfolioId)) + { + ThrowError("Invalid portfolio ID"); + return; + } + + var alerts = await _alertService.GetAlertsAsync(portfolioId, ct); + + HttpContext.Response.StatusCode = StatusCodes.Status200OK; + HttpContext.Response.ContentType = "application/json"; + await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(alerts), ct); + } +} + +public interface IAlertService +{ + Task GetAlertsAsync(Guid portfolioId, CancellationToken ct); +} + +public class AlertService : IAlertService +{ + private readonly NpgsqlDataSource _dataSource; + + public AlertService(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task GetAlertsAsync(Guid portfolioId, CancellationToken ct) + { + const string activeSql = """ + SELECT alert_id, threshold_type, status, current_value, threshold_value, triggered_at, message + FROM risk_management.risk_alerts + WHERE portfolio_id = @portfolioId AND removed_at IS NULL AND status IN ('Initial', 'Warning', 'Critical') + ORDER BY critical_at DESC NULLS LAST; + """; + + const string resolvedSql = """ + SELECT alert_id, threshold_type, status, current_value, threshold_value, triggered_at, message + FROM risk_management.risk_alerts + WHERE portfolio_id = @portfolioId AND removed_at IS NOT NULL AND status = 'Resolved' + ORDER BY resolved_at DESC LIMIT 10; + """; + + var response = new GetAlertsResponse(); + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + + // Fetch active alerts + await using var cmd1 = connection.CreateCommand(); + cmd1.CommandText = activeSql; + cmd1.Parameters.AddWithValue("@portfolioId", portfolioId); + + await using var reader1 = await cmd1.ExecuteReaderAsync(ct); + while (await reader1.ReadAsync(ct)) + { + response.ActiveAlerts.Add(new AlertDto + { + AlertId = reader1.GetGuid(0), + ThresholdType = reader1.GetString(1), + Severity = reader1.GetString(2), + CurrentValue = reader1.GetDecimal(3), + Threshold = reader1.GetDecimal(4), + TriggeredAt = reader1.GetDateTime(5), + Message = reader1.GetString(6), + }); + } + + // Fetch resolved alerts + await using var cmd2 = connection.CreateCommand(); + cmd2.CommandText = resolvedSql; + cmd2.Parameters.AddWithValue("@portfolioId", portfolioId); + + await using var reader2 = await cmd2.ExecuteReaderAsync(ct); + while (await reader2.ReadAsync(ct)) + { + response.ResolvedAlerts.Add(new AlertDto + { + AlertId = reader2.GetGuid(0), + ThresholdType = reader2.GetString(1), + Severity = reader2.GetString(2), + CurrentValue = reader2.GetDecimal(3), + Threshold = reader2.GetDecimal(4), + TriggeredAt = reader2.GetDateTime(5), + Message = reader2.GetString(6), + }); + } + + return response; + } +} + +public interface IAlertEscalationJob +{ + Task ExecuteAsync(CancellationToken ct); +} + +public class AlertEscalationJobHandler : IAlertEscalationJob +{ + private readonly NpgsqlDataSource _dataSource; + + public AlertEscalationJobHandler(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task ExecuteAsync(CancellationToken ct) + { + // Scheduled every 1 minute (after risk metrics update) + // Evaluate all active alerts for escalation/resolution + + const string sql = """ + SELECT alert_id, threshold_type, status, triggered_at + FROM risk_management.risk_alerts + WHERE removed_at IS NULL AND status IN ('Initial', 'Warning'); + """; + + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + var alertId = reader.GetGuid(0); + var status = reader.GetString(2); + var triggeredAt = reader.GetDateTime(3); + + var minutesElapsed = (int)(DateTime.UtcNow - triggeredAt).TotalMinutes; + + // Simple escalation: warn at 2 min, critical at 5 min + if (status == "Initial" && minutesElapsed >= 2) + { + const string updateSql = "UPDATE risk_management.risk_alerts SET status = 'Warning', warned_at = CURRENT_TIMESTAMP WHERE alert_id = @alertId;"; + await using var updateCmd = connection.CreateCommand(); + updateCmd.CommandText = updateSql; + updateCmd.Parameters.AddWithValue("@alertId", alertId); + await updateCmd.ExecuteNonQueryAsync(ct); + } + else if (status == "Warning" && minutesElapsed >= 5) + { + const string updateSql = "UPDATE risk_management.risk_alerts SET status = 'Critical', critical_at = CURRENT_TIMESTAMP WHERE alert_id = @alertId;"; + await using var updateCmd = connection.CreateCommand(); + updateCmd.CommandText = updateSql; + updateCmd.Parameters.AddWithValue("@alertId", alertId); + await updateCmd.ExecuteNonQueryAsync(ct); + } + } + } +} + +#endregion