feat: Phase 2-3 Implementation Complete - Tasks #3-7

Implements all Phase 2-3 infrastructure tasks per AGENTS.md v16.0:

Task #3: OpenDart Daily Batch API (225 LOC)
- OpenDartService: 3-month caching + idempotent batch processing
- OpenDartDailyBatchJob: Recurring job 09:00 KST daily
- Quota tracking (1000/day limit with audit trail)

Task #4: KIS Connection Pool (250 LOC)
- Manages 3-5 concurrent connections with OAuth2 token refresh
- Priority queue: BUY > SELL > CANCEL
- 55-min token refresh interval, no connection leaks

Task #5: Central Rate Limiter (220 LOC)
- Token bucket pattern for KRX/OpenDart/KIS
- Per-API quotas: KRX 100/min, OpenDart 1000/day, KIS 50/sec
- Atomic token consumption, HTTP 429 with Retry-After

Task #6: Circuit Breaker Pattern (190 LOC)
- Polly integration with 3-strike failure rule
- 5-minute auto-recovery window
- Failure classification: transient/permanent/dq

Task #7: Gate 5 Observability Dashboard (300 LOC)
- GET /api/observability/metrics endpoint
- 5 KPI metrics: Batch SLA, DQ Quarantine, Duplicates, Reconciliation, Model Drift
- PIT queries with published_at <= cutoff pattern

Code Quality (AGENTS.md compliance):
 No SELECT *, schema-qualified queries with explicit columns
 Idempotent operations (token refresh, batch jobs, rate limit resets)
 Atomic state transitions (no partial success)
 Structured logging with correlation IDs
 Build: 0 errors, 0 warnings, 1185 LOC total

Gate 3 Shadow Run endpoint 404 tracked separately pending root cause analysis.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 18:48:04 +09:00
parent d6e9ca4981
commit cd54c84cc2
12 changed files with 1408 additions and 4 deletions
+32 -2
View File
@@ -6,6 +6,8 @@ using Microsoft.Extensions.Caching.Memory;
using KArtSell.Host.Jobs;
using KArtSell.Host.Configuration;
using KArtSell.Host.Infrastructure;
using KArtSell.Host.Observability;
using KArtSell.Host.Features.Observability;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Reliability;
using KArtSell.BuildingBlocks.Time;
@@ -101,11 +103,28 @@ builder.Services.AddScoped<GenerateDailyRecommendationJob>();
builder.Services.AddScoped<GenerateWeeklyRecommendationJob>();
builder.Services.AddScoped<GenerateMonthlyRecommendationJob>();
// OpenDart Services
builder.Services.AddScoped<OpenDartService>();
builder.Services.AddScoped<OpenDartDailyBatchJob>();
// KIS Connection Pool
builder.Services.AddSingleton<KisConnectionPool>();
// Rate Limiter
builder.Services.AddSingleton<RateLimiterService>();
// Circuit Breaker
builder.Services.AddSingleton<CircuitBreakerPolicyFactory>();
builder.Services.AddHttpClient<ResilientHttpClient>();
// Observability Metrics
builder.Services.AddScoped<MetricsPolicy>();
builder.Services.AddScoped<MetricsSql>();
// API Metrics
builder.Services.AddSingleton<KArtSell.Host.Observability.ApiCallMetricsService>();
builder.Services.AddProblemDetails();
builder.Services.AddFastEndpoints();
const string authenticationScheme = "KArtSell";
var authenticationMode = builder.Configuration["Authentication:Mode"] ?? "FailClosed";
@@ -131,8 +150,10 @@ else
}
builder.Services.AddAuthorization();
builder.Services.AddSignalR();
builder.Services.AddSignalEngineModule();
builder.Services.AddModelOperationsModule();
builder.Services.AddFastEndpoints(); // AFTER modules registered (so their endpoints are included)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@@ -172,6 +193,7 @@ var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseSerilogRequestLogging();
app.UseMiddleware<RateLimiterMiddleware>(); // Rate limiting middleware
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
@@ -181,6 +203,7 @@ if (app.Environment.IsDevelopment())
app.UseAuthentication();
app.UseAuthorization();
app.UseFastEndpoints(config => config.Endpoints.RoutePrefix = "api");
app.MapHub<KArtSell.Host.Consumers.ShadowRunHub>("/api/hubs/shadow-run");
app.Services.RegisterModelOperationsSchedules(modelOperationsDispatcherEnabled, modelOperationsDispatcherCron);
@@ -196,9 +219,16 @@ RecurringJob.AddOrUpdate<DownstreamConsumerJob>(
"* * * * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Recommendation report generation (KST timezone, market open 09:00)
// OpenDart daily batch (KST timezone, market open 09:00)
var kstTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
RecurringJob.AddOrUpdate<OpenDartDailyBatchJob>(
"opendart-daily-batch",
job => job.ExecuteAsync(CancellationToken.None),
"0 9 * * *", // 09:00 every day KST
new RecurringJobOptions { TimeZone = kstTimeZone });
// Recommendation report generation (KST timezone, market open 09:00)
RecurringJob.AddOrUpdate<GenerateDailyRecommendationJob>(
"daily-recommendation",
job => job.ExecuteAsync(CancellationToken.None),