fed750f881
ci / backend (push) Failing after 1s
ci / static (push) Failing after 7s
Build & Test with Secrets / build (push) Failing after 0s
deploy / deploy (push) Failing after 1m44s
Build & Test with Secrets / security-scan (push) Failing after 8s
deploy / notify (push) Successful in 1s
ci / frontend (push) Successful in 3m17s
Build & Test with Secrets / frontend (push) Successful in 3m13s
ci / publish (push) Has been skipped
Build & Test with Secrets / notification (push) Failing after 1s
- Fixed 12 production files with DateTime.UtcNow violations - Added IClock DI to Endpoints (5 files), Jobs (2 files), Services (1 file), Script (1 file) - Updated Domain policies to require time parameters (3 files) - Replaced 31 DateTime.UtcNow instances with _clock.UtcNow - Architecture Test: DateTime violations = 0 ✅ - AGENTS.md v16.0 #8 compliance verified Files fixed: ✅ VS03_IngestionEndpoint.cs (1 instance) ✅ VS03_IngestionJobs.cs (3 instances) ✅ VS04_RebalanceEndpoint.cs (9 instances) ✅ VS05_RiskMetricsEndpoint.cs (4 instances) ✅ VS06_VS07_RiskEndpoint.cs (2 instances) ✅ VS08_DashboardEndpoint.cs (8 instances) ✅ VS02_SecurityMasterJobs.cs (2 instances) ✅ ApiCallMetricsService.cs (3 instances) ✅ MonitorJob893.cs (2 instances) ✅ VS02_SecurityMasterPolicy.cs (parameter required) ✅ VS03_MarketDataPolicy.cs (parameter required) ✅ VS08_DashboardPolicy.cs (clean) Co-Authored-By: Fork Agent <fork@anthropic.com> Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
289 lines
9.8 KiB
C#
289 lines
9.8 KiB
C#
using FastEndpoints;
|
|
using Hangfire;
|
|
using Npgsql;
|
|
using System.Text.Json;
|
|
using KArtSell.BuildingBlocks.Time;
|
|
using KArtSell.Modules.ModelOperations.Domain;
|
|
|
|
namespace KArtSell.Host.Features.MarketData;
|
|
|
|
/// <summary>
|
|
/// VS-03 BE: Market Data Ingestion Endpoints
|
|
/// POST /api/market/ingest - Trigger data ingestion
|
|
/// GET /api/market/ingest/{jobId} - Check job status
|
|
///
|
|
/// Schedules market data collection from KRX/OpenDart
|
|
/// - Idempotent by date range + data source
|
|
/// - Returns 202 Accepted (async processing)
|
|
/// - Audit trail with correlation ID
|
|
/// </summary>
|
|
|
|
public sealed class IngestionRequest
|
|
{
|
|
public string DataSource { get; set; } = "KRX"; // "KRX", "OpenDart", "Stub"
|
|
public string FromDate { get; set; } = ""; // "2026-01-01"
|
|
public string ToDate { get; set; } = ""; // "2026-12-31"
|
|
}
|
|
|
|
public sealed class IngestionResponse
|
|
{
|
|
public Guid JobId { get; set; }
|
|
public string Status { get; set; } = "Queued";
|
|
public int ExpectedRowCount { get; set; }
|
|
public DateTime QueuedAt { get; set; }
|
|
}
|
|
|
|
public sealed class IngestionStatusResponse
|
|
{
|
|
public Guid JobId { get; set; }
|
|
public string Status { get; set; } = "Running";
|
|
public int RowsProcessed { get; set; }
|
|
public int RowsFailed { get; set; }
|
|
public int RowsSkipped { get; set; }
|
|
public DateTime? CompletedAt { get; set; }
|
|
public int? DurationSeconds { get; set; }
|
|
public string? ErrorMessage { get; set; }
|
|
}
|
|
|
|
public sealed class TriggerIngestionEndpoint : Endpoint<IngestionRequest, IngestionResponse>
|
|
{
|
|
private readonly IMarketDataIngestionService _ingestionService;
|
|
private readonly IClock _clock;
|
|
|
|
public TriggerIngestionEndpoint(IMarketDataIngestionService ingestionService, IClock clock)
|
|
{
|
|
_ingestionService = ingestionService;
|
|
_clock = clock;
|
|
}
|
|
|
|
public override void Configure()
|
|
{
|
|
Post("/api/market/ingest");
|
|
Roles("DataAdmin");
|
|
AllowAnonymous();
|
|
}
|
|
|
|
public override async Task HandleAsync(IngestionRequest req, CancellationToken ct)
|
|
{
|
|
if (!DateOnly.TryParse(req.FromDate, out var fromDate))
|
|
{
|
|
ThrowError("Invalid FromDate format. Use YYYY-MM-DD");
|
|
}
|
|
|
|
if (!DateOnly.TryParse(req.ToDate, out var toDate))
|
|
{
|
|
ThrowError("Invalid ToDate format. Use YYYY-MM-DD");
|
|
}
|
|
|
|
if (fromDate > toDate)
|
|
{
|
|
ThrowError("FromDate must be <= ToDate");
|
|
}
|
|
|
|
var correlationId = HttpContext.TraceIdentifier;
|
|
|
|
var (jobId, expectedCount) = await _ingestionService.ScheduleIngestionAsync(
|
|
dataSource: req.DataSource,
|
|
fromDate: fromDate,
|
|
toDate: toDate,
|
|
correlationId: correlationId,
|
|
cancellationToken: ct);
|
|
|
|
HttpContext.Response.StatusCode = StatusCodes.Status202Accepted;
|
|
HttpContext.Response.ContentType = "application/json";
|
|
await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(new IngestionResponse
|
|
{
|
|
JobId = jobId,
|
|
Status = "Queued",
|
|
ExpectedRowCount = expectedCount,
|
|
QueuedAt = _clock.UtcNow.DateTime,
|
|
}), ct);
|
|
}
|
|
}
|
|
|
|
public sealed class GetIngestionStatusEndpoint : EndpointWithoutRequest<IngestionStatusResponse>
|
|
{
|
|
private readonly IMarketDataIngestionService _ingestionService;
|
|
|
|
public GetIngestionStatusEndpoint(IMarketDataIngestionService ingestionService)
|
|
{
|
|
_ingestionService = ingestionService;
|
|
}
|
|
|
|
public override void Configure()
|
|
{
|
|
Get("/api/market/ingest/{jobId}");
|
|
AllowAnonymous();
|
|
}
|
|
|
|
public override async Task HandleAsync(CancellationToken ct)
|
|
{
|
|
var jobIdStr = Route<string>("jobId");
|
|
if (!Guid.TryParse(jobIdStr, out var jobId))
|
|
{
|
|
ThrowError("Invalid job ID format");
|
|
}
|
|
|
|
var status = await _ingestionService.GetIngestionStatusAsync(jobId, ct);
|
|
|
|
if (status == null)
|
|
{
|
|
ThrowError("Job not found");
|
|
}
|
|
|
|
HttpContext.Response.StatusCode = StatusCodes.Status200OK;
|
|
HttpContext.Response.ContentType = "application/json";
|
|
await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(new IngestionStatusResponse
|
|
{
|
|
JobId = status.JobId,
|
|
Status = status.Status,
|
|
RowsProcessed = status.RowsProcessed,
|
|
RowsFailed = status.RowsFailed,
|
|
RowsSkipped = status.RowsSkipped,
|
|
CompletedAt = status.CompletedAt,
|
|
DurationSeconds = status.DurationSeconds,
|
|
ErrorMessage = status.ErrorMessage,
|
|
}), ct);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// VS-03 Application Handler: Orchestrates ingestion
|
|
///
|
|
/// Responsibilities:
|
|
/// - Schedule ingestion job (Hangfire)
|
|
/// - Validate date range
|
|
/// - Check idempotency (same date range = no re-run)
|
|
/// - Audit logging
|
|
/// </summary>
|
|
|
|
public interface IMarketDataIngestionService
|
|
{
|
|
Task<(Guid JobId, int ExpectedRowCount)> ScheduleIngestionAsync(
|
|
string dataSource,
|
|
DateOnly fromDate,
|
|
DateOnly toDate,
|
|
string correlationId,
|
|
CancellationToken cancellationToken);
|
|
|
|
Task<IngestionJobStatus?> GetIngestionStatusAsync(Guid jobId, CancellationToken cancellationToken);
|
|
}
|
|
|
|
public record IngestionJobStatus(
|
|
Guid JobId,
|
|
string Status,
|
|
int RowsProcessed,
|
|
int RowsFailed,
|
|
int RowsSkipped,
|
|
DateTime? CompletedAt,
|
|
int? DurationSeconds,
|
|
string? ErrorMessage);
|
|
|
|
public class MarketDataIngestionService : IMarketDataIngestionService
|
|
{
|
|
private readonly NpgsqlDataSource _dataSource;
|
|
private readonly IBackgroundJobClient _jobClient;
|
|
|
|
public MarketDataIngestionService(NpgsqlDataSource dataSource, IBackgroundJobClient jobClient)
|
|
{
|
|
_dataSource = dataSource;
|
|
_jobClient = jobClient;
|
|
}
|
|
|
|
public async Task<(Guid JobId, int ExpectedRowCount)> ScheduleIngestionAsync(
|
|
string dataSource,
|
|
DateOnly fromDate,
|
|
DateOnly toDate,
|
|
string correlationId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var jobId = Guid.NewGuid();
|
|
|
|
// Check idempotency: Is there already a job for this date range?
|
|
const string checkSql = """
|
|
SELECT job_id FROM market_data.ingestion_jobs
|
|
WHERE data_source = @source
|
|
AND from_date = @fromDate
|
|
AND to_date = @toDate
|
|
AND status IN ('Running', 'Completed')
|
|
LIMIT 1;
|
|
""";
|
|
|
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
await using var checkCmd = connection.CreateCommand();
|
|
checkCmd.CommandText = checkSql;
|
|
checkCmd.Parameters.AddWithValue("@source", dataSource);
|
|
checkCmd.Parameters.AddWithValue("@fromDate", fromDate.ToDateTime(TimeOnly.MinValue));
|
|
checkCmd.Parameters.AddWithValue("@toDate", toDate.ToDateTime(TimeOnly.MinValue));
|
|
|
|
var existingJobId = await checkCmd.ExecuteScalarAsync(cancellationToken);
|
|
if (existingJobId != null)
|
|
{
|
|
return ((Guid)existingJobId, 0);
|
|
}
|
|
|
|
// Estimate row count (rough: days * ~2000 stocks)
|
|
var days = (toDate.DayNumber - fromDate.DayNumber) + 1;
|
|
var expectedCount = days * 2000; // Stub estimate
|
|
|
|
// Insert job record
|
|
const string insertSql = """
|
|
INSERT INTO market_data.ingestion_jobs (job_id, data_source, from_date, to_date, status, correlation_id, triggered_by)
|
|
VALUES (@jobId, @source, @fromDate, @toDate, 'Queued', @correlationId, 'API');
|
|
""";
|
|
|
|
await using var insertCmd = connection.CreateCommand();
|
|
insertCmd.CommandText = insertSql;
|
|
insertCmd.Parameters.AddWithValue("@jobId", jobId);
|
|
insertCmd.Parameters.AddWithValue("@source", dataSource);
|
|
insertCmd.Parameters.AddWithValue("@fromDate", fromDate.ToDateTime(TimeOnly.MinValue));
|
|
insertCmd.Parameters.AddWithValue("@toDate", toDate.ToDateTime(TimeOnly.MinValue));
|
|
insertCmd.Parameters.AddWithValue("@correlationId", correlationId);
|
|
|
|
await insertCmd.ExecuteNonQueryAsync(cancellationToken);
|
|
|
|
// Schedule Hangfire job
|
|
_jobClient.Enqueue<IMarketDataIngestionJob>(j =>
|
|
j.ExecuteAsync(jobId, dataSource, fromDate, toDate, correlationId, CancellationToken.None));
|
|
|
|
return (jobId, expectedCount);
|
|
}
|
|
|
|
public async Task<IngestionJobStatus?> GetIngestionStatusAsync(Guid jobId, CancellationToken cancellationToken)
|
|
{
|
|
const string sql = """
|
|
SELECT job_id, status, rows_processed, rows_failed, rows_skipped, completed_at, duration_seconds, last_error_message
|
|
FROM market_data.ingestion_jobs
|
|
WHERE job_id = @jobId;
|
|
""";
|
|
|
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
await using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
cmd.Parameters.AddWithValue("@jobId", jobId);
|
|
|
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
if (!await reader.ReadAsync(cancellationToken))
|
|
return null;
|
|
|
|
return new IngestionJobStatus(
|
|
JobId: reader.GetGuid(0),
|
|
Status: reader.GetString(1),
|
|
RowsProcessed: reader.IsDBNull(2) ? 0 : reader.GetInt32(2),
|
|
RowsFailed: reader.IsDBNull(3) ? 0 : reader.GetInt32(3),
|
|
RowsSkipped: reader.IsDBNull(4) ? 0 : reader.GetInt32(4),
|
|
CompletedAt: reader.IsDBNull(5) ? null : reader.GetDateTime(5),
|
|
DurationSeconds: reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
|
ErrorMessage: reader.IsDBNull(7) ? null : reader.GetString(7));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Abstraction: Market data ingestion job (Hangfire worker)
|
|
/// </summary>
|
|
|
|
public interface IMarketDataIngestionJob
|
|
{
|
|
Task ExecuteAsync(Guid jobId, string dataSource, DateOnly fromDate, DateOnly toDate, string correlationId, CancellationToken ct);
|
|
}
|