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